Docker Best Practices in 2026 — Production-Ready Containers
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: 10sHealth 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=maxCommon 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
latesttags in production — not reproducible, breaks rollbacks - Installing devDependencies in the runtime image — inflates image size and attack surface
- Not setting
--start-periodon 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-alpineovernode: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-periodto prevent orchestrators from killing containers during initialization - Separate liveness, readiness, and startup probes give orchestrators fine-grained control over traffic routing
- Image tag
latestin 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