Docker Best Practices 2025 — Production Checklist for Secure, Lean Images
Advertisement
Introduction
Why This Matters
Most Docker tutorials show you how to get something running. Production Docker work requires a different mindset: images must be lean (faster CI, lower egress costs, smaller attack surface), containers must run as unprivileged users, and health checks must be in place so orchestrators like Kubernetes can detect and replace unhealthy instances automatically.
Teams that skip these practices pay the price in bloated registries, security vulnerabilities that get flagged by compliance scanners, and mysterious container restarts that are hard to diagnose. A 1.2 GB Node.js image is not just slow to pull — it likely contains unnecessary compilers, root-level processes, and outdated system libraries with known CVEs.
This guide codifies 2025 production standards for Docker images and containers, covering the decisions that matter most when containers leave development and hit real traffic.
Use Minimal Base Images
Choosing the right base image is the single highest-impact decision in a Dockerfile.
# Bad: 1.1 GB, ships with many tools attackers can exploit
FROM node:20
# Good: 180 MB Alpine variant
FROM node:20-alpine
# Best for production: distroless (no shell, no package manager)
FROM gcr.io/distroless/nodejs20-debian12Alpine-based images reduce size by 70–85% compared to Debian/Ubuntu variants. Distroless images go further by excluding a shell entirely, making interactive exploitation significantly harder.
For Python:
# Development
FROM python:3.12-slim
# Production (distroless)
FROM gcr.io/distroless/python3-debian12Pin Exact Versions
# Bad: unpredictable, breaks reproducibility
FROM node:latest
FROM node:20-alpine
# Good: fully pinned — reproducible builds guaranteed
FROM node:20.12.0-alpine3.19Pin OS package versions too:
RUN apk add --no-cache \
curl=8.5.0-r0 \
ca-certificates=20230506-r0Multi-Stage Builds
Multi-stage builds eliminate build tools from the final image:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production (no node_modules devDependencies, no build tools)
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]The production image contains only the compiled output and production dependencies — not TypeScript, webpack, or any build toolchain.
Never Run as Root
# Create a non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Change ownership of app files
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuserFor images based on Ubuntu/Debian:
RUN groupadd -r app && useradd -r -g app app
USER appVerify at runtime:
docker run --rm myapp whoami # Should output: appuser (not root)Optimize Layer Caching
Docker rebuilds all layers after the first changed layer. Order instructions from least-to-most frequently changed:
FROM node:20-alpine
WORKDIR /app
# 1. Dependencies change rarely — cache this layer aggressively
COPY package*.json ./
RUN npm ci --only=production
# 2. Source code changes frequently — keep this near the bottom
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]For Python:
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Source after dependencies
COPY . .Use .dockerignore
Without .dockerignore, every file in your project gets sent to the Docker build context:
# .dockerignore
node_modules
.git
.env
.env.*
*.log
dist
build
coverage
.DS_Store
.vscode
.idea
__pycache__
*.pyc
*.pyo
Dockerfile*
docker-compose*
README.mdThis reduces build context from hundreds of MBs to just your source files, dramatically speeding up builds.
Add Health Checks
Without a health check, Docker and Kubernetes assume the container is healthy if it is running. This leads to traffic routed to broken instances.
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1For apps without curl:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1Set Resource Limits at Runtime
# Limit memory to 512 MB (container OOM-killed if exceeded)
docker run -d --memory=512m --memory-swap=512m myapp
# Limit to 50% of one CPU
docker run -d --cpus=0.5 myapp
# Combined
docker run -d --memory=512m --cpus=0.5 --name api myapp:1.0In Docker Compose:
services:
api:
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256MScan Images for Vulnerabilities
# Docker Scout (built into Docker CLI)
docker scout cves myapp:1.0
# Trivy (open-source, widely used in CI)
trivy image myapp:1.0
# Grype (Anchore's scanner)
grype myapp:1.0Integrate scanning into CI so builds fail on critical CVEs:
trivy image --exit-code 1 --severity CRITICAL myapp:$GITHUB_SHAUse Read-Only Filesystems
# Run container with read-only root filesystem
docker run -d --read-only \
--tmpfs /tmp \
--tmpfs /var/run \
myapp:1.0In Docker Compose:
services:
api:
read_only: true
tmpfs:
- /tmp
- /var/runCommon Mistakes
- Building images FROM
node:latest— tag changes without warning, breaking reproducible builds - Including
node_modulesin the Docker build context — drastically slows builds and inflates image size - Running the application process as PID 1 without a proper init process — zombie processes accumulate
- Storing secrets as ENV instructions in Dockerfiles — they are visible in
docker historyand image layers - Using
CMD ["sh", "-c", "node server.js"]— the app becomes a child of sh and does not receive SIGTERM properly
Best Practices
- Use
COPYinstead ofADDunless you specifically need URL fetching or auto-extraction - Use
CMD ["node", "server.js"](exec form) notCMD node server.js(shell form) for proper signal handling - Set
WORKDIRexplicitly — never rely on the default/working directory - Clean up package manager caches in the same RUN layer:
&& apt-get clean && rm -rf /var/lib/apt/lists/* - Label your images with metadata:
LABEL org.opencontainers.image.version="1.0.0" - Use
--no-cachefor package managers (pip install --no-cache-dir,npm ci) to avoid cached stale packages in the image
Key Takeaways
- Switching from
node:20tonode:20-alpinereduces image size by ~80% with zero application changes - Multi-stage builds are mandatory for compiled languages — keep build tools out of production images
- Copy dependency manifests before source code to maximize layer cache hits and speed up CI builds
- Non-root users, read-only filesystems, and dropped capabilities are the three pillars of container hardening
- Health checks are required for container orchestrators to route traffic correctly and replace unhealthy pods
- Scan images in CI with Trivy or Docker Scout and fail the build on CRITICAL severity vulnerabilities
- Never store secrets in ENV Dockerfile instructions — use runtime environment injection or secrets managers
.dockerignoreis as important as.gitignore— without it, build contexts bloat and secrets leak into image layers
Advertisement