Back to Blog

Next.js Environment Variables Undefined After Docker Build

Published: July 29, 2026
Next.js Environment Variables Undefined After Docker Build

Next.js environment variables undefined after Docker build is a bug that survives an embarrassing number of "just add it to the Dockerfile" attempts, because the actual cause has nothing to do with whether the variable exists somewhere — it's about exactly when Next.js reads it. NEXT_PUBLIC_ prefixed variables aren't read at container startup, or even when the app receives its first request. They're read once, during next build, and permanently baked into the compiled JavaScript. If that variable wasn't present in the environment next build actually ran in in, no amount of setting it later fixes code that's already compiled.

Short answer: NEXT_PUBLIC_ variables need to exist in the Docker build stage that runs next build, passed explicitly as build arguments — setting them as regular container runtime environment variables afterward does nothing, because the value is already permanently inlined into the JavaScript bundle by the time the container even starts.

A crumpled sheet of paper with a large hand-drawn question mark on it, representing an environment variable that comes back undefined instead of holding the value you actually set

Why the Variable Works Locally But Not in the Docker Build

Next.js's own documentation on environment variables confirms exactly this distinction. Locally, running next dev or next build directly, your .env file sits right there in the working directory, and Next.js reads it at exactly the moment it needs to — no separate build environment to worry about. Inside a Docker multi-stage build, the next build command runs in a completely isolated build stage, and that stage only has access to environment variables and files you've explicitly given it. If NEXT_PUBLIC_YOUR_VAR isn't actually set in that specific stage's environment when next build executes, Next.js inlines undefined into the compiled bundle — and that's now a permanent, unchangeable part of the built JavaScript, regardless of what environment variables you set on the container afterward.

