Docker

Containerizing a Next.js app packages the same self-hosted Node.js server into a portable image that runs identically on a laptop, a CI runner, or any container platform -- Kubernetes, ECS, Cloud Run, Fly.io, or a plain docker run on a VPS. The build relies on the same output: 'standalone' mode used for self-hosting, since copying an entire node_modules folder into every image layer would make builds slow and images unnecessarily large.

Why a Multi-Stage Dockerfile

A naive Dockerfile that runs npm install and next build in a single stage ships the final image with the full development dependency tree, the source files, and the build cache all baked in -- often hundreds of megabytes larger than necessary, and with a bigger attack surface. A multi-stage build splits the work into separate stages and discards everything except the final runtime output:

The result is typically a final image in the tens of megabytes range on top of the base image, versus several hundred megabytes for a single-stage build.

This mirrors the official Next.js example Dockerfile. It assumes output: 'standalone' is set in next.config.js -- see Self-Hosting for what that option does.

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

# ---- 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

# ---- runner ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV 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
ENV HOSTNAME="0.0.0.0"

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

Running as a dedicated non-root user (nextjs above) rather than the container's default root user is a small addition that meaningfully reduces the impact of a container escape or dependency compromise.



Building and Running


terminal:
docker build -t my-nextjs-app .
docker run -p 3000:3000 --env-file .env.production my-nextjs-app


A docker-compose Example

For local development against dependent services (a database, a cache, a reverse proxy) or a simple single-host production setup, wrapping the same image in a compose file keeps the whole stack declarative.


docker-compose.yml:
services:
  web:
    build: .
    ports:
      - "3000:3000"
    env_file:
      - .env.production
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: example
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

Environment variables that need to be baked into the client bundle at build time (anything prefixed NEXT_PUBLIC_) must be available as build arguments during the builder stage, not just at container run time -- by the time docker run executes, next build has already finished and those values are already compiled into the JavaScript sent to the browser.