Why Containerization Changes Node.js Deployment
Running Node.js applications directly on servers creates environment parity problems, dependency conflicts, and deployment friction that compounds as teams grow. Docker solves these by packaging your application with its exact runtime environment. For Next.js specifically, the combination of server-side rendering, API routes, and static assets creates containerization challenges that a naive Dockerfile handles poorly. This guide covers production-grade Docker patterns for Next.js — multi-stage builds, compose orchestration, nginx integration, and CI/CD wiring.
Multi-Stage Dockerfile for Next.js
A single-stage Dockerfile for Next.js results in a container image exceeding 1GB because it includes build tools, dev dependencies, and intermediate build artifacts. Multi-stage builds solve this:
# Stage 1: Dependencies
FROM node:20-alpine AS deps
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 package.json package-lock.json ./
RUN npm ci
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 --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]This produces an image under 200MB. The runner stage contains only the compiled output and production Node.js runtime. Enable the standalone output option in next.config.mjs to make this work: output: 'standalone'.
Docker Compose for Local Development
# docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
target: runner
ports:
- '3000:3000'
environment:
- DATABASE_URL=${DATABASE_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 10s
timeout: 5s
retries: 5
nginx:
image: nginx:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- app
volumes:
postgres_data:Environment Variables Best Practices
Never bake secrets into Docker images. Use .env files with docker-compose for local development and proper secrets management (Docker Secrets, Kubernetes Secrets, or a secrets manager like Vault/AWS Secrets Manager) in production. Distinguish between build-time and runtime environment variables in Next.js — NEXT_PUBLIC_ variables are embedded at build time and cannot be changed without a rebuild. Server-side secrets should only be runtime variables never prefixed with NEXT_PUBLIC_.
Health Checks
Add a health check endpoint to your Next.js application and configure Docker to use it:
// pages/api/health.ts
export default function handler(req, res) {
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
}# In docker-compose.yml app service
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40sNginx Reverse Proxy Configuration
upstream nextjs {
server app:3000;
}
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location /_next/static/ {
proxy_pass http://nextjs;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}Docker Networks and Volume Mounting
Use named networks for service isolation. Your application container should only communicate with the database via an internal network — never expose PostgreSQL ports externally. Mount source code as volumes during development for hot reload, but use COPY in production builds for immutability.
CI/CD Integration
In GitHub Actions, build and push your Docker image to a container registry on every main branch push:
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
target: runner
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxDeploy to your VPS via SSH by pulling the new image and restarting the compose stack. Use rolling restarts or blue-green deployment to achieve zero-downtime deployments even on a single-server setup.
Monitoring with docker stats
For basic monitoring, docker stats provides real-time CPU, memory, network, and disk I/O per container. For production alerting, integrate with Netdata or Prometheus via the Docker metrics endpoint. Set memory limits in docker-compose to prevent a runaway process from taking down the entire server: mem_limit: 512m for the Next.js container is a reasonable starting point, adjusted based on profiling.
Docker containerization for Next.js is a discipline, not just a Dockerfile. The patterns above — multi-stage builds, health checks, secrets management, nginx integration — form the foundation of a deployment pipeline that scales from a $5 VPS to a Kubernetes cluster without architectural rework.