EmpireUI
Get Pro
← Blog9 min read#docker#next.js#production

Docker + Next.js Production Setup: Multi-Stage Build, Alpine, Env

Stop shipping bloated Node images. Here's the exact multi-stage Dockerfile that gets your Next.js app under 200 MB and actually behaves in production.

Docker container ship at port representing Next.js production deployment workflow

Why Your Current Dockerfile Is Probably Wrong

Most Next.js Docker tutorials on the internet show you a five-liner that pulls node:18, copies everything in, runs npm run build, and calls it a day. That approach works — until you actually care about image size, startup time, or secrets leaking into your layers. A naive build easily tops 1.5 GB. That's not a production image; that's a dev environment wearing a suit.

Multi-stage builds exist precisely for this problem. You get a heavy 'builder' stage with all your devDependencies and build tooling, and a lean 'runner' stage that only carries what the app needs at runtime. The final image never touches your node_modules from the build phase. This is the gap between a 1.4 GB monstrosity and a 180 MB Alpine image that actually ships fast.

Worth noting: Next.js introduced the output: 'standalone' option in version 12.1, and it's the single biggest unlock for Docker-based deployments. It traces exactly which files your app needs at runtime and dumps them into .next/standalone — no full node_modules required in the final stage. If you're not using it, you're leaving serious image-size savings on the table.

Honestly, the combination of standalone output + Alpine base + multi-stage build is the setup most teams should have been running since 2022 and still aren't. Let's fix that.

The Complete Multi-Stage Dockerfile

Here's the full file. Read through it first, then we'll break down each stage.

# syntax=docker/dockerfile:1

# ─── Stage 1: deps ───────────────────────────────────────────────────────────
FROM node:20-alpine AS deps
# libc6-compat is required for Alpine + some native Node modules
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else echo "No lockfile found." && exit 1; \
  fi

# ─── Stage 2: builder ────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app

# Copy installed deps from previous stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Telemetry off — stops Next.js phoning home during CI builds
ENV NEXT_TELEMETRY_DISABLED=1

# Build args for public env vars baked at compile time
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

RUN npm run build

# ─── Stage 3: runner ─────────────────────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Non-root user — critical for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Copy only the standalone output + static assets
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
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

Three stages, clean separation. The deps stage installs packages. The builder stage runs next build. The runner stage copies only what's needed — the standalone server bundle, static files, and public assets. The node_modules from the builder stage never touches the final image.

That libc6-compat package on Alpine deserves a mention. Some npm packages with native bindings link against glibc, which Alpine (a musl-based distro) doesn't ship by default. Adding it prevents cryptic runtime crashes that only appear after deploy, not during build.

next.config.js: Enabling Standalone Output

The Dockerfile above is useless without this setting in your Next.js config. Add it before you build.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  // Optional: disable the x-powered-by header in production
  poweredByHeader: false,
  // If you use image optimisation, declare remote domains here
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
    ],
  },
};

module.exports = nextConfig;

With output: 'standalone', Next.js traces your app's dependencies at build time and emits a minimal server.js in .next/standalone. That file is a self-contained Node server — no next start, no extra CLI, no surprise dependencies. The final Docker CMD is just node server.js.

Quick aside: if you're on a monorepo (Turborepo, Nx, pnpm workspaces), you need to set experimental.outputFileTracingRoot to your workspace root. Otherwise the trace misses packages hoisted to the root node_modules and you get Cannot find module errors at runtime. Point it at the repo root like this: outputFileTracingRoot: path.join(__dirname, '../../').

Handling Environment Variables Correctly

This is where most teams get burned. Next.js has two categories of environment variables and Docker makes the distinction matter a lot more. NEXT_PUBLIC_* vars are inlined at build time — they get baked into the JavaScript bundle during next build. Regular vars like DATABASE_URL are read at runtime by server code. They behave completely differently inside a container.

Build-time public vars need to be available when docker build runs, not when the container starts. Pass them as --build-arg flags:

docker build \
  --build-arg NEXT_PUBLIC_API_URL=https://api.yourapp.com \
  --build-arg NEXT_PUBLIC_POSTHOG_KEY=phc_abc123 \
  -t yourapp:latest .

Runtime vars — database URLs, API keys that stay server-side, secrets — should never be in the image. Pass them at docker run time or, better, via your orchestrator's secret store:

# docker run example
docker run -p 3000:3000 \
  -e DATABASE_URL="postgres://user:pass@db:5432/mydb" \
  -e NEXTAUTH_SECRET="your-secret-here" \
  yourapp:latest

# docker-compose example
services:
  web:
    image: yourapp:latest
    env_file:
      - .env.production  # keep this out of git
    ports:
      - "3000:3000"

In practice, never put secrets in your Dockerfile ENV instructions. They end up in image history and get leaked via docker inspect. Runtime injection through your CI/CD platform (GitHub Actions secrets, Doppler, AWS Secrets Manager) is the right pattern. The .env.production file approach above is acceptable locally but not in real CI.

