Next.js Docker Deployment: Complete Guide with Multi-Stage Builds
ID | EN

Next.js Docker Deployment: Complete Guide with Multi-Stage Builds

Kamis, 16 Jan 2025

Deploying Next.js applications with Docker provides consistent environments, simplified scaling, and seamless CI/CD integration. This guide walks you through containerizing your Next.js app from basic setup to production-optimized multi-stage builds.

Why Use Docker for Next.js Deployment?

Docker solves the “works on my machine” problem by packaging your Next.js application with all its dependencies into a portable container. Key benefits include:

  • Environment consistency across development, staging, and production
  • Simplified deployments to any container-compatible platform (AWS ECS, Google Cloud Run, Kubernetes)
  • Easy scaling with container orchestration tools
  • Isolated dependencies preventing conflicts between projects
  • Reproducible builds for reliable CI/CD pipelines

Basic Dockerfile for Next.js

Let’s start with a simple Dockerfile that works for most Next.js applications:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

This straightforward approach copies your code, installs dependencies, builds the app, and runs it. However, the resulting image is larger than necessary because it includes development dependencies and source files.

Multi-Stage Builds for Smaller Images

Multi-stage builds dramatically reduce image size by separating the build environment from the runtime environment. Here’s an optimized Dockerfile:

# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci --only=production

# 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
ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000
ENV HOSTNAME "0.0.0.0"

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

This multi-stage approach typically reduces image size from 1GB+ to under 150MB.

Enabling Standalone Output Mode

The Dockerfile above uses Next.js standalone output mode, which creates a minimal production build. Enable it in your next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
}

module.exports = nextConfig

Standalone mode bundles only the necessary dependencies, creating a self-contained server.js file that doesn’t require node_modules at runtime.

Docker Compose Setup

For local development and multi-container setups, Docker Compose simplifies orchestration:

# docker-compose.yml
version: '3.8'

services:
  nextjs:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
    depends_on:
      - postgres
    restart: unless-stopped

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  postgres_data:

Run your stack with:

docker-compose up -d

Handling Environment Variables

Next.js has two types of environment variables that require different handling in Docker:

Build-time Variables (NEXT_PUBLIC_*)

These are embedded during the build process:

FROM node:20-alpine AS builder
WORKDIR /app

ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_ANALYTICS_ID

ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_ANALYTICS_ID=$NEXT_PUBLIC_ANALYTICS_ID

COPY . .
RUN npm run build

Pass them during build:

docker build \
  --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
  --build-arg NEXT_PUBLIC_ANALYTICS_ID=UA-12345 \
  -t my-nextjs-app .

Runtime Variables

Server-side variables are passed when running the container:

docker run -p 3000:3000 \
  -e DATABASE_URL="postgresql://user:pass@host:5432/db" \
  -e API_SECRET="your-secret-key" \
  my-nextjs-app

Or use an env file:

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

Production Optimization Tips

1. Use Alpine-based Images

Alpine images are significantly smaller than standard Debian-based images:

# ~150MB smaller base image
FROM node:20-alpine

2. Leverage Build Cache

Order your Dockerfile commands to maximize cache hits:

# Dependencies change less frequently
COPY package*.json ./
RUN npm ci

# Source code changes more often
COPY . .
RUN npm run build

3. Add a .dockerignore File

Exclude unnecessary files from the build context:

# .dockerignore
node_modules
.next
.git
.gitignore
*.md
.env*.local
Dockerfile
docker-compose*.yml

4. Enable Health Checks

Add container health monitoring:

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:3000/api/health || exit 1

Create a simple health endpoint:

// pages/api/health.ts
export default function handler(req, res) {
  res.status(200).json({ status: 'healthy' });
}

5. Set Resource Limits

In Docker Compose or your orchestrator:

services:
  nextjs:
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 512M
        reservations:
          cpus: '0.5'
          memory: 256M

CI/CD Integration

GitHub Actions Example

# .github/workflows/docker.yml
name: Build and Push Docker Image

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Login to Container Registry
        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
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            NEXT_PUBLIC_API_URL=${{ vars.API_URL }}

GitLab CI Example

# .gitlab-ci.yml
stages:
  - build
  - deploy

build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  only:
    - main

Troubleshooting Common Issues

1. “Cannot find module” Errors

This usually means standalone mode isn’t configured or files aren’t copied correctly:

# Ensure all necessary files are copied
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

2. Large Image Size

Check your .dockerignore and ensure you’re using multi-stage builds. Verify with:

docker images my-nextjs-app
docker history my-nextjs-app

3. Environment Variables Not Working

Remember: NEXT_PUBLIC_* variables must be available at build time:

# Wrong - runtime only
docker run -e NEXT_PUBLIC_API_URL=... my-app

# Correct - build time
docker build --build-arg NEXT_PUBLIC_API_URL=... .

4. Permission Denied Errors

Ensure proper file ownership in the runner stage:

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./

5. Sharp/Image Optimization Issues

Install required dependencies for the sharp package:

FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat vips-dev

Complete Production-Ready Dockerfile

Here’s a comprehensive Dockerfile incorporating all best practices:

# syntax=docker/dockerfile:1

# Stage 1: Install dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

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

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Build-time environment variables
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1

RUN npm run build

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

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

# Copy built application
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"

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1

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

Deployment Commands Summary

# Build the image
docker build -t my-nextjs-app:latest \
  --build-arg NEXT_PUBLIC_API_URL=https://api.example.com .

# Run locally
docker run -p 3000:3000 \
  -e DATABASE_URL="your-db-url" \
  my-nextjs-app:latest

# Push to registry
docker tag my-nextjs-app:latest registry.example.com/my-nextjs-app:latest
docker push registry.example.com/my-nextjs-app:latest

# Run with Docker Compose
docker-compose up -d --build

Conclusion

Docker transforms Next.js deployment from a complex, environment-specific process into a reproducible, portable workflow. By implementing multi-stage builds and standalone output mode, you achieve minimal image sizes without sacrificing functionality.

Start with the basic Dockerfile to understand the concepts, then progressively adopt the production-ready configuration as your needs grow. The investment in proper Docker setup pays dividends through reliable deployments and simplified scaling.