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.