(If you've been setting the variable in Render's or Railway's dashboard and wondering why it's still undefined in the browser — that's exactly the trap. Those platform-level environment variables apply at container runtime, and NEXT_PUBLIC_ variables needed to exist several steps earlier, during the build itself.)

Fix: Pass NEXT_PUBLIC_ Variables as Docker Build Arguments

The correct pattern declares the variable as a build argument in the same stage that runs next build:

Dockerfile
1# Dockerfile
2FROM node:20-alpine AS build
3WORKDIR /app
4
5ARG NEXT_PUBLIC_API_URL
6ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
7
8COPY package*.json ./
9RUN npm ci
10COPY . .
11RUN npm run build
12
13FROM node:20-alpine AS production
14WORKDIR /app
15COPY --from=build /app/.next ./.next
16COPY --from=build /app/package*.json ./
17RUN npm ci --omit=dev
18CMD ["npm", "start"]
Bash
1# The actual value has to be passed explicitly at build time
2docker build \
3  --build-arg NEXT_PUBLIC_API_URL=https://api.yourapp.com \
4  -t your-app .

Docker's own reference on ARG covers its full scoping rules — critically, an ARG declared before a FROM line doesn't automatically carry into any stage after it without being re-declared. The ARG and ENV pair has to live in the build stage specifically — declaring it in the production stage does nothing, since next build already ran and finished in the earlier stage by the time the production stage even starts. The value passed via --build-arg on the command line is what actually reaches process.env.NEXT_PUBLIC_API_URL during the build.

Fresh loaves of bread and pastries sitting inside a warm, glowing oven, representing an environment variable value that gets permanently baked into the JavaScript bundle at build time

The Trap: .dockerignore Excluding .env

If you're relying on copying a .env file into the build stage instead of passing explicit --build-arg flags, check whether .dockerignore excludes it — a common, security-conscious default that quietly breaks this exact flow:

Bash
1# .dockerignore — common pattern that excludes the very file the build needs
2.env
3.env.local
4node_modules

Excluding .env from what's copied into the build stage is generally the right call for secrets, but it means next build never sees the values inside it either, unless you're deliberately passing the NEXT_PUBLIC_ values through explicit --build-arg flags instead of relying on the file being present. Confirming which of these two paths your CI/CD pipeline actually uses is worth a direct check before assuming the Dockerfile itself is wrong.

A Runtime Workaround: Placeholder Values and Entrypoint Substitution

If rebuilding the entire image for every environment (staging, production, each client's white-label instance) is genuinely too slow, one documented technique builds with a placeholder value and swaps it for the real one the moment the container starts:

Dockerfile
1# Dockerfile — build with a recognizable placeholder instead of a real value
2ARG NEXT_PUBLIC_API_URL=__RUNTIME_NEXT_PUBLIC_API_URL__
3ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
4# ... rest of build stage
5
6ENTRYPOINT ["/app/entrypoint.sh"]
Bash
1#!/bin/sh
2# entrypoint.sh — replaces the placeholder across the built output at container start
3find /app/.next -type f -exec sed -i "s|__RUNTIME_NEXT_PUBLIC_API_URL__|${NEXT_PUBLIC_API_URL}|g" {} +
4exec npm start

A real discussion thread on this exact problem covers several teams' variations of this pattern if your setup needs something more elaborate than the version shown here. This is honestly a start-time substitution rather than true runtime configuration — the value is fixed for the life of that container once it boots, not changeable mid-request — but it does mean the same built image can serve different environments without a full rebuild for each one, at the cost of one extra shell script most teams never end up needing.

Several blank green tags hanging on strings against a black background, representing placeholder values swapped for the real environment variable the moment a container actually starts

The Opinion Part

Here's the pattern worth naming, because it's a specific instance of a theme running through most Docker-plus-framework bugs in this series: a framework feature named NEXT_PUBLIC_ is telling you plainly what it does — reaches the public, client-side bundle — but says nothing about when that happens, and the "when" is exactly where Docker's staged, isolated build environment breaks an assumption that held fine on a single machine running next dev. Reading the actual lifecycle of a config value — build-time versus runtime — before assuming "set it as an env var" is a complete instruction, is the same discipline that keeps this whole category of bug from costing an afternoon of confused Dockerfile edits that all individually look correct.

Conclusion

If Next.js environment variables come back undefined after a Docker build, the fix almost never involves the container's runtime environment at all — NEXT_PUBLIC_ variables need to exist in the build stage itself, passed explicitly via --build-arg, since they're inlined into the JavaScript bundle once, during next build, and never read again afterward. Check that ARG/ENV live in the correct stage, confirm .dockerignore isn't quietly excluding the .env file your build was counting on, and reach for the placeholder-substitution pattern only if rebuilding per environment genuinely doesn't scale for your team.

If the build itself is also missing files that should have been copied into the final image, our Docker multi-stage build guide covers that adjacent class of "did the build actually include what I expected" bug, and if you're baking NEXT_PUBLIC_ values into a fully static export rather than a running Docker server, our API routes and static export guide covers the equivalent build-time-versus-runtime distinction for that deployment model.

Pass the value at the stage that actually needs it, and let the bundle ship with the real URL instead of the word "undefined" quietly baked in where a config value used to be.

Frequently Asked Questions

Because NEXT_PUBLIC_ prefixed variables get inlined directly into the compiled JavaScript bundle during next build — they're read once, at build time, and baked into the static output as literal values. Locally, your .env file is sitting right there when you run next dev or next build, so the value gets read correctly. Inside a Docker build, if that same environment variable isn't actually present in the build stage's environment, Next.js bakes in undefined instead of a real value, and no amount of setting it later at container runtime fixes already-compiled code.

NEXT_PUBLIC_ prefixed variables are explicitly meant to reach client-side JavaScript, which means they must be resolved at build time and get permanently inlined into the bundle everyone's browser downloads. Regular, non-prefixed variables stay server-side only, are read from process.env at actual runtime, and can be set on the running container without any rebuild — a genuinely different lifecycle that a lot of Docker-plus-Next.js confusion comes from mixing up.

Declare them as build arguments in the same Dockerfile stage that runs next build, using ARG to accept the value and ENV to expose it to the build process, then pass the actual value with --build-arg when you run docker build. Declaring the ARG in the wrong stage, or forgetting to pass --build-arg at all, are the two most common reasons the variable still comes through as undefined even after adding the Dockerfile lines.

Check whether .env itself is actually reaching the build stage — a .dockerignore file that excludes .env (common, for good security reasons) means the file never makes it into the Docker build context at all, so even correctly written ARG/ENV lines have no source value to pick up unless you're passing --build-arg explicitly on the command line instead of relying on a copied .env file.

Yes, with a workaround: build with placeholder values, then use a shell script in the Docker ENTRYPOINT that runs a find-and-replace across the compiled output, swapping the placeholder for the real value the moment the container starts. It's a genuine technique used to avoid rebuilding the same image per environment, though it's more accurately a start-time substitution than true runtime configuration — the value is still fixed for the life of that specific container.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio