The ticket was small. A security review of a legacy PHP 8.2 site for an equipment financing company had flagged API calls by IP address and asked for the internal FQDN instead, so TLS validation and network policy could do their jobs. The site has a payment calculator on its credit-application pages, and the calculator fetches rate programs from an internal REST API over curl. Swap the IP literal for the hostname, commit, done.

Except the IP wasn't the interesting part. The interesting part was how the code chose it.

Guessing the environment from your own address

// Point at the RMS API on the same subnet as the web server.
$serverIp = $_SERVER['SERVER_ADDR'] ?? '';
if (str_starts_with($serverIp, '10.1.8.')) {
    $base_url = "10.1.8.11:9444";            // prod
} elseif (str_starts_with($serverIp, '10.1.148.')) {
    $base_url = "10.1.148.166:9444";         // test
} else {
    $base_url = "host.docker.internal:6000"; // local dev
}

(Addresses changed, structure intact.) It's a tidy idea. The web server knows which subnet it's on, prod and test live on different subnets, so the server can work out which API to talk to without any configuration at all. No env files to forget, no deploy step to get wrong. Someone was pleased with this when they wrote it, and I understand why.

I did the FQDN swap and sent it for review. That's when the client mentioned, almost in passing, that they run Zerto for disaster recovery.

What Zerto does to that if statement

Zerto replicates the VMs continuously and, when DR conditions are met, brings them up from a recovery checkpoint at the other site. For this client the other site is another region, on a different address range. The recovered web server comes up with an address that isn't on 10.1.8.x.

Walk the code with that in mind:

  1. SERVER_ADDR matches neither prefix, so it falls into the else branch. Production is now configured as a developer laptop and tries to reach host.docker.internal:6000, which doesn't exist on a real server.
  2. The fetch layer has a fallback. When the API call fails, it serves the cached programs.json from disk and carries on.
  3. The page renders. No 500, nothing in the logs worth a second glance.

The calculator keeps working, on rates frozen at the moment of the snapshot. Each day after the failover those numbers drift a little further from reality, in a financial product where the numbers are the product.

That's the part that bothered me most. Subnet detection doesn't fail loudly on a normal day. It fails silently on the one day everyone is busy watching whether the failover worked at all, and nobody is reading application internals.

Config belongs in the environment

The fix isn't clever, which is the point. phpdotenv with two files:

  • .env, tracked in the repo, holds the safe local-dev default: RMS_API_URL=http://host.docker.internal:6000
  • .env.local, untracked and written once per server, holds the real FQDN for prod and for test

phpdotenv doesn't pick up .env.local on its own, and given two file names it stops at the first one it can read. The third argument turns that off:

Dotenv::createImmutable(__DIR__, ['.env', '.env.local'], false)->load();

Both files get read, and a key in .env.local wins over the same key in .env.

The property that matters for DR: .env.local lives on the VM's disk. The replicated VM carries it along. The recovered server comes up pointing at the right API no matter which subnet it lands on, and there's nothing to detect and nothing to re-provision.

Two things that bit on the way

A healthcheck.php included the calculator helpers without the bootstrap that loads dotenv. The health check would have run with empty env vars while every normal page was fine, so it would have reported healthy by exercising nothing. The include file now require_onces its own bootstrap. require_once dedupes by real path, so the double load from normal pages is a no-op.

The silent cache has one more way to bite: if .env.local is missing on a fresh deploy, there is still no error. Just stale cache again. That's now a line on the deployment checklist, and the health check calls the real endpoint instead of just loading the helpers. Reporting the cache age from the same health check is the next item on the list.

Takeaway

Implicit environment detection encodes assumptions about infrastructure that ops can invalidate without ever opening your repo. Subnets, hostname patterns, reverse DNS, all of them. Disaster recovery is the canonical invalidator, because its whole purpose is to put your app somewhere else.

Graceful degradation needs a witness. A fallback that nobody logs or alerts on doesn't soften an incident, it hides it. Log the fallback path, alert on it, and put the cache age where a human will see it.

One more thing the audit turned up, unrelated to any of this: a dead legacy page in the webroot still curl-ing a decade-old API hostname. Audits that start with one narrow question tend to surface corpses. Grep wide.


Inherited a legacy app with a landmine like this? Send me your problem and I'll help you find the rest of them.