Docker Compose for Local Production Parity

Running your production image locally before pushing to a registry saves a lot of late-night debugging. Here's a compose file that mirrors a real deployment — Postgres included.

# docker-compose.yml
version: '3.9'

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: secret
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  web:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        NEXT_PUBLIC_API_URL: http://localhost:3000
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://myapp:secret@db:5432/myapp
      NEXTAUTH_URL: http://localhost:3000
      NEXTAUTH_SECRET: dev-secret-change-in-prod
    depends_on:
      - db

volumes:
  postgres_data:

Run docker compose up --build and you've got an environment that behaves almost identically to production. The depends_on doesn't wait for Postgres to be *ready* (just started), so if you use Prisma or run migrations on startup, wrap your startup command with a wait-for-it.sh or use a health check.

That said, this compose file is for local parity testing — not the file you'd use in actual production. In prod you'd separate your database to a managed service (RDS, Neon, Supabase) and deploy the app container to your platform of choice. The image should be stateless.

CI/CD: Building and Pushing the Image

Here's a GitHub Actions workflow that builds the image, tags it with the commit SHA, and pushes to GitHub Container Registry. It's the workflow pattern you'd use before deploying to Fly.io, Railway, ECS, or any container platform.

# .github/workflows/docker-publish.yml
name: Build & Push Docker Image

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          build-args: |
            NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

The cache-from/cache-to: type=gha lines are important. GitHub Actions cache lets Docker reuse unchanged layers across builds. On a real app, that drops build time from 4-5 minutes to under 90 seconds for most pushes because the deps and builder stages rarely change from commit to commit.

One more thing — tag with both latest and the commit SHA. latest is convenient for quick deploys; the SHA tag is what you actually pin in production manifests. Rolling back to a specific commit is trivial when every build has an immutable SHA-tagged image sitting in the registry.

If your frontend uses Empire UI's glassmorphism components or any of the pre-built templates, the build process is identical — the component library is just a React dependency that gets tree-shaken during next build. No special Docker handling needed.

Image Size, Security, and Runtime Gotchas

After the build, run docker images and check your final size. A typical Next.js app with this setup lands between 150 MB and 250 MB. If you're above 400 MB, something's wrong — probably a COPY . . that's pulling in your whole node_modules or .next/cache into the runner stage. Add a .dockerignore file to fix it:

# .dockerignore
node_modules
.next
.git
*.md
.env*
coverage
.DS_Store

The non-root user setup (nextjs:nodejs at UID/GID 1001) in the Dockerfile isn't optional in a serious production environment. Running containers as root means a container escape gives an attacker root on the host. Most container security scanners (Trivy, Snyk, AWS Inspector) will flag missing non-root users as a high-severity finding.

Look, one runtime gotcha that bites people constantly: Next.js 13+ with App Router sometimes relies on the filesystem for ISR cache storage. Inside a container, that path needs to be writable by your non-root user. If you see EACCES errors in logs around cache writes, it's the ISR cache hitting a permission wall. Either mount a writable volume at /app/.next/cache or switch to a distributed cache adapter.

For production observability, set ENV NODE_OPTIONS='--enable-source-maps' in the runner stage. Source maps in production containers mean your error monitoring (Sentry, Datadog) shows actual line numbers in your TypeScript source, not minified bundle garbage. Small thing, saves enormous amounts of debugging time. The page-transitions-nextjs article touches on some of the same Next.js config patterns if you're building out a full production app with polished UX alongside your deployment setup.

FAQ

Do I need `output: 'standalone'` in next.config.js for this Dockerfile to work?

Yes, absolutely. Without it, .next/standalone doesn't get generated and the runner stage COPY commands will fail. Add output: 'standalone' before you run your first Docker build.

Why use Alpine instead of the default Node image?

Alpine-based images (node:20-alpine) are around 50-70 MB versus 300-900 MB for the Debian-based defaults. Smaller images pull faster, scan faster, and have a smaller attack surface. The libc6-compat workaround handles the rare native module compatibility issues.

How do I pass secret environment variables without baking them into the image?

Pass runtime-only secrets via -e flags at docker run, an env_file, or your CI/CD platform's secret store. Never use ENV in a Dockerfile for secrets — they appear in docker inspect and image history.

My image is 800 MB even with multi-stage builds. What went wrong?

Almost certainly a missing .dockerignore file causing node_modules or .next/cache to land in the runner stage, or a stray COPY --from=builder /app/node_modules line. Check with docker history <image> to see which layer is the bloat culprit.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

GitHub Actions CI/CD for Next.js: Test, Build, Deploy PipelinePrisma + Next.js in 2026: Schema, Migrations, Connection PoolingDeploying Next.js in 2026: Vercel, Docker, VPS ComparedAnalytics in Next.js: Vercel Analytics, PostHog, Plausible Setup