Docker Best Practices in 2026 — Production-Ready Containers

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

Docker is the default deployment unit for backend services in 2026. But "it runs in Docker" and "it runs well in production Docker" are different things. Oversized images slow CI by minutes. Root-running containers create unnecessary attack surface. Missing health checks cause orchestrators to route to broken instances. This post covers the patterns that matter for production reliability, security, and build speed.

Multi-Stage Builds

Multi-stage builds are the single highest-ROI Dockerfile pattern. They separate build tooling from the runtime image, producing images that can be 10x smaller.

# Dockerfile — Node.js API with multi-stage build
 
# Stage 1: Dependencies
FROM node:22-alpine AS deps
WORKDIR /app
 
# Copy only package files first (layer caching)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
 
# Stage 2: Build
FROM node:22-alpine AS builder
WORKDIR /app
 
# Install ALL deps including devDeps for build
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
 
COPY . .
RUN npm run build
 
# Stage 3: Runtime
FROM node:22-alpine AS runner
WORKDIR /app
 
# Security: run as non-root user
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup
 
# Copy only what runtime needs
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json .
 
# Drop to non-root
USER appuser
 
# Document the port (does not publish it)
EXPOSE 3000
 
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1
 
CMD ["node", "dist/index.js"]

Image size comparison:

node:22              → 1.1 GB
node:22-alpine       → 180 MB
Multi-stage alpine   → 45 MB  (production target)

Layer Caching Strategy

Docker builds layers from top to bottom and caches each layer. Invalidating an early layer rebuilds everything below it. Order matters enormously for CI speed.

# WRONG: Copies all source before installing deps
# Any source change rebuilds deps (2-3 minutes per commit)
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
 
# RIGHT: Copy dep manifests first, source second
FROM node:22-alpine AS builder
WORKDIR /app
 
# 1. Copy only dep manifests (cached until deps change)
COPY package.json package-lock.json ./
RUN npm ci
 
# 2. Copy source (only this layer and below rebuild on source changes)
COPY . .
RUN npm run build
 
# Cache timing (approximate):
# Dep change: full rebuild ~3 minutes
# Source change only: ~30 seconds (deps layer cached)
# Python (same principle)
FROM python:3.12-slim AS builder
WORKDIR /app
 
# 1. Deps first
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
 
# 2. Source second
COPY . .

Security Hardening

# production/Dockerfile.hardened
 
FROM node:22-alpine AS runner
WORKDIR /app
 
# 1. Non-root user
RUN addgroup -g 1001 -S app && \
    adduser -S app -u 1001 -G app
 
# 2. Enforce read-only filesystem at runtime:
# docker run --read-only --tmpfs /tmp ...
 
# 3. Drop capabilities at runtime:
# docker run --cap-drop ALL --cap-add NET_BIND_SERVICE ...
 
COPY --chown=app:app --from=builder /app/dist ./dist
COPY --chown=app:app --from=deps /app/node_modules ./node_modules
 
USER app
 
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.version="1.0.0"
 
CMD ["node", "dist/index.js"]
# docker-compose.yml — enforce security at compose level
services:
  api:
    build: .
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true
    user: "1001:1001"
    environment:
      - NODE_ENV=production
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

Health Checks and Graceful Shutdown

// src/server.ts — production-grade health checks and shutdown
 
import express from 'express';
import { pool } from './db';
 
const app = express();
let isShuttingDown = false;
 
// Liveness: is the process alive?
app.get('/health/live', (req, res) => {
  if (isShuttingDown) {
    return res.status(503).json({ status: 'shutting_down' });
  }
  res.json({ status: 'ok' });
});
 
// Readiness: can the process handle requests?
app.get('/health/ready', async (req, res) => {
  try {
    await pool.query('SELECT 1');
    res.json({ status: 'ready', db: 'ok' });
  } catch (err) {
    res.status(503).json({ status: 'not_ready', db: 'error' });
  }
});
 
const server = app.listen(3000);
 
// Graceful shutdown: finish in-flight requests before exit
function gracefulShutdown(signal: string) {
  console.log(`Received ${signal}, starting graceful shutdown`);
  isShuttingDown = true;
 
  server.close(async () => {
    console.log('HTTP server closed');
    await pool.end();
    process.exit(0);
  });
 
  // Force exit if graceful shutdown takes too long
  setTimeout(() => {
    console.error('Graceful shutdown timeout, forcing exit');
    process.exit(1);
  }, 30_000);
}
 
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

Image Scanning and CI Integration

# .github/workflows/docker.yml
name: Docker Build and Scan
 
on:
  push:
    branches: [main]
  pull_request:
 
jobs:
  build-and-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Build image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: false
          tags: myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          load: true
 
      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: 1
 
      - name: Push to registry
        if: github.ref == 'refs/heads/main'
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/org/myapp:latest
            ghcr.io/org/myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Common Mistakes

  • Running containers as root — creates unnecessary attack surface even if the container is otherwise isolated
  • Copying the entire repository before installing deps — defeats layer caching, adds minutes to every build
  • Not including a HEALTHCHECK — orchestrators cannot detect broken instances without health checks
  • Using latest tags in production — not reproducible, breaks rollbacks
  • Installing devDependencies in the runtime image — inflates image size and attack surface
  • Not setting --start-period on health checks — causes orchestrators to kill containers during startup

Best Practices

  • Use multi-stage builds in every production Dockerfile — no exceptions
  • Order Dockerfile instructions from least to most frequently changing: base, deps, source
  • Use Alpine or distroless base images — node:22-alpine over node:22
  • Run as non-root with explicit UID/GID
  • Include separate liveness, readiness, and startup health endpoints
  • Pin base image versions (e.g., node:22.4.1-alpine) and update on a schedule
  • Scan images in CI with Trivy and fail on HIGH/CRITICAL CVEs
  • Tag images with git SHA for reproducibility and rollback capability

Key Takeaways

  • Multi-stage builds reduce Node.js image sizes from 1.1 GB to under 50 MB, speeding CI and reducing attack surface
  • Layer caching depends on instruction order: copy dependency manifests before source files to avoid reinstalling deps on every commit
  • Non-root users and read-only filesystems are now standard security baselines, not optional hardening
  • Health checks must include --start-period to prevent orchestrators from killing containers during initialization
  • Separate liveness, readiness, and startup probes give orchestrators fine-grained control over traffic routing
  • Image tag latest in production is an anti-pattern — use git SHA tags for reproducibility and reliable rollbacks
  • Trivy or Grype vulnerability scanning integrated into CI catches CVEs before they reach production
  • Alpine base images (node:22-alpine) carry roughly 80% fewer CVEs than full Debian images

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading