The production Dockerfile template
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system nodejs && adduser --system --ingroup nodejs nextjs
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]Why Alpine?
Alpine Linux results in images 3–10x smaller than Debian-based images. Smaller images: faster pull times, smaller attack surface, lower registry storage costs. Caveat: some npm packages with native bindings require build tools not present in Alpine. If npm ci fails with Alpine, add RUN apk add --no-cache python3 make g++ or switch to node:20-slim.
The .dockerignore file
.git
.gitignore
node_modules
dist
.env
.env.*
*.test.ts
coverage
.nyc_output
*.md
Dockerfile*
docker-compose*Without this, Docker sends your entire git history, local node_modules, and test coverage to the build context. This slows builds and bloats images.
Layer caching strategy
Docker rebuilds layers from the first changed instruction. Put things that change rarely at the top:
- Base image (never changes)
COPY package*.json+npm ci(changes only when dependencies change)COPY . .(changes on every commit)
This means npm ci uses cache on every build that doesn't change dependencies — which is most builds.
Security scan
Before pushing to production, scan your image for CVEs:
# Using Docker Scout
docker scout cves myapp:latest
# Using Trivy (free, comprehensive)
trivy image myapp:latestA Node 20 Alpine image typically has zero high-severity CVEs. A Node 20 Debian image may have dozens from OS packages you're not even using.
Environment variable injection
Never bake secrets into the image. Pass them at runtime:
# Development
docker run --env-file .env.local myapp:latest
# Production (with a secrets manager)
docker run -e DATABASE_URL="$(aws secretsmanager get-secret-value --secret-id db-url --query SecretString --output text)" myapp:latest