Tools: Dockerizing Next.js for production (2026)

Tools: Dockerizing Next.js for production (2026)

The Dockerfile, up front

Why multi-stage

Stage 1 — Dependencies

Stage 2 — Builder

Build-time vs runtime env vars

Stage 3 — Runner

The .dockerignore file

Image size walkthrough

docker-compose for local dev

Common gotchas

Where to deploy this

What's next Most Dockerfiles for Next.js you'll find online ship a 1.2 GB image, leak environment variables at build time, and rebuild every layer on a one-line change. They work on the demo. They don't work in production. This is the Dockerfile I actually run. Multi-stage, ~150 MB final image, build-time and runtime env vars cleanly separated, layer caching that survives a package.json change. I'll walk through every line, explain why each stage exists, and call out the four gotchas that account for most "it worked locally" production failures. The full setup (Dockerfile plus docker-compose, GitHub Actions deploy pipeline, auth, testing) is in a production-grade Next.js + NestJS starter I'm building. Free for email subscribers — subscribe at mahmoud-mokaddem.com. If you're in a hurry, copy this and skip to Common gotchas. The rest of the post explains every line. Three stages: deps, builder, runner. The first two do work; only the third ships. A naïve Dockerfile copies your source, installs dependencies, builds, and runs — all in one stage. The image you ship to production carries everything that helped you build it: the full Node toolchain, npm's cache, dev dependencies, build artifacts you don't need at runtime, your .git directory if you weren't careful with .dockerignore. Easily 1+ GB. Multi-stage builds let you do all that work in a "fat" intermediate image, then copy only the artifacts that need to ship into a clean final image. Each FROM starts a fresh image; COPY --from= reaches back into a previous stage to grab specific files. For Next.js, the practical result: ~150 MB final image vs ~1.2 GB single-stage. Why this matters in production: The mental shortcut: do the messy work in a fat intermediate image, ship only the artifacts that need to run. node:20-alpine is a deliberate trade-off. Alpine Linux is ~50 MB; node:20-slim is ~340 MB; node:20 (Debian-based) is ~1 GB. Alpine wins on size and is fine for almost every Next.js app. The catch: Alpine uses musl libc instead of glibc. Some npm packages with prebuilt native binaries (historically canvas, sharp, certain database drivers) ship glibc binaries that don't load on Alpine. If you hit a binary-compatibility error during npm ci, the fix is usually to switch this stage's base to node:20-slim and accept the larger image. For a vanilla Next.js app, you'll never see this. Notice we copy only package.json and package-lock.json, not the source. This is layer-caching discipline. Docker caches each layer; if a layer's input hasn't changed, it reuses the cached output. By isolating the dependency install to the lockfile, we get full cache reuse on every commit that doesn't touch dependencies — which is most of them. If we copied the source first, every code change would re-run npm ci from scratch. About npm ci vs npm install: ci is deterministic, installs exactly what's in the lockfile, fails if the lockfile is out of date, and is faster. Always ci in Docker. (Yarn: yarn install --frozen-lockfile. pnpm: pnpm install --frozen-lockfile.) Fresh stage, fresh Alpine, node_modules pulled forward from stage 1. COPY . . brings in the source tree (filtered by .dockerignore, covered below). The standalone output mode is the one Next.js config flag you actually need. Add it to your next.config.js: Without this flag, npm run build produces the standard Next.js build output and your final image has to ship the entire node_modules tree (~300 MB+). With it, Next.js traces every dependency actually used by your built routes and emits a self-contained server.js plus only those traced packages in .next/standalone/node_modules, typically ~15 MB. That one flag is the biggest size win in this Dockerfile. npm run build produces three things we care about: Stage 3 copies these three things and nothing else. This is the most common Next.js + Docker bug I see, so it gets its own callout. Variables prefixed NEXT_PUBLIC_ are baked into the client-side JavaScript bundle at build time. They are not read at runtime from the container's environment. If you set NEXT_PUBLIC_API_URL only at runtime via docker run -e, your client code will see whatever value it had at build time (usually empty), not what you set at runtime. Two ways to handle it. (a) Pass NEXT_PUBLIC_* as --build-arg and rebuild per environment: (b) Keep NEXT_PUBLIC_* for things that don't change per deploy (your domain, public Stripe key, public Sentry DSN), and put environment-specific config behind server-side data fetching where you can read process.env at runtime. I prefer (b). Fewer images, simpler pipeline. Use (a) only when you genuinely need the value baked into the client bundle. Final stage. Fresh Alpine, no toolchain, no dev dependencies. This is what ships. NODE_ENV=production matters. Next.js skips dev-only logging and telemetry, and many libraries optimize behavior based on it. The non-root user: addgroup creates a system group, adduser creates a user in it, USER nextjs switches the runtime to that user. Many container platforms (Kubernetes, ECS, Fly with strict modes) refuse to run containers as root by default. Even when they don't, running as root expands the impact of any container-escape CVE. This costs nothing; do it now. The three copies are where the standalone output pays off: EXPOSE 3000 is documentation, not a port-open. It tells docker run -p and orchestrators "this app expects to be reachable on 3000." HOSTNAME=0.0.0.0 is required to accept connections from outside the container. Next.js's standalone server defaults to localhost, which means your container would only accept traffic from itself. Use CMD ["node", "server.js"], not npm start. npm wraps the process and intercepts signals, so your container won't gracefully shut down on SIGTERM. Orchestrator-driven restarts hang for 30+ seconds before the kernel kills it. node server.js handles signals correctly. This file gets skipped a lot, and it's often the answer to "why is my build context 2 GB?" The .env* line is a security concern worth dwelling on. If you've ever had an .env.local sitting in your working directory, .dockerignore is what keeps it out of the image. An image with .env.production baked in can be pulled by anyone with read access to your registry. Put real secrets in your runtime environment, not in the image. Numbers are approximate; your app's specific dependencies move them ±20%. What this saves you: deploy time drops from ~96 seconds to ~12 on a 100 Mbps registry pull. Cold start time on Fly or Cloud Run becomes meaningful at the standalone size. The biggest win is the standalone output flag. The Alpine base is second. Multi-stage is the structural decision that makes both composable. This Dockerfile builds the production image. For local dev you usually want hot reload, a local Postgres, maybe Redis. A minimal compose file: This runs the production build locally, which is useful for catching prod-only bugs but not for hot reload. For real dev work you want a separate docker-compose.dev.yml with the source mounted as a volume and next dev running. That's a full post in itself — coming next in this series. The four bugs that account for most "it worked locally" production failures with this setup: 1. Public folder not appearing. You forgot COPY --from=builder /app/public ./public. Symptom: 404s on every static asset. Fix: add the line. 2. NEXT_PUBLIC_* env vars not reaching the client. They were set at runtime, not build time. Symptom: client-side code reads undefined or stale values. Fix: pass via --build-arg (per the Stage 2 section) or restructure so the value isn't needed in the client bundle. 3. Container exits immediately, no logs. You're using npm start instead of node server.js. npm wraps the process and hides what's happening. Fix: CMD ["node", "server.js"]. 4. OOM during npm run build on a small VPS. Hetzner CX11 / DigitalOcean $4 droplets often can't fit a Next.js build in RAM. Symptom: build fails with JavaScript heap out of memory or gets SIGKILLed. Fix: build in CI/CD and push the image to your registry, then pull on the VPS; or add a swap file on the VPS. Each one has happened to me. Each one looks unrelated to Docker until you find it. The Dockerfile doesn't change; the deploy target does. Each of these gets its own deploy walkthrough later in this series. The Dockerfile above works on all of them unchanged. Two follow-ups in this series: If you've shipped this Dockerfile to a deploy target I didn't cover, I'd be curious what platform you picked and what bit you. The full setup (Dockerfile, .dockerignore, docker-compose, GitHub Actions deploy pipeline, auth, testing) is in a production-grade Next.js + NestJS starter I'm building. Free for email subscribers — subscribe at mahmoud-mokaddem.com. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# Stage 1: deps FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN -weight: 500;">npm ci # Stage 2: builder FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN -weight: 500;">npm run build # Stage 3: runner FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 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 ENV PORT=3000 HOSTNAME=0.0.0.0 CMD ["node", "server.js"] # Stage 1: deps FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN -weight: 500;">npm ci # Stage 2: builder FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN -weight: 500;">npm run build # Stage 3: runner FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 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 ENV PORT=3000 HOSTNAME=0.0.0.0 CMD ["node", "server.js"] # Stage 1: deps FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN -weight: 500;">npm ci # Stage 2: builder FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN -weight: 500;">npm run build # Stage 3: runner FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 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 ENV PORT=3000 HOSTNAME=0.0.0.0 CMD ["node", "server.js"] FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN -weight: 500;">npm ci FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN -weight: 500;">npm ci FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN -weight: 500;">npm ci FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN -weight: 500;">npm run build FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN -weight: 500;">npm run build FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN -weight: 500;">npm run build module.exports = { output: 'standalone', }; module.exports = { output: 'standalone', }; module.exports = { output: 'standalone', }; ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL RUN -weight: 500;">npm run build ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL RUN -weight: 500;">npm run build ARG NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL RUN -weight: 500;">npm run build -weight: 500;">docker build \ --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \ -t my-app . -weight: 500;">docker build \ --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \ -t my-app . -weight: 500;">docker build \ --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \ -t my-app . FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 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 ENV PORT=3000 HOSTNAME=0.0.0.0 CMD ["node", "server.js"] FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 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 ENV PORT=3000 HOSTNAME=0.0.0.0 CMD ["node", "server.js"] FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 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 ENV PORT=3000 HOSTNAME=0.0.0.0 CMD ["node", "server.js"] node_modules .next .-weight: 500;">git .env* README.md *.log coverage .vscode .idea .DS_Store node_modules .next .-weight: 500;">git .env* README.md *.log coverage .vscode .idea .DS_Store node_modules .next .-weight: 500;">git .env* README.md *.log coverage .vscode .idea .DS_Store services: app: build: . ports: ['3000:3000'] environment: DATABASE_URL: postgres://user:pass@db:5432/myapp depends_on: [db] db: image: postgres:16-alpine environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: myapp volumes: ['db_data:/var/lib/postgresql/data'] volumes: db_data: services: app: build: . ports: ['3000:3000'] environment: DATABASE_URL: postgres://user:pass@db:5432/myapp depends_on: [db] db: image: postgres:16-alpine environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: myapp volumes: ['db_data:/var/lib/postgresql/data'] volumes: db_data: services: app: build: . ports: ['3000:3000'] environment: DATABASE_URL: postgres://user:pass@db:5432/myapp depends_on: [db] db: image: postgres:16-alpine environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: myapp volumes: ['db_data:/var/lib/postgresql/data'] volumes: db_data: - Faster registry pulls on small VPSes or autoscaling platforms. Pulling 1.2 GB on a 100 Mbps link takes ~96 seconds; pulling 150 MB takes ~12. - Faster cold starts on platforms like Fly.io and Cloud Run, where containers -weight: 500;">start on demand. - Lower registry cost when you push every commit. - Smaller security surface — fewer packages carrying potential CVEs in production. - .next/standalone/ — the self-contained server plus traced node_modules - .next/static/ — built static assets (JS bundles, CSS) for _next/static/* routes - public/ — static files you put in the public folder, which Next.js doesn't bundle into standalone - COPY --from=builder /app/public ./public — public/ is not part of the standalone output. Forget this line and all your favicons, robots.txt, and static images return 404. The first time. Always. - COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ — the actual server. --chown makes the non-root user own the files, otherwise it can't read its own runtime. - COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static — also not in standalone. Forgetting this gives you a site with no JS or CSS. - node_modules — gets reinstalled in the deps stage. - .next — build artifacts get rebuilt; carrying old ones in confuses Next.js's cache. - .-weight: 500;">git — your version history shouldn't ship in the container. - .env* — never bake secrets into images. Pass at runtime. - Logs, IDE folders, coverage reports — clutter. - Hetzner VPS + Coolify or Dokploy — cheapest, most control. What I'd pick for indie projects. Push the image to GitHub Container Registry; Coolify pulls and runs it. - DigitalOcean App Platform — push the Dockerfile, get a URL. Good middle ground. - Fly.io — global edge deploy, generous free tier for hobby work. fly launch auto-detects Next.js and writes a fly.toml for you. - AWS ECS / Fargate — enterprise default. More setup overhead, but the right call if you're already in AWS. - -weight: 500;">docker-compose for Next.js + NestJS local dev — the full dev-mode compose file with hot reload, Postgres, and Redis. - How I structure a NestJS project for production — the architecture conventions in the starter, with rationale.