Dockerfile Best Practices
[!IMPORTANT] Always produce the smallest, most secure image possible. Every layer costs space and every root process is a security risk.
The Non-Negotiables
- Multi-stage builds — never ship build tools in production.
- Non-root user — never run the app as root.
- Pin base image versions — never use
:latest. .dockerignore— always present.- No secrets in the image — not in
ENV,ARG, orCOPY.
Multi-Stage Build Pattern
Node.js / Next.js
# ---- build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --frozen-lockfile
COPY . .
RUN npm run build
# ---- production stage ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Non-root user
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Python / FastAPI
FROM python:3.12-slim AS base
WORKDIR /app
FROM base AS builder
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM base AS runner
COPY --from=builder /install /usr/local
COPY . .
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Layer Caching — Order Matters
# WRONG — invalidates cache on every code change
COPY . .
RUN npm install
# CORRECT — npm install cached unless package.json changes
COPY package*.json ./
RUN npm install
COPY . .
Rule: copy dependency manifests first, install, then copy source.
Base Image Selection
| Use case | Image | Why |
|---|---|---|
| Node.js prod | node:20-alpine |
Minimal, ~50MB |
| Python prod | python:3.12-slim |
Minimal, no extras |
| Go | scratch or gcr.io/distroless/static |
Zero OS overhead |
| General Linux | debian:bookworm-slim |
When Alpine musl causes issues |
| Avoid | :latest, ubuntu, node:20 (full) |
Too large, unpredictable |
.dockerignore (always create this)
.git
.gitignore
.env
.env.*
node_modules
.next
dist
build
coverage
*.log
*.md
.DS_Store
Dockerfile*
docker-compose*
Security Rules
# Pin digest for critical images (most secure)
FROM node:20-alpine@sha256:abc123...
# Never do this — exposes secrets in image layers
ARG API_KEY
ENV API_KEY=$API_KEY # ← secret baked into image, visible in docker history
# Never run as root
USER root # ← prohibited in production images
HEALTHCHECK — always add in production
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
docker-compose.yml production patterns
services:
app:
build:
context: .
target: runner # target the production stage
restart: unless-stopped
environment:
- NODE_ENV=production
# Pass secrets as env vars, never hardcode
- DATABASE_URL=${DATABASE_URL}
ports:
- "3000:3000"
read_only: true # filesystem immutability
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
Image Size Checklist
- Multi-stage build used
- Alpine or slim base image
-
npm ci --frozen-lockfile(notnpm install) -
--no-cache-diron pip - devDependencies excluded from production stage
-
.dockerignoreexcludesnode_modules,.git,.env - Build artifacts only (not full source) copied to final stage