Docker and Kubernetes
Build one image, then hand it different values at docker run. The image never needs a rebuild to point at a new API, flip a flag, or move from staging to production.
docker run -e NEXT_PUBLIC_API_URL=https://staging.example.com my-app
docker run -e NEXT_PUBLIC_API_URL=https://api.example.com my-appPrepare the app
Render PublicEnvScript (or PublicEnvProvider) in the root layout, as in the quick start. Then turn on standalone output so Next.js emits a self-contained server.js with only the files it needs:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
}
export default nextConfigRead public values through next-dynenv
Use env(), useEnvContext(), or envParsers for public values. A literal process.env.NEXT_PUBLIC_API_URL in client code is still replaced by Next.js at build time, and that value ships inside the image.
Dockerfile
This is a three-stage build: install, build, then a slim runtime image that runs as a non-root user. It uses npm; swap in your package manager's install and build commands if you use pnpm, Yarn, or Bun.
FROM node:24-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
# The standalone server binds to $HOSTNAME, which Docker sets to the container id
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
# Drop this line if your app has no public/ directory
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"]The build stage needs no NEXT_PUBLIC_* values. They arrive when the container starts.
Keep local env files out of the build context so the image only ever sees what you pass at run time:
# .dockerignore
.env*
.next
node_modules
.gitExpose unprefixed variables
If your platform hands you API_URL rather than NEXT_PUBLIC_API_URL, call makeEnvPublic() from instrumentation.ts. Next.js runs register() once when the server starts, in every output mode:
// instrumentation.ts (or src/instrumentation.ts)
import { makeEnvPublic } from 'next-dynenv'
export function register() {
makeEnvPublic(['API_URL', 'APP_NAME'])
}Not in next.config
A standalone server.js embeds a serialized copy of your config and never evaluates next.config.ts again. A makeEnvPublic() call there runs during next build only, so the container never sees the copied variables.
Run the container
docker build -t my-app .
docker run -p 3000:3000 \
-e NEXT_PUBLIC_API_URL=https://api.example.com \
-e NEXT_PUBLIC_APP_NAME="My App" \
-e DATABASE_URL=postgres://user:pass@db:5432/app \
my-appOr keep the values in a file outside the image:
docker run -p 3000:3000 --env-file ./production.env my-appTo check what the browser received, open the app and run window.__ENV in the devtools console.
Docker Compose
# compose.yaml
services:
app:
build: .
ports:
- '3000:3000'
environment:
NEXT_PUBLIC_API_URL: https://api.example.com
NEXT_PUBLIC_APP_NAME: My App
DATABASE_URL: postgres://app:app@db:5432/app
depends_on:
- db
db:
image: postgres:17
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: app
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:Layer an override file per environment and the same image picks up different values:
# compose.staging.yaml
services:
app:
environment:
NEXT_PUBLIC_API_URL: https://staging.example.comdocker compose -f compose.yaml -f compose.staging.yaml upKubernetes
Put public values in a ConfigMap and secrets in a Secret, then load both into the container:
apiVersion: v1
kind: ConfigMap
metadata:
name: my-app-config
data:
NEXT_PUBLIC_API_URL: https://api.example.com
NEXT_PUBLIC_APP_NAME: My App
---
apiVersion: v1
kind: Secret
metadata:
name: my-app-secrets
type: Opaque
stringData:
DATABASE_URL: postgres://user:pass@db:5432/app
API_SECRET_KEY: replace-me
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: app
image: registry.example.com/my-app:1.4.0
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: my-app-config
- secretRef:
name: my-app-secretsEnvironment variables are read when a container starts. After you edit the ConfigMap or Secret, restart the pods; no rebuild is involved:
kubectl rollout restart deployment/my-appHealth checks
A route handler makes a cheap readiness probe and confirms which config the container booted with:
// app/api/health/route.ts
import { env } from 'next-dynenv'
export function GET() {
return Response.json({ status: 'ok', app: env('NEXT_PUBLIC_APP_NAME', 'unknown') })
}HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1Next steps
- Other platforms: Fly.io, Cloud Run, ECS, and friends
- Making env public: more on
makeEnvPublic() - Security: what is safe to send to the browser