COLUMN
Next.js Environment Variables in Docker: Why Build-Time and Runtime Don't Mix

Ship the same Next.js image to staging and production, and the API URL still points at staging. That's not a caching bug — it's how NEXT_PUBLIC_* variables work.
If you've containerized a Next.js app and tried to promote one built image from staging to production, you've probably hit this. The build succeeds, the container starts, the health check passes — and the browser still calls the wrong backend. The image looks identical. The environment variable is definitely set correctly in the deployment manifest. And yet the value in the running app is the one from whichever environment built the image.
This isn't a Docker problem or a Kubernetes problem. It's what Next.js does with client-exposed environment variables at build time, and it changes how you have to design your container pipeline.
Build-time vs. runtime: two different process.env
Next.js draws a hard line between two kinds of environment variables:
- Server-only variables (no
NEXT_PUBLIC_prefix) are read fromprocess.envwhen server code actually runs — in Route Handlers, Server Components,getServerSideProps, or middleware. Change the value in the container's environment and the next request sees the new value. - Client-exposed variables (prefixed
NEXT_PUBLIC_) are substituted directly into the JavaScript bundle duringnext build. The stringprocess.env.NEXT_PUBLIC_API_URLin your source is literally replaced with the value present at build time, and that value ships to the browser baked into a static.jsfile.
This is documented behavior, not a bug: the Next.js docs on environment variables are explicit that NEXT_PUBLIC_ values are "inlined into the JavaScript bundle during the build process." Once that bundle exists, there is no process.env left to read — the value is a string literal sitting in a static file that gets served to every browser, regardless of which container serves it.

Why "build once, deploy everywhere" breaks
Most teams containerizing Next.js want the same artifact promoted through staging, then production — build once, sign it, deploy it twice. That's the entire point of a private registry and a promotion pipeline: you're testing the exact bytes that will run in production, not a re-build that happens to look the same.
NEXT_PUBLIC_ variables undo that guarantee for anything that touches the client bundle. If NEXT_PUBLIC_API_URL is set to the staging endpoint when the image is built, that value is permanent for that image — no amount of setting environment variables on the container, in a Kubernetes ConfigMap, or in docker run -e will change it, because nothing in the running container ever reads process.env for that value again. The only way to get a different value with a plain build is to build a second image. Now you have two artifacts to test, sign, and track instead of one, and the thing you promote to production is no longer the thing you tested in staging.
Teams usually discover this the same way: a client-visible config value is wrong in one environment, someone sets the correct value and redeploys, and the value in the browser doesn't change. The fix isn't "set the variable correctly" — it's "stop expecting a built bundle to read variables at all."
Three ways to actually fix it
1. Move the value off the client bundle entirely
If a config value is only used to make a request, put it behind a same-origin API route instead of exposing it to the client:
// app/api/config/route.ts — runs server-side, reads process.env per request
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ apiUrl: process.env.API_URL });
}
The browser fetches /api/config instead of reading a baked-in constant, and the value comes from process.env on the server at request time — which behaves exactly like you'd expect from a container. This is the simplest fix when the "public" variable was never something that needed to be public.
2. Runtime substitution in the entrypoint
When you do need a value in the client bundle (feature flags, third-party SDK keys meant for the browser), build with a placeholder and replace it when the container starts, before the server accepts traffic:
FROM node:20-alpine AS build
WORKDIR /app
COPY . .
ENV NEXT_PUBLIC_API_URL=__RUNTIME_API_URL__
RUN npm ci && npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
COPY entrypoint.sh ./
ENTRYPOINT ["./entrypoint.sh"]
entrypoint.sh runs once, before node server.js starts, and replaces the placeholder with the real value for that container:
#!/bin/sh
set -eu
find /app/.next -type f \( -name "*.js" -o -name "*.html" \) \
-exec sed -i "s|__RUNTIME_API_URL__|${NEXT_PUBLIC_API_URL}|g" {} +
exec node server.js
One image, built once. The value that ends up in the bundle is whatever NEXT_PUBLIC_API_URL is set to on that specific container — which in Kubernetes means a ConfigMap or Secret mounted as an environment variable, no rebuild required.

3. Accept that some values genuinely belong to the build
Not every difference between environments should be runtime-configurable — feature flags tied to a specific code path, or values that change the output of static generation, are legitimately build-time concerns. The fix there isn't substitution; it's being deliberate about which variables are build-time by design versus which ones only look that way because NEXT_PUBLIC_ was the easiest prefix to reach for.
Getting the pipeline around it right
Once the image itself stops caring which environment it lands in, the rest of the pipeline gets simpler, and it's worth setting up properly rather than patching it later:
- Store the one built image somewhere both environments can pull from. A private container registry gives you a single source of truth for the artifact — the same digest that passed staging is the one deployed to production, not a same-looking rebuild.
- Let CI build and push once, on every merge. Wiring the build into a CI/CD pipeline means the substitution step in your entrypoint runs the same way every time, instead of depending on whoever happened to run
docker buildlocally.
This is the point where teams realize a single Docker image can't carry per-environment config baked into its JS bundle. Kubo runs standard Kubernetes, so the same image is promoted across environments with ConfigMaps and Secrets injected at runtime — no rebuild, no drift.
Promoting the image, not rebuilding it
The last piece is how the same image digest actually moves from staging to production. Manually re-pointing a deployment at a new tag works until someone forgets a step or promotes the wrong digest. Declarative, Git-driven promotion removes that manual step: the environment's desired state — which image digest, which ConfigMap values — lives in a Git repository, and a controller reconciles the cluster to match it.

If you're already managing multiple environments by hand, GitOps removes the manual promotion step entirely — see a practical GitOps walkthrough for how to wire it up.
The underlying lesson holds regardless of which tools you pick: in Next.js, "build time" and "runtime" are not two names for the same moment, and a container promotion pipeline only works once every environment-specific value picks the right one.