Undated · 8 min read (1,475 words at 200 wpm)
The runtime environment variable that could not change a statically built URL
A note on the code below: every snippet in this post is illustrative. It is written to show the mechanism as plainly as possible, not copied out of the production codebase. The bug, the diagnosis and the fix are real; the exact lines are a reconstruction.
The infrastructure code said the site's public URL was one hostname. The deployed page — canonical link, Open Graph tags, JSON-LD, every absolute link in the footer — said a different, older one. It had been saying it for months.
Neither was lying. The variable was set. The container did receive it. The page simply never read it, because by the time the container existed, the value that mattered had already been compiled into a static file.
This is the most boring class of bug I know and one of the most expensive, because every layer involved is individually correct and the failure produces no error at any layer. Configuration that silently does nothing looks exactly like configuration that works.
Three reasonable decisions
The failure needed all three of these, and each was defensible on its own.
1. The framework inlines that class of variable at build time. Next.js
replaces NEXT_PUBLIC_* references in the bundle during next build — textual
substitution, not a runtime lookup. This is deliberate and documented: the value
has to reach the browser, and the browser has no environment. After the build,
there is no process.env read left to intercept. The code that looks like a
runtime read:
// lib/site.ts — reads like a runtime lookup, is not one
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ?? 'https://previous-host.example';
compiles to the equivalent of:
export const SITE_URL = 'https://previous-host.example';
...whenever the variable is absent at build time. Which it was.
2. The image was built without the variable defined. Container images were built remotely by the registry's build service. Application secrets and environment configuration live in the deployment layer, which is the right default — you do not want build inputs to be the place your configuration is established. The consequence here is that the build saw no value and baked in the fallback.
3. The page was statically prerendered. The landing page has no per-request
data, so the framework rendered it once at build time and shipped HTML. The
metadata derived from SITE_URL — canonical tag, social card URLs, structured
data — was frozen into that HTML. Even a genuine runtime read would have arrived
after the only moment it could have mattered.
Stack those and you get deployment configuration that is set correctly, propagates correctly, deploys cleanly, and has no effect:
# The deployment says this. The page disagrees. Both are "right".
env = {
NEXT_PUBLIC_SITE_URL = "https://integrahive.ai"
}
How it was actually found
Not by an alert. Not by a test. By someone comparing the deployed HTML against the deployed configuration and noticing that they disagreed:
curl -s https://<the-deployed-host>/ | grep -o 'rel="canonical"[^>]*'
# → the old hostname
# ...while the deployment's environment showed the new one.
That is the diagnostic, and it is worth internalising as a habit rather than an incident response. Every configuration value has a declaring layer and a consuming layer, and the only interesting question is whether they agree. Most config verification checks the declaring layer, because that is the layer you can query with a CLI. A variable present in a deployment manifest proves the manifest.
Verify configuration at the layer that consumes it, not the layer that declares it.
The real question: where is this resolved?
The generalisation is not "beware NEXT_PUBLIC_". It is that every
configuration value is resolved at some specific moment, that moment is usually
implicit, and everyone on the team has a different assumption about which one it
is.
| Resolved at | Mechanism | Changing it requires | Silent-failure mode |
|---|---|---|---|
| Build time | Bundler substitution, prerendered HTML, compile-time constants, baked image layers | A rebuild | Deploy-time value is inert; the old value is inside the artefact |
| Container start | process.env read at module load, config file parsed on boot |
A restart | Value changes in the platform, running instances keep the old one |
| Request time | Per-request lookup, feature-flag service, header or cookie | Nothing | Latency and a dependency on the flag service being up |
| Edge | CDN rules, response-header files, redirects | An edge deploy | Application tests never see it at all |
The trap is that all four look identical in source. process.env.X in a server
module is a start-time read. The same expression in a client component is a
build-time substitution. The same expression in a request handler is a
per-request read of a value that was already frozen at start-time by the
runtime. Three different lifecycles, one syntax.
Once you start asking the question, the same shape turns up everywhere:
- A log level read once at module load. Changing it in the platform does nothing until every instance restarts, and the change is reported as applied.
- A feature flag compiled into a client bundle. It flips for new sessions after the next deploy, not when you toggle it.
- A config file mounted into a container and read at startup. Updating the mounted content updates the file on disk and nothing in memory.
- A
.envfile that the framework loads in development and pointedly does not load in production, so the production default has never been exercised locally.
Every one of these produces the same experience: you change the value, the system reports the change, and the behaviour does not move.
The fix: stop pretending it is dynamic
The instinct is to make the value genuinely dynamic — server-render the page, read the variable at request time, thread it through. That is a real amount of machinery bought with no benefit, because the value changes roughly never. It is a canonical origin. It changes when the product is renamed.
So the replacement site does the opposite. The value is a build-time constant, declared once, in one file, with a comment that explains why it is not a variable:
/**
* Canonical public origin. No trailing slash. The single flip point.
*
* There is deliberately no runtime environment variable: the site is a static
* export, so a runtime value could never reach the HTML anyway — that exact
* mistake is why the previous landing page served a stale hostname for months.
*/
export const SITE_URL = 'https://integrahive.ai';
Everything absolute on the site derives from it — metadata, canonical tags, JSON-LD, sitemap, robots. Changing origin is a one-line edit, which is roughly the cost the actual change frequency justifies.
Then two layers of check, because a constant is only as good as the assertion that it reached the artefact.
Source-level tests pin the invariants that are decidable in the repository:
the origin is HTTPS, is the bare apex with no trailing slash, carries no
credentials, is not the www host, and every generated sitemap entry resolves
against it. Also — and this is the one that earns its keep — a test that the
retired hostnames appear nowhere in shipped source, so the old value cannot
creep back in through a copy-paste.
A post-build check inspects the exported artefact, which is the only place that can tell you what actually shipped:
// Runs after `next build`, against out/ — the thing that deploys.
const html = fs.readFileSync(path.join(OUT, 'index.html'), 'utf8');
if (!html.includes(`rel="canonical" href="${SITE_URL}/"`)) {
fail('exported homepage does not carry the canonical origin');
}
That check has one property that matters more than its logic: it fails closed. An earlier version returned early when the output directory was absent — and because that directory is gitignored, it asserted nothing on a fresh checkout while reporting green. A verification that passes vacuously is worse than no verification, because it manufactures confidence without coverage.
What I would do differently
Ask "what layer resolves this?" before writing the value anywhere. It is one question and it has one correct answer per value. Writing that answer in a comment next to the declaration costs nothing and would have prevented this entirely.
Make inert configuration loud. If a build-time variable is genuinely required, fail the build when it is missing instead of falling back:
const raw = process.env.NEXT_PUBLIC_SITE_URL;
if (!raw) throw new Error('NEXT_PUBLIC_SITE_URL must be set at build time');
A default value is a decision to keep going with the wrong answer. That is
sometimes right — and when it is, the default should be obviously wrong
(https://unset.invalid) rather than a plausible stale hostname that survives
review because it looks like a real address.
Never let a fallback be a previously-correct value. This was the specific detail that made the bug survive months of people looking at it. The stale hostname was not obviously wrong; it was last year's right answer, which reads as intentional in every diff it appears in.
Assert against the artefact, not the source. Source tests prove what you wrote. Only a check against the built output proves what ships — and the build is exactly where this class of value gets fixed in place.
Prefer one flip point over configurability you will not use. The urge to make a value configurable is usually an urge to avoid deciding where it lives. A constant in one file, with tests, is more honest and considerably easier to verify than a variable threaded through four layers, three of which cannot read it.
Related reading
- Engineering notes — the original write-up, plus fail-closed infrastructure guards
- Architecture — how the site and the platform are deployed
- Tenant isolation — a related failure: a control whose evidence did not survive the process that produced it