Skip to content
IntegraHive
Engineering blog

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 .env file 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

Written for

Engineers who deploy web applications as containers, and anyone who has ever changed a config value and watched nothing happen.