Docker has become the standard way to package and deploy applications. But there's a significant difference between a Dockerfile that "works on my machine" and one that's production-ready. This guide covers the practices that matter when your containers are serving real traffic.
Multi-Stage Builds
Multi-stage builds are the single most impactful optimization. They dramatically reduce image size by separating the build environment from the runtime:
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
The result: your final image contains only what's needed to run the application — no build tools, no source code, no dev dependencies.
Security Essentials
Never Run as Root
By default, containers run as root. This is a security risk — if an attacker escapes the container, they have root access to the host. Always create and switch to a non-root user:
RUN addgroup -S app && adduser -S app -G app
USER app
Use Minimal Base Images
Choose the smallest base image that works for your application:
alpine— ~5MB, good for most casesdistroless— Google's minimal images, no shellscratch— empty image, for statically compiled binaries (Go, Rust)
# For Go applications — scratch image = ~10MB total
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o server .
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]
Scan for Vulnerabilities
Integrate image scanning into your CI pipeline. Tools like Trivy, Snyk, or Docker Scout catch known CVEs before deployment:
# In CI pipeline
trivy image --severity HIGH,CRITICAL myapp:latest
Health Checks
Health checks let the orchestrator (Docker Swarm, Kubernetes) know when your container is actually ready to serve traffic:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
Your health endpoint should verify actual dependencies — database connection, cache availability, etc. — not just return 200.
Layer Caching Strategy
Docker builds images layer by layer, caching unchanged layers. Order your Dockerfile instructions from least to most frequently changed:
# 1. Base image (rarely changes)
FROM node:20-alpine
# 2. System dependencies (rarely changes)
RUN apk add --no-cache tini
# 3. App dependencies (changes when packages update)
COPY package*.json ./
RUN npm ci --only=production
# 4. Application code (changes most often)
COPY . .
This way, changing your application code doesn't invalidate the dependency cache.
Resource Limits
Always set memory and CPU limits to prevent a single container from consuming all host resources:
# docker-compose.yml
services:
api:
image: myapp:latest
deploy:
resources:
limits:
memory: 512M
cpus: '0.50'
reservations:
memory: 256M
cpus: '0.25'
Logging Best Practices
Write logs to stdout/stderr, not to files inside the container. This lets Docker's logging driver handle collection and rotation:
// In your application
const logger = {
info: (msg, meta) => console.log(JSON.stringify({
level: 'info', msg, ...meta,
timestamp: new Date().toISOString()
})),
error: (msg, meta) => console.error(JSON.stringify({
level: 'error', msg, ...meta,
timestamp: new Date().toISOString()
}))
};
Structured JSON logs are easier to parse and query in log aggregation systems like ELK, Loki, or Datadog.
Summary
Production-ready Docker images are:
- Small — multi-stage builds, minimal base images
- Secure — non-root user, vulnerability scanning, no secrets in layers
- Observable — health checks, structured logging, metrics
- Efficient — optimized layer caching, resource limits
- Reproducible — pinned versions, deterministic builds
Apply these practices consistently, and you'll spend less time debugging container issues in production and more time building features.