Docker Production
Production Docker patterns. Multi-stage builds that actually minimize image size, security hardening, Compose configs that survive real traffic, and debugging techniques.
1. Multi-Stage Builds
Base images, mid-2026. Use a current LTS for runtimes you don't want to babysit and current stable for everything else. As of Jul 2026: Node 24 LTS (default, Active LTS) or 22 (Maintenance LTS until Apr 2027); Node 20 is EOL (Mar 2026); Go 1.25/1.26; Rust current stable (1.97); Python 3.13 (3.14 if you've tested C-extension wheels); Postgres 17 (18 once your extensions support it); Redis 8 (or the BSD-licensed Valkey 8 fork). Verify the latest patch tags at the official Docker Hub pages and pin a digest for reproducibility (see below). Always require BuildKit:
DOCKER_BUILDKIT=1(default in modern Docker /docker buildx).
Node.js
# syntax=docker/dockerfile:1
# Stage 1: Install ALL deps once (cached via BuildKit), then prune to prod
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# Cache mount keeps npm's cache across builds without baking it into a layer
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Stage 2: Build
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Derive a clean production-only node_modules from the lockfile
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# Stage 3: Production
FROM node:24-alpine AS production
WORKDIR /app
# Security: non-root user (UID/GID 10001 — high, avoids clashing with host users)
RUN addgroup -g 10001 -S appgroup && \
adduser -S appuser -u 10001 -G appgroup
# tini for proper PID 1 / signal handling (install before dropping privileges)
RUN apk add --no-cache tini
# Only production deps + build output, owned by the runtime user
COPY --chown=appuser:appgroup --from=build /app/node_modules ./node_modules
COPY --chown=appuser:appgroup --from=build /app/dist ./dist
COPY --chown=appuser:appgroup --from=build /app/package.json ./
# Drop to non-root for the rest of the lifecycle
USER appuser
EXPOSE 3000
ENV NODE_ENV=production
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
Result: ~150MB instead of ~1.2GB with the naive approach. The --mount=type=cache keeps the npm cache out of the final image and makes rebuilds fast; npm ci --omit=dev replaces the old "install twice + copy" dance.
Python
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS build
WORKDIR /app
# Install build dependencies (apt cache mounts speed up rebuilds)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev
COPY requirements.txt .
# Cache wheels across builds; --prefix gives a relocatable tree to copy later
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --prefix=/install -r requirements.txt
FROM python:3.13-slim AS production
WORKDIR /app
# Runtime dependencies only
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
libpq5 tini
# Non-root user (high numeric UID)
RUN groupadd -g 10001 -r appgroup && \
useradd -u 10001 -r -g appgroup -d /app appuser
COPY --from=build /install /usr/local
COPY --chown=appuser:appgroup . .
USER appuser
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
EXPOSE 8000
ENTRYPOINT ["tini", "--"]
CMD ["gunicorn", "app:create_app()", "-w", "4", "-b", "0.0.0.0:8000", "--access-logfile", "-"]
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
aptcache mounts: when you mount/var/cache/aptand/var/lib/aptas caches, droprm -rf /var/lib/apt/lists/*(the lists now live in the cache, not the layer) and do not setdocker-cleanremoval.sharing=lockedserializes concurrent builds so apt's dpkg DB stays consistent.
Go
# syntax=docker/dockerfile:1
FROM golang:1.26-alpine AS build
WORKDIR /app
# Install CA certs in the BUILDER so we control that they exist,
# then create a non-root user entry to copy into scratch.
RUN apk add --no-cache ca-certificates && \
adduser -D -u 10001 appuser
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
# Scratch image — only your binary, CA bundle, and a passwd entry.
FROM scratch AS production
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# /etc/passwd carries the appuser entry so USER resolves to a real, non-root id
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /server /server
USER appuser
EXPOSE 8080
ENTRYPOINT ["/server"]
Result: ~10MB image. Tiny, but not "nothing to exploit": your binary, its dependencies, the kernel/syscall surface, and the network service you expose are all still attack surface. scratch removes the shell and package manager (so RCE can't apt install tooling), which is a real but partial win.
scratchrequiresUSERtoo. A scratch image with noUSERruns as root (uid 0) — exactly what the checklist below forbids. You must (a) bake a/etc/passwdentry in the builder and copy it, then setUSER appuser, or (b) set a numericUSER 10001:10001(works without/etc/passwd, but some libs that calluser.Current()will error). Also remember scratch has no/tmp, no timezone DB, and noHEALTHCHECKexec — providetmpfsfor temp dirs and use an external/TCP healthcheck.
Prefer distroless for most services — it gives you CA certs, /etc/passwd, timezone data, and a guaranteed-nonroot tag, while staying nearly as small and far less fiddly than scratch:
# ... same golang:1.26-alpine build stage as above ...
FROM gcr.io/distroless/static-debian12:nonroot AS production
COPY --from=build /server /server
# distroless :nonroot already runs as uid 65532, ships CA certs + tzdata
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
Rust
# syntax=docker/dockerfile:1
FROM rust:1-slim AS build
WORKDIR /app
# Cache the registry + git index + compiled deps via BuildKit mounts.
# This replaces the old "build a dummy main.rs first" hack entirely.
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
cargo build --release && \
cp /app/target/release/myapp /myapp # copy OUT of the cache mount
FROM debian:bookworm-slim AS production
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tini
RUN groupadd -g 10001 -r app && useradd -u 10001 -r -g app app
COPY --from=build /myapp /usr/local/bin/myapp
USER app
EXPOSE 8080
ENTRYPOINT ["tini", "--"]
CMD ["myapp"]
rust:1-slimalways resolves to the current stable 1.x; pin a concrete patch (rust:1.97-slim) + digest for reproducible builds. Because/app/targetis a cache mount (not a layer), you mustcpthe binary out before the stage ends; anything left only in the mount is not available toCOPY --from.
Reproducibility: pin digests, rebuild on a schedule
A tag like node:24-alpine is a moving target: the same Dockerfile builds different images week to week. For supply-chain integrity, pin the immutable digest and re-resolve it deliberately:
# Pin the exact content, not just the tag. Keep the human-readable tag as a comment.
FROM node:24-alpine@sha256:<digest> AS deps # node:24.x-alpine, resolved 2026-07
# Get the current digest for a tag (so you can pin it)
docker buildx imagetools inspect node:24-alpine --format '{{.Manifest.Digest}}'
# Build reproducibly: clamp file timestamps so layer hashes are deterministic
SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) \
docker buildx build --output type=docker --provenance=true -t myapp:$GIT_SHA .
- Rebuild automation: pinning digests freezes known CVEs in too. Run a weekly job (Dependabot/Renovate "docker" updater, or a scheduled CI build) that bumps the pinned digest to the latest patch and re-runs the Trivy gate. Pin for reproducibility; rebuild on a cadence for patches.
- Lockfiles in, not resolved at build: copy
package-lock.json/go.sum/Cargo.lockand usenpm ci/go mod download/cargo build --lockedso dependency versions are fixed, not floated.
2. Security Hardening
Non-root users (mandatory)
Use a high numeric UID/GID (e.g. 10001) so it can't collide with a real host user if the container's filesystem is bind-mounted, and so a read-only root FS still works.
# Alpine
RUN addgroup -g 10001 -S app && adduser -S app -u 10001 -G app
USER app
# Debian/Ubuntu
RUN groupadd -g 10001 -r app && useradd -u 10001 -r -g app -d /app app
USER app
This applies to
scratchanddistrolesstoo. Ascratchimage with noUSERruns as root — bake an/etc/passwdentry in the builder and copy it (or setUSER 10001:10001numerically); see the Go example in §1.distroless:nonroottags already run as uid 65532. There is no "the image is tiny so root is fine" exemption — root inside the container is still root against any mounted volume and a larger blast radius if the kernel is exploited.
To prove it after build: docker run --rm myapp id should NOT print uid=0(root).
Read-only filesystem
# docker-compose.yml
services:
app:
image: myapp:1.4.2 # explicit tag — see "No latest tag" below
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=100m
- /app/cache:noexec,nosuid,size=50m
security_opt:
- no-new-privileges:true
Vulnerability scanning with Trivy
# Scan a specific built image (use the tag/SHA you actually built, not :latest)
trivy image myapp:1.4.2
# Scan and fail CI if HIGH/CRITICAL found; skip CVEs that have no fix yet
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed myapp:${GITHUB_SHA}
# Scan the Dockerfile / IaC for misconfig (root user, no healthcheck, etc.)
trivy config Dockerfile
# In GitHub Actions — PIN to a release tag (or full SHA), never @master:
- name: Scan image
uses: aquasecurity/trivy-action@0.28.0 # check github.com/aquasecurity/trivy-action/releases for current
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: "1"
severity: HIGH,CRITICAL
ignore-unfixed: true # don't fail on CVEs with no available fix
Never
@master. A floating action ref lets an upstream change (or a compromise) run in your pipeline without review. Pin a tag for readability or a full commit SHA for maximum integrity, and let Dependabot bump it. The same rule applies to every third-party action in your workflows.
.dockerignore (don't ship your secrets — or break your build)
# Secrets & VCS — always exclude
.git
.github
.env
.env.*
!.env.example # keep the template; re-include after a broad exclude
# Heavy / regenerated — exclude
node_modules
target
__pycache__
coverage
.nyc_output
dist # ONLY if you build inside the image; keep if you COPY a prebuilt dist
# Tests & editor cruft
__tests__
*.test.*
.vscode
.idea
.dockerignore
Don't blanket-exclude
*.md,Dockerfile, ordocker-compose*.yml. Many builds legitimately needREADME.md/LICENSE(Go modules, somepip/npm packaging, license-scanning steps), and excludingDockerfile/docker-compose*.ymlbreaks tools that read them from the build context (e.g. multi-Dockerfile setups, generated compose files baked in). Exclude specific docs you know are unused (e.g.docs/,CHANGELOG.md) rather than the whole glob, and use a!negationto re-include anything a broad rule swept up. Note.dockerignorepatterns are prefix-anchored unless they start with**/—coveragematches only./coverage, notsrc/coverage.
No latest tag — ever
# BAD: which version is "latest"? Nobody knows.
docker pull myapp:latest
# GOOD: semantic version or commit SHA
docker pull myapp:1.4.2
docker pull myapp:abc123f
# In CI, tag with both:
docker tag myapp:$SHA myapp:$VERSION
docker tag myapp:$SHA myapp:latest # Only for convenience, never for deploys
3. Compose for Production
No top-level
version:key. It's obsolete under the current Compose Specification (Compose v2, thedocker composeplugin) — Compose now warnsthe attribute version is obsolete, it will be ignored. Delete it. Likewise thedocker-compose(v1, Python, hyphenated) binary is EOL; usedocker compose(space).
deploy:is mostly Swarm-only. A plaindocker compose upon a single host ignoresdeploy.replicas,deploy.restart_policy,deploy.update_config,deploy.rollback_config, anddeploy.placement; those only take effect underdocker stack deploy(Swarm). The onlydeployfields single-host Compose honors aredeploy.resources.limits(cpus/memory → container limits) anddeploy.resources.reservations(soft). For single-host equivalents use top-levelrestart:unless-stopped(notdeploy.restart_policy) and run multiple instances withdocker compose up --scale app=3behind a load balancer (though for real replicas/rolling updates you want Swarm, K8s, or ECS, see §9). The keys below are annotated with which mode honors them.
# docker-compose.production.yml — Compose Specification (no version: key)
services:
app:
image: registry.example.com/myapp:${VERSION} # require an explicit tag; never :latest
restart: unless-stopped # single-host restart (honored by `compose up`)
deploy:
# replicas/restart_policy below are SWARM-ONLY — ignored by `docker compose up`.
# Kept for `docker stack deploy`; on a single host use `--scale app=3` + the
# top-level `restart:` above instead.
replicas: 3 # SWARM ONLY
resources:
limits: # HONORED by single-host compose
cpus: "1.0"
memory: 1G
reservations: # HONORED (soft) by single-host compose
cpus: "0.25"
memory: 256M
restart_policy: # SWARM ONLY
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
env_file:
- .env.production
networks:
- frontend
- backend
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
tag: "{{.Name}}"
read_only: true
tmpfs:
- /tmp:size=100m
security_opt:
- no-new-privileges:true
postgres:
image: postgres:17-alpine # 18 once your extensions (pgvector, PostGIS, etc.) ship 18 builds
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: "2.0"
memory: 4G
shm_size: 256mb # Important for PG performance
networks:
- backend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:8-alpine # or valkey/valkey:8-alpine — the BSD-licensed fork, drop-in for most uses
restart: unless-stopped
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
networks:
- backend
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- app
networks:
- frontend
deploy:
resources:
limits:
cpus: "0.5"
memory: 128M
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No external access — only inter-service
volumes:
postgres_data:
driver: local
redis_data:
driver: local
secrets:
db_password:
file: ./secrets/db_password.txt
4. Secrets Management
Compose file-based secrets (single host, no Swarm needed)
The Compose Specification supports secrets: on plain docker compose up — each secret is mounted read-only at /run/secrets/<name>, never placed in the environment or inspect output. This is what the §3 compose file uses for db_password. Prefer this over environment: for anything sensitive:
services:
app:
secrets:
- db_password # available at /run/secrets/db_password (mode 0444)
secrets:
db_password:
file: ./secrets/db_password.txt # chmod 600, gitignored
# or, to read from the host env instead of a file:
# environment: DB_PASSWORD # Compose Spec >= 2.x
In your app, read the file — and prefer the *_FILE convention many official images support (POSTGRES_PASSWORD_FILE, etc.) so the secret never transits an env var:
const password = fs.readFileSync(process.env.DB_PASSWORD_FILE ?? '/run/secrets/db_password', 'utf8').trim();
Docker secrets (Swarm mode)
# Create secret from stdin (don't pass it as a CLI arg — args leak via `ps`/history)
printf '%s' "$DB_PASSWORD" | docker secret create db_password -
# Use in service
docker service create --secret db_password myapp
# In container, secret is at /run/secrets/db_password
BuildKit secrets (build-time)
# Don't bake secrets into layers!
# BAD:
COPY .npmrc /app/.npmrc
RUN npm ci
RUN rm /app/.npmrc # Still in layer history!
# GOOD: BuildKit mount secret
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc npm ci
# Secret never appears in any layer
docker build --secret id=npmrc,src=.npmrc -t myapp .
Environment variable security
# BAD: secrets in docker-compose.yml or CLI
environment:
- DB_PASSWORD=hunter2 # Visible in docker inspect
# BETTER: env_file (still visible in inspect, but not in source)
env_file:
- .env.production
# BEST: Compose/Swarm secrets + file reading (never in env, never in inspect)
# In your app:
# const password = fs.readFileSync('/run/secrets/db_password', 'utf8').trim();
env_file:keeps secrets out of your source, but they are still visible to anyone who can rundocker inspect <container>or read/proc/<pid>/environon the host. Treat env vars as non-secret config (ports, feature flags, log level) and route real credentials through file-based secrets above.
4b. Supply-Chain: SBOM, Provenance & Signing
For anything you ship to others or run in regulated/audited environments, attach a software bill of materials and provenance, then sign the image so consumers can verify it.
# Build with an SBOM (SPDX) + SLSA provenance attestation attached to the image
docker buildx build \
--sbom=true \
--provenance=mode=max \
--tag registry.example.com/myapp:$GIT_SHA \
--push .
# Inspect what got attached
docker buildx imagetools inspect registry.example.com/myapp:$GIT_SHA --format '{{json .SBOM}}'
docker buildx imagetools inspect registry.example.com/myapp:$GIT_SHA --format '{{json .Provenance}}'
# Generate a standalone SBOM with Syft, or scan the SBOM with Grype/Trivy
syft registry.example.com/myapp:$GIT_SHA -o spdx-json > sbom.spdx.json
trivy sbom sbom.spdx.json --exit-code 1 --severity HIGH,CRITICAL
Keyless signing with cosign (recommended over long-lived keys) — uses your CI's OIDC identity (Fulcio/Rekor), so there's no private key to leak:
# In GitHub Actions, with `id-token: write` permission set on the job:
cosign sign --yes registry.example.com/myapp:$GIT_SHA
# Attach the SBOM as a signed attestation
cosign attest --yes --predicate sbom.spdx.json \
--type spdxjson registry.example.com/myapp:$GIT_SHA
# Verify, constraining WHO signed it and from WHICH workflow:
cosign verify \
--certificate-identity-regexp 'https://github.com/your-org/.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
registry.example.com/myapp:$GIT_SHA
Enforce at admission, not just at build. A signature/SBOM is only useful if something checks it before running the image — wire verification into your deploy gate (e.g. a Kyverno/Sigstore policy controller on K8s, or a
cosign verifystep beforedocker stack deploy). Otherwise it's decoration.
5. Networking
Custom networks for isolation
networks:
# Frontend network — nginx + app
frontend:
driver: bridge
# Backend network — app + db + redis
# internal: true means no external access
backend:
driver: bridge
internal: true
# Monitoring network
monitoring:
driver: bridge
internal: true
Service discovery
# Services on the same network can reach each other by service name
services:
app:
environment:
- DATABASE_URL=postgresql://myapp:pass@postgres:5432/myapp
- REDIS_URL=redis://redis:6379
networks:
- backend
postgres:
networks:
- backend # Reachable as "postgres" from app
DNS resolution debugging
# Check DNS from inside a container
docker exec -it myapp nslookup postgres
docker exec -it myapp ping redis
# Inspect network
docker network inspect backend
# Check which networks a container is on
docker inspect myapp --format '{{json .NetworkSettings.Networks}}' | jq
6. Logging
JSON structured logging
// Use JSON logging — parseable by any log aggregator
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
formatters: {
level: (label) => ({ level: label }),
},
// Don't pretty-print in production
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
});
// Structured context
logger.info({ userId: '123', action: 'login', ip: '1.2.3.4' }, 'User logged in');
// Output: {"level":"info","time":1234567890,"userId":"123","action":"login","msg":"User logged in"}
Docker logging drivers
# JSON file (default) — good for small deployments
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
# Fluentd — forward to ELK/Loki
logging:
driver: fluentd
options:
fluentd-address: localhost:24224
tag: "docker.{{.Name}}"
# Loki — native Grafana integration
logging:
driver: loki
options:
loki-url: "http://loki:3100/loki/api/v1/push"
loki-batch-size: "400"
loki-retries: "3"
Log rotation (don't fill your disk)
// /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "5"
}
}
7. Debugging Production Containers
# View logs
docker logs myapp --tail 100 -f
docker logs myapp --since 30m
# Execute command in running container
docker exec -it myapp /bin/sh
# Check environment — but DON'T dump it all: `docker exec myapp env`, `docker inspect`,
# and process args all expose secrets (DB passwords, API keys, tokens) to anyone with
# host/Docker access, and may land in logs/screen-shares. Inspect ONE non-secret key:
docker exec myapp printenv NODE_ENV
# Confirm a secret was MOUNTED (correct pattern) without printing its value:
docker exec myapp test -f /run/secrets/db_password && echo "secret present"
# Resource usage (CPU, memory, network, disk I/O)
docker stats myapp
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
# Events (container lifecycle)
docker events --filter container=myapp --since 1h
# Inspect everything
docker inspect myapp | jq '.[0].State'
docker inspect myapp | jq '.[0].NetworkSettings.Networks'
# Disk usage
docker system df
docker system df -v # Verbose — shows per-image/container/volume
# Clean up — DESTRUCTIVE. On a prod host, dry-run/scope first; `-f` skips the confirm prompt.
# Run WITHOUT -f so Docker prints what it will delete and asks y/N:
docker system prune # stopped containers + unused networks + dangling images (prompts)
docker image prune # dangling (untagged) images only — safe-ish
# `image prune -a` removes EVERY image not used by a running container — including ones
# you'll redeploy in 5 minutes. Scope by age instead of nuking everything:
docker image prune -a --filter "until=168h" # only images older than 7 days
docker builder prune --filter "until=168h" # trim build cache older than 7 days
# `docker volume prune` DELETES DATA. Never run blanket on a host with stateful services
# (Postgres/Redis volumes). List first, then remove a specific volume by name:
docker volume ls
docker volume rm <explicit_volume_name> # never `volume prune -f` in prod
# Copy files from container
docker cp myapp:/app/logs/error.log ./error.log
# Check why a container exited
docker inspect myapp --format '{{.State.ExitCode}} {{.State.Error}}'
Debugging networking
# Check if service is listening
docker exec myapp netstat -tlnp
docker exec myapp ss -tlnp
# DNS resolution
docker exec myapp nslookup postgres
# Test connectivity
docker exec myapp wget -qO- http://api:3000/health
docker exec myapp curl -v telnet://postgres:5432
# Packet capture (needs NET_ADMIN capability)
docker exec myapp tcpdump -i eth0 port 5432 -nn
8. Private Registry
Self-hosted with Docker Registry
For most teams, don't self-host: use a managed registry (GitHub Container Registry / GHCR, GitLab, AWS ECR, Google Artifact Registry, Docker Hub). They handle auth, TLS, replication, vuln scanning, and retention for you. Self-host only when you need air-gapped or on-prem control. The
registry:3(CNCF Distribution) setup below is a baseline for that case.
# registry/docker-compose.yml
services:
registry:
image: registry:3
restart: unless-stopped
# Bind to localhost only and terminate TLS at a reverse proxy (below), OR
# publish 5000 and set the REGISTRY_HTTP_TLS_* vars to serve TLS directly.
ports:
- "127.0.0.1:5000:5000"
volumes:
- registry_data:/var/lib/registry
- ./auth:/auth:ro
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: "Registry Realm"
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
REGISTRY_STORAGE_DELETE_ENABLED: "true" # required for GC to reclaim space
deploy:
resources:
limits:
memory: 512M
volumes:
registry_data:
# Create the auth file. -B = bcrypt. Do NOT pass the password on the command line with -b:
# `htpasswd -Bbn user pass` leaks the password into your shell history and the process
# args (visible via `ps`/`docker inspect`). Use -i to read it from STDIN instead:
mkdir -p auth
read -rs -p "Registry password: " REG_PW && echo
printf '%s' "$REG_PW" | docker run --rm -i --entrypoint htpasswd httpd:2 -iBn myuser > auth/htpasswd
unset REG_PW
chmod 600 auth/htpasswd # readers: registry only
# (-b reads from CLI = leak; -i reads from STDIN = safe. Keep -i for real credentials.)
# Login and push (you'll be prompted for the password, not pass it as an arg)
docker login registry.example.com
docker tag myapp registry.example.com/myapp:1.0.0
docker push registry.example.com/myapp:1.0.0
Terminate TLS at a reverse proxy (Caddy/Nginx/Traefik) rather than juggling cert files in the registry env — you get automatic Let's Encrypt renewal and HTTP/2:
# Caddyfile — Caddy auto-provisions + renews the TLS cert
registry.example.com {
reverse_proxy 127.0.0.1:5000
request_body { max_size 0 } # don't cap large image-layer uploads
}
Image signing with Cosign
Prefer keyless signing in CI (see §4b) — no private key to store or rotate. The keypair flow below is for local/offline use; if you keep a key, store it in a secrets manager (not the repo) and protect it with COSIGN_PASSWORD:
# Install cosign
brew install cosign # or: go install github.com/sigstore/cosign/v2/cmd/cosign@latest
# Keyless (recommended) — signs against your OIDC identity:
cosign sign --yes registry.example.com/myapp:1.0.0
# Keypair (offline) — guard the private key:
cosign generate-key-pair # creates cosign.key (KEEP SECRET) + cosign.pub
cosign sign --key cosign.key registry.example.com/myapp:1.0.0
cosign verify --key cosign.pub registry.example.com/myapp:1.0.0
Garbage collection (causes downtime — schedule it)
Deleting a tag only removes the reference; the blobs stay until GC runs. GC requires the registry to be read-only for correctness — concurrent pushes during GC can corrupt or lose layers. Treat it as a maintenance window:
# 1. Put the registry in read-only mode (REGISTRY_STORAGE_MAINTENANCE_READONLY_ENABLED=true)
# or stop pushes, then run GC. -m also purges now-unreferenced manifests:
docker exec registry bin/registry garbage-collect --delete-untagged \
/etc/distribution/config.yml
# 2. Restart in read-write mode.
# Retention: registry:3 has no built-in tag retention. Prune old tags out-of-band before GC,
# e.g. with a script over the v2 API, or run a tool with policies (Harbor, JFrog) for real RBAC,
# replication, scanning, and tag-retention rules — graduate to one of those at scale.
Enabling upload purging (clears stalled uploads, not old tags) in
config.yml:storage: maintenance: uploadpurging: enabled: true age: 168h # 1 week interval: 24h
9. When to Graduate from Compose
Stay on Compose when:
- Single host deployment
- < 10 services
- Simple scaling needs (
docker compose up --scale app=Nbehind a proxy; ordocker stack deployto a one-node Swarm if you wantdeploy.replicas) - Team is small and doesn't need multi-host
Move to Kubernetes when:
- Multi-host / multi-region required
- Need auto-scaling based on metrics
- Zero-downtime rolling updates are critical
- Service mesh (mTLS between services)
- Team has K8s expertise
Move to ECS/Fargate when:
- Want managed orchestration without K8s complexity
- Already on AWS
- Need auto-scaling but not the full K8s feature set
- Small team, want less operational overhead
Hybrid approach (common):
# Use Compose for development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Use Compose for staging (single host)
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
# Use ECS/K8s for production
# Same Dockerfiles, different orchestration
10. Production Dockerfile Checklist
- [ ] `# syntax=docker/dockerfile:1` + BuildKit enabled
- [ ] Multi-stage build (deps → build → production)
- [ ] Specific base image tag pinned to a DIGEST (node:24-alpine@sha256:..., never :latest)
- [ ] .dockerignore covers .git, node_modules, .env* — but NOT *.md/Dockerfile blanket-globbed
- [ ] Non-root user via USER directive — INCLUDING scratch/distroless images (numeric UID or /etc/passwd)
- [ ] CA certs present in the final image if it makes outbound TLS calls
- [ ] HEALTHCHECK defined (or external check for scratch, which can't exec one)
- [ ] Signal handling (tini or dumb-init) so SIGTERM reaches your app for graceful shutdown
- [ ] No secrets in layers (BuildKit --mount=type=secret, not COPY+rm); none in ENV either
- [ ] BuildKit cache mounts for the package manager (npm/pip/go/cargo)
- [ ] COPY least-changing files first (lockfiles before source) for layer caching
- [ ] Single CMD (one process per container — use Compose/orchestrator for multi-process)
- [ ] Trivy/Grype scan passes (no HIGH/CRITICAL, ignore-unfixed); CI action pinned (not @master)
- [ ] SBOM + provenance attached and image signed (cosign) for anything shipped/audited
- [ ] OCI labels for metadata (source, version, build date, commit)
# Good label practice
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.created="${BUILD_DATE}"
LABEL org.opencontainers.image.revision="${COMMIT_SHA}"