All posts

27 Oct 2026 · Docker & Infra

Next.js 15 Docker Environment Variables, Build-Time vs Runtime

Why process.env is undefined in Docker, why NEXT_PUBLIC values need rebuilds, and how to keep DATABASE_URL and Stripe secrets out of the image.

If your container logs undefined for process.env.SOMETHING, or a secret shows up in the browser bundle, you are mixing two different Next.js 15 phases: next build and node server.js.

The ARG vs runtime split below is in the MIT starter if you want the files next to this guide: Agentic-Packs/agenticpacks-oss. If you need the complete multi-container setup with Postgres and Caddy, start with the production standalone deployment guide.

Root cause

App Router is hybrid.

During docker build, next build runs in the builder stage. It:

  • Evaluates Server Components that can be statically generated
  • Bundles Client Components
  • Replaces process.env.NEXT_PUBLIC_* with string literals in both the client and server graphs

At container start, CMD ["node", "server.js"] reads everything else from the process environment. DATABASE_URL is not in the JS bundle unless you put it in a NEXT_PUBLIC_* name or inlined it during build.

So:

  • A public URL missing at build is frozen as undefined inside that image tag.
  • A server secret missing at runtime is undefined until you inject it with Compose, even if the image is correct.

Those two failures look identical in a log line. They are not the same bug.

The NEXT_PUBLIC trap

NEXT_PUBLIC_SITE_URL is inlined at next build. Docker ENV on the running container cannot change it. You have to pass it as a build arg, copy it into ENV before npm run build, then rebuild when the value changes.

From our production Dockerfile:

FROM node:22-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production

ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_SUPPORT_EMAIL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SUPPORT_EMAIL=$NEXT_PUBLIC_SUPPORT_EMAIL

RUN npm run build

ARG alone is not enough. Next reads process.env, not Docker build-args. The ENV lines make them visible to next build.

Compose must feed those args at build time:

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
        NEXT_PUBLIC_SUPPORT_EMAIL: ${NEXT_PUBLIC_SUPPORT_EMAIL}

And the build command has to load the file that contains those values:

docker compose --env-file .env build web

env_file: on the service does not participate in next build. It only appears after node server.js starts.

Checklist when a public variable is undefined in the browser:

  1. Name starts with NEXT_PUBLIC_
  2. It is declared as ARG and ENV in the builder stage
  3. docker compose --env-file .env build actually passed a non-empty value
  4. You rebuilt the image after changing it

Runtime server secrets

Keep these out of ARG and out of the image:

  • DATABASE_URL
  • AUTH_SECRET
  • STRIPE_SECRET_KEY
  • STRIPE_WEBHOOK_SECRET
  • other API keys

Inject them only at runtime:

services:
  web:
    env_file:
      - .env
    environment:
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app

.dockerignore must exclude env files:

.env
.env.*

If you COPY . . with .env still in the build context, the secret is in a layer even if you never ENV it. Anyone with the image can docker history or copy the file out.

next.config.ts does not need to list env keys. Next 15 already inlines NEXT_PUBLIC_* and leaves the rest to process.env on the server. Do not add:

env: {
  DATABASE_URL: process.env.DATABASE_URL,
}

That pattern pushes a server secret into the Next config object and can leak it into client compilation.

Tired of chasing undefined env vars across build and runtime?

The ARG vs env_file split is in the MIT starter: Agentic-Packs/agenticpacks-oss.

Pro injects Stripe and database secrets at runtime through Terraform. See runtime secrets and Terraform env in Pro.

Fail fast with Zod

Silent undefined mid-request is how you ship a broken checkout. Validate once at module load on the server:

import { z } from "zod";

const serverEnv = z.object({
  DATABASE_URL: z.string().url(),
  AUTH_SECRET: z.string().min(32),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
});

const publicEnv = z.object({
  NEXT_PUBLIC_SITE_URL: z.string().url(),
});

export const env = {
  ...serverEnv.parse(process.env),
  ...publicEnv.parse({
    NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
  }),
};

Import env from a file that is only used in Server Components, Route Handlers, and auth.ts. Do not import it from a Client Component. If a variable is missing, the container exits (or the first server import throws) instead of rendering a payment page with an empty Stripe key.

@t3-oss/env-nextjs is the same idea with a client / server split and a built-in skip for NEXT_PUBLIC_ misuse.

Dockerfile and Compose together

Builder: public args only. Runner: no public rebuild, secrets via Compose.

FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs \
  && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
    env_file:
      - .env
    environment:
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
      AUTH_SECRET: ${AUTH_SECRET}
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}

next.config.ts stays:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
  reactStrictMode: true,
};

export default nextConfig;

Runtime secrets then work with output: standalone because server.js is a normal Node process. It reads process.env from Compose. Public keys still need a rebuild.

Same split in the starters: site URLs and other NEXT_PUBLIC_* values are build args. DATABASE_URL, AUTH_SECRET, and Stripe secrets are runtime env_file values and never ARG.


Where to Go From Here

You do not have to reverse-engineer an environment variable wiring from a blank repo. Pick a path based on where the project is.

Option A: Free open-source baseline (MIT)

Inspect the Dockerfile ARG/ENV block and Compose runtime secrets:

  • GitHub: github.com/Agentic-Packs/agenticpacks-oss
  • What is included: standalone image, Compose, PostgreSQL 16, Auth.js v5, single-plan ($19/mo) Stripe checkout with webhooks and a credit counter, and the build-time vs runtime split from this post
  • Cost: free, MIT. Star the repo if this guide saved you a debugging session.

Option B: Ship with AgenticPacks Pro ($149 one-time)

If Stripe keys, webhook secrets, and host env should never enter the image:

  • Automated Hetzner provisioning: Terraform injects runtime secrets into Compose. No rebuild to rotate DATABASE_URL.
  • Production multi-agent core: LangGraph, pgvector RAG, Redis on the internal network.
  • Usage & Unit Economics: Stripe subscriptions, webhooks, and real-time token-to-dollar cost ledger.