Docker Best Practices 2025 — Production Checklist for Secure, Lean Images

Sanjeev SharmaSanjeev Sharma
6 min read

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-debian12

Alpine-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-debian12

Pin 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.19

Pin OS package versions too:

RUN apk add --no-cache \
    curl=8.5.0-r0 \
    ca-certificates=20230506-r0

Multi-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 appuser

For images based on Ubuntu/Debian:

RUN groupadd -r app && useradd -r -g app app
USER app

Verify 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.md

This 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 1

For apps without curl:

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

Set 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.0

In Docker Compose:

services:
  api:
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 256M

Scan 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.0

Integrate scanning into CI so builds fail on critical CVEs:

trivy image --exit-code 1 --severity CRITICAL myapp:$GITHUB_SHA

Use Read-Only Filesystems

# Run container with read-only root filesystem
docker run -d --read-only \
  --tmpfs /tmp \
  --tmpfs /var/run \
  myapp:1.0

In Docker Compose:

services:
  api:
    read_only: true
    tmpfs:
      - /tmp
      - /var/run

Common Mistakes

  • Building images FROM node:latest — tag changes without warning, breaking reproducible builds
  • Including node_modules in 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 history and 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 COPY instead of ADD unless you specifically need URL fetching or auto-extraction
  • Use CMD ["node", "server.js"] (exec form) not CMD node server.js (shell form) for proper signal handling
  • Set WORKDIR explicitly — 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-cache for package managers (pip install --no-cache-dir, npm ci) to avoid cached stale packages in the image

Key Takeaways

  • Switching from node:20 to node:20-alpine reduces 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
  • .dockerignore is as important as .gitignore — without it, build contexts bloat and secrets leak into image layers

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading