Overview
Multi-stage Docker builds for production Node.js/Next.js applications. Covers image size optimization, security hardening, and Docker Compose patterns.
Multi-Stage Build (Gold Standard)
# 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 --omit=dev
# 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 (smallest possible image)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Non-root user — security requirement
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
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
Requires output: "standalone" in next.config.ts. The runner image contains only the production output — no source files, no dev dependencies.
Security Hardening Rules
# Always use specific image versions — never :latest
FROM node:20.18.1-alpine AS base
# Run as non-root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# Read-only filesystem where possible
# Set in docker run: --read-only --tmpfs /tmp
# No shell in production image
RUN rm -f /bin/sh # Only if app doesn't need shell
# Minimal apt/apk — only what the app needs
RUN apk add --no-cache curl # curl for healthcheck only
.dockerignore (Essential)
node_modules
.next
.git
*.log
*.md
.env*
.DS_Store
coverage
.nyc_output
dist
A missing .dockerignore sends gigabytes of node_modules into build context. Always create it first.
Docker Compose — Production Pattern
services:
app:
image: my-app:${APP_VERSION:-latest}
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
env_file:
- .env.production
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
memory: 256M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
app:
condition: service_healthy
Development Override
# docker-compose.override.yml (auto-loaded in dev)
services:
app:
build:
context: .
target: builder
volumes:
- .:/app
- /app/node_modules # Prevent host node_modules overriding container
environment:
- NODE_ENV=development
command: npm run dev
ports:
- "3000:3000"
- "9229:9229" # Node debugger
Health Check Patterns
// app/api/health/route.ts
export async function GET() {
try {
// Lightweight dependency check
await db.execute("SELECT 1");
return Response.json({ status: "ok", timestamp: Date.now() });
} catch (err) {
return Response.json({ status: "error", error: String(err) }, { status: 503 });
}
}
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
Cross-Platform Builds (M1/M2 Mac to Linux)
# Build for multiple architectures
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myapp:latest \
--push .
# Set up buildx once
docker buildx create --use --name multi-arch
docker buildx inspect --bootstrap
Image Size Targets
| Stack | Target Size | |-------|-------------| | Next.js standalone | < 200MB | | Node.js API | < 150MB | | Python FastAPI | < 300MB | | Static site (nginx) | < 50MB |
Use docker image inspect <image> and docker history <image> to diagnose bloated layers.