MM
Mahmoud Mokaddem
← All posts

Dockerizing Next.js for production

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.

You can read this post standalone and have a working Dockerfile in 20 minutes. The full setup (Dockerfile plus docker-compose, GitHub Actions deploy pipeline, auth, testing) is in the production-grade Next.js + NestJS starter I’m building. There’s a subscribe link at the end.

The Dockerfile, up front

If you’re in a hurry, copy this and skip to the gotchas. The rest of the post explains every line: why each stage exists, why these specific defaults, and the bugs that account for most production failures.

# Stage 1: deps
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN 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 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"]

Three stages: deps, builder, runner. The first two do work; only the third ships.

Why multi-stage

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 and 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:

  • 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 start on demand.
  • Lower registry cost when you push every commit during the day.
  • Smaller security surface, with fewer packages carrying potential CVEs in production.

The mental shortcut: do the messy work in a fat intermediate image, ship only the artifacts that need to run.

Stage 1 — Dependencies

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

node:20-alpine is a deliberate trade-off. Alpine Linux is ~50 MB; the standard 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 I’ve shipped.

The catch with Alpine is 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 of the build; if a layer’s input hasn’t changed, it reuses the cached output. By isolating the dependency install layer 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 and cost minutes.

About npm ci vs npm install: ci is deterministic. It installs exactly what’s in the lockfile, fails if the lockfile is out of date, and is faster because it skips tree resolution. Always ci in Docker. (For Yarn: yarn install --frozen-lockfile. For pnpm: pnpm install --frozen-lockfile.)

We don’t npm prune --production here because the Next.js standalone output (next stage) does it implicitly. It includes only the runtime deps actually traced from the build output.

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 npm run build

Fresh stage, fresh Alpine, node_modules pulled forward from the previous stage. COPY . . brings in the source tree (filtered by .dockerignore, covered below).

NEXT_TELEMETRY_DISABLED=1 opts the build out of Next.js’s anonymous build telemetry. Optional, but it’s my default. I don’t want phone-home behavior in CI/CD pipelines.

The standalone output mode is the one Next.js config flag you actually need. Add it to your next.config.js:

module.exports = {
  output: 'standalone',
  // ... your other config
};

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:

  • .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

Stage 3 copies these three things and nothing else.

Build-time vs runtime env vars

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:

# In your Dockerfile, in stage 2:
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# When building:
docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com -t my-app .

(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.

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"]

Final stage. Fresh Alpine, no toolchain, no dev dependencies. This is what ships.

NODE_ENV=production matters. Next.js skips a bunch of dev-only logging and telemetry, and many libraries (Express, Pino, etc.) optimize behavior based on it.

About the non-root user dance: addgroup creates a system group, adduser creates a user in it, and USER nextjs switches the runtime to that non-root 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.

  • COPY --from=builder /app/public ./public: public/ is not part of the standalone output. If you forget this line, 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. The --chown flag 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. Same forgetfulness produces a site with no JS or CSS.

EXPOSE 3000 is documentation, not a port-open. It tells docker run -p and orchestrators “this app expects to be reachable on 3000.” The port still has to be mapped.

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 directly invokes the standalone server and handles signals correctly.

The .dockerignore file

This file gets skipped a lot, and it’s often the answer to “why is my build context 2 GB?” The whole context (your project directory) is sent to the Docker daemon before the build starts. A bloated context adds seconds, sometimes minutes, to every build.

A reasonable .dockerignore:

node_modules
.next
.git
.env*
README.md
*.log
coverage
.vscode
.idea
.DS_Store

Why each entry:

  • node_modules: gets reinstalled in the deps stage.
  • .next: build artifacts get rebuilt; carrying old ones in confuses Next.js’s cache.
  • .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.

The .env* line is a security thing worth dwelling on. If you’ve ever had an .env.local sitting in your working directory, even briefly, .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.

Image size walkthrough

Four approaches, four different image sizes:

ApproachFinal image
Naïve single-stage on node:20~1.2 GB
Multi-stage on node:20 (no standalone)~600 MB
Multi-stage on node:20-alpine (no standalone)~400 MB
Multi-stage on node:20-alpine + standalone~150 MB

(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 and is barely measurable at 1.2 GB. Registry storage cost matters too if you push often.

The biggest win is the standalone output flag. The Alpine base is second. Multi-stage is third, but it’s the structural decision that makes the others possible.

docker-compose for local dev

This Dockerfile builds the production image. For local dev you usually want hot reload, a local Postgres, maybe Redis. A minimal compose file:

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:

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.

Common gotchas {#gotchas}

The four bugs that account for most “it worked locally” production failures with this setup:

1. Public folder not appearing. You forgot the COPY --from=builder /app/public ./public line. Symptom: 404s on every static asset (favicon, robots.txt, anything in public/). 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 callout) 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 just gets SIGKILLed. Two fixes: build in CI/CD and push the image to your registry, then pull on the VPS (almost always the right answer); or add a swap file on the VPS (works, but slows builds substantially).

Each one of these has happened to me. Each one looks unrelated to Docker until you find it.

Where to deploy this

The Dockerfile doesn’t change; the deploy target does. Quick map of options:

  • Hetzner VPS + Coolify or Dokploy: cheapest, most control. What I’d pick for indie projects. Push the image to GitHub Container Registry or Docker Hub; Coolify pulls and runs it.
  • DigitalOcean App Platform: push the Dockerfile, get a URL. Good middle ground. A bit opinionated about the structure.
  • 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 than the others, but the right call if you’re already in AWS.

Each of these gets its own deploy walkthrough later in this series. The Dockerfile in this post works on all of them unchanged.

The full setup

Everything in this post (the Dockerfile, the .dockerignore, the docker-compose for local dev, the Next.js config) is part of a production-grade Next.js + NestJS starter I’m building. The starter also includes a GitHub Actions deploy pipeline (lint → test → build → push image → deploy), auth with refresh tokens, the testing layer, and the NestJS API structure these patterns are built around.

It’ll be free, and email subscribers get it the day it’s ready. Subscribe below.

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. Find me on X or LinkedIn.