</>CodeWithKarani

NEXT_PUBLIC_ Vars Are Baked at Build Time: Why One Docker Image Can't Serve Staging and Prod

Karani GeoffreyKarani Geoffrey7 min read

You do everything right. You build one Docker image in CI, you tag it, you push it through staging, it passes, you promote the exact same image to production. This is the whole point of containers: build once, run anywhere, no "works on my machine". Then production loads and the browser starts calling https://staging-api.example.com. The same image that behaved in staging is now pointing your customers at the staging backend, and no environment variable you set on the production container changes it.

You set NEXT_PUBLIC_API_URL in the production container's environment. You restart it. Still staging. You exec into the container and confirm echo $NEXT_PUBLIC_API_URL prints the production URL. The shipped JavaScript ignores it completely. This is not a bug and it is not a caching problem. It is Next.js working exactly as designed, and the design surprises almost everyone the first time.

The thesis: a NEXT_PUBLIC_ variable is not read at runtime, it is compiled in at build time. "Build once, deploy everywhere" and "bake the config into the client bundle" are contradictory, and Next.js chose the second. Once you internalise that, the fixes are obvious.

NEXT_PUBLIC_ vars are string-replaced into the client JavaScript during next build. The running container never reads them.

  • The value is frozen at the moment you ran next build. Changing the container's environment afterwards has no effect on client code.
  • Fix A (simplest): build a separate image per environment in CI, passing each environment's values as build args so each bake is correct.
  • Fix B (one image, many envs): do not put runtime config in NEXT_PUBLIC_. Fetch it from a small /config API endpoint, or substitute placeholders at container start with an entrypoint script.
  • Server-only env vars (no NEXT_PUBLIC_ prefix) are read at runtime and do not have this problem. Keep secrets and server config there.

What "build time" actually means here

When you write process.env.NEXT_PUBLIC_API_URL in a component that runs in the browser, Next.js cannot ship a process.env lookup, because there is no Node process in the browser to look it up from. So during next build it does a find-and-replace: every occurrence of that expression in client code is substituted with the literal string value present in the build environment. The output is not "read this variable at runtime", it is the string itself, welded into the bundle.

Concretely, this source:

// client component
fetch(`${process.env.NEXT_PUBLIC_API_URL}/orders`)

after a build where NEXT_PUBLIC_API_URL=https://staging-api.example.com, becomes, in the shipped JavaScript:

fetch(`https://staging-api.example.com/orders`)

There is no variable left. There is nothing for the production container to override. The string is as fixed as if you had typed it by hand. That is why setting the env var on the running container does nothing: the code that would have read it does not exist in the output.

NEXT_PUBLIC_API_URL (client, build-time) next build value read HERE, once string welded into bundle "https://staging-api..." container start: env ignored no lookup left to override DATABASE_URL (server-only, runtime) next build not inlined stays as process.env lookup container start: value read HERE same image, different value
The prefix decides when the value is read. Client code reads it once, at build. Server code reads it every time it runs.

Fix A: build a separate image per environment

If you are comfortable giving up "one image everywhere" for the client bundle, the honest fix is to build once per environment with the correct values baked in. In a Dockerfile, that means accepting the value as a build argument and promoting it to an env var before next build runs.

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

# Accept the public value as a build arg, expose it to the build
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

RUN npm run build      # the value is inlined HERE

Then build each environment explicitly in CI:

# staging image
docker build --build-arg NEXT_PUBLIC_API_URL=https://staging-api.example.com \
  -t myapp:staging .

# production image (a different build, correct value baked in)
docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
  -t myapp:prod .

The cost is real: you no longer promote the identical artifact from staging to prod, so your staging test does not exercise the exact bytes that ship to production. For many teams that is an acceptable trade, and it is simple. If you deploy to your own servers with a pipeline like the one in shipping to your own VPS with GitHub Actions, adding a per-environment build arg is a two-line change.

Fix B: stop baking runtime config into the bundle

If you genuinely need one image across environments, the client must not learn its config at build time. Move the environment-specific value out of NEXT_PUBLIC_ and fetch it at runtime instead.

Option B1: a tiny runtime config endpoint

Serve the public config from the Next.js server, which does read env vars at runtime, and have the client fetch it once on load.

// app/config/route.ts  (a route handler, runs on the server at request time)
export const dynamic = "force-dynamic";  // never statically cached

export function GET() {
  return Response.json({
    apiUrl: process.env.API_URL,   // NOTE: no NEXT_PUBLIC_ prefix
  });
}
// client: read config at runtime, not from a baked constant
let cached: { apiUrl: string } | null = null;
export async function getConfig() {
  if (!cached) cached = await fetch("/config").then((r) => r.json());
  return cached;
}

Because the route is server code, process.env.API_URL is a genuine runtime lookup, so the same image returns staging's URL in the staging container and production's URL in the production container. Mark the route dynamic so Next.js does not statically render it and freeze the build-time value, which would reintroduce the exact bug you are escaping.

Option B2: entrypoint placeholder substitution

The other common pattern keeps the client reading a global, and rewrites that global at container start. You build with a recognisable placeholder, then an entrypoint script replaces it with the real value from the environment before the server boots.

#!/bin/sh
# entrypoint.sh - runs at container start, when real env vars exist
: "${API_URL:?API_URL must be set}"

# Replace the placeholder baked at build time with the runtime value
find /app/.next -type f -name "*.js" -exec \
  sed -i "s|__RUNTIME_API_URL__|${API_URL}|g" {} +

exec node server.js

You build the image once with NEXT_PUBLIC_API_URL=__RUNTIME_API_URL__, so the placeholder is what gets welded in, and the entrypoint swaps it per container. This keeps a single image and needs no change to how the client reads the value. The trade is a startup step that rewrites files and a placeholder you must keep unique enough not to collide with real code.

ApproachOne image?Client code changeBest for
Per-environment build (Fix A)NoNoneSmall number of environments, simplicity over artifact identity
Runtime /config endpoint (B1)YesFetch config on loadNew apps, or where an async config fetch is acceptable
Entrypoint substitution (B2)YesNone (uses a placeholder)Existing apps you cannot easily refactor
Server-only env varYesMove logic server-sideAnything the browser does not strictly need

Verification: prove the value is baked or runtime

You can see the problem with your own eyes. Grep the built client output for the value, and it will be there as a literal if it was inlined.

# After next build, search the client chunks for the baked string
grep -r "staging-api.example.com" .next/static/
# a match means the value is welded into the bundle (build-time)

To confirm a runtime fix works, run the same image with two different environments and check the browser network tab (or the /config response) in each. Same bytes, different backend URL, is the proof that you moved the value from build time to run time.

# same image, two environments
docker run -e API_URL=https://staging-api.example.com -p 3001:3000 myapp:latest
docker run -e API_URL=https://api.example.com          -p 3002:3000 myapp:latest
# /config on :3001 returns staging, on :3002 returns prod

What people get wrong

Assuming env vars behave the same on client and server. They do not. The NEXT_PUBLIC_ prefix is not just a visibility flag, it changes when the value is read: build time for the client, never at runtime. Server vars without the prefix are read at runtime. Mixing up the two is the entire root cause here.

Restarting the container to pick up a new NEXT_PUBLIC_ value. There is nothing to pick up. The value is not read at start; it was consumed during the build. Restarting reads runtime vars, and this is not one.

Rebuilding on the server at deploy time to "read the current env". This works but throws away the benefit of a prebuilt image and couples deploy to a full build. If you are going to rebuild per environment, do it in CI with build args (Fix A), not on the box at deploy.

Putting secrets in NEXT_PUBLIC_ to make them available. Anything with that prefix is compiled into the client bundle and shipped to every browser. It is public by definition. An API key in a NEXT_PUBLIC_ var is an API key you have published. Keep secrets in server-only vars, always.

When it is still broken

  • Runtime fix works locally but the value is still stale in prod. A statically rendered page cached the build-time value. Force the config route or the consuming page to be dynamic, or the static render freezes it again.
  • Entrypoint substitution misses some files. Next.js emits client code in several places under .next. Make sure your find covers all the JavaScript chunks, and that the placeholder survived minification intact (choose a placeholder that will not be transformed).
  • /config returns the wrong value. The server env var is not set in that container, or the route is being cached at the CDN. Confirm the container's environment and add cache-control headers so the config is never cached across environments.
  • Only some vars are wrong. You mixed prefixes: one value is NEXT_PUBLIC_ (baked) and another is server-only (runtime), so they disagree. Pick one strategy per value and be consistent.

The Next.js documentation on environment variables spells out the build-time inlining, and it is worth reading the one paragraph on NEXT_PUBLIC_ until it clicks. The rule that saves you: if the browser needs it and it changes per environment, it cannot be a baked NEXT_PUBLIC_ var. Fetch it at runtime or build per environment.

Frequently asked questions

Why does my NEXT_PUBLIC_ variable still show the old value in the container?
Because NEXT_PUBLIC_ variables are read during next build, not when the container starts. Next.js finds every reference to process.env.NEXT_PUBLIC_X in client code and literally replaces it with the string value that existed at build time. Setting a different value in the running container does nothing, because there is no process.env lookup left in the shipped JavaScript to read it. The value was frozen into the bundle when you built the image.
Can I use one Docker image across staging and production with Next.js?
Not if the environments differ in any NEXT_PUBLIC_ value, because those are compiled into the client bundle at build time. You have two options: build a separate image per environment in CI so each bakes the correct values, or stop baking public config into NEXT_PUBLIC_ vars and instead fetch runtime config from an API endpoint or inject it at container start. Server-only env vars do not have this limitation and can be shared across environments.
What is the difference between build-time and runtime env vars in Next.js?
NEXT_PUBLIC_ variables are build-time: they are inlined as string constants into the client-side JavaScript during next build and cannot change afterwards. Plain (non-prefixed) env vars are runtime: they are read from process.env on the server whenever the code runs, so the same image reads whatever the container provides at start. The rule of thumb is that anything the browser needs is build-time and frozen; anything only the server touches is runtime and flexible.
How do I inject runtime public config into a Next.js Docker container?
Use an entrypoint script that runs at container start and substitutes placeholder tokens in the built client files with the real values from the container's environment, or serve a small /config endpoint from the Next.js server that the client fetches on load. Both approaches keep a single image and move the environment-specific value from build time to start time. The entrypoint approach is common because it needs no code change to how the client reads config beyond a one-time bootstrap.
#Next.js#Docker#Environment Variables#CI/CD#Deployment
Keep reading

Related articles