dockerfile-best-practices · git:20260921.8d3f9eb · 2026-09-21 · sha256 d7a07860e9927f06
dockerfile-best-practices git:20260921.8d3f9ebA
Immutable. This exact content is served forever at /api/v1/blob/d7a07860e9927f06.
--- name: dockerfile-best-practices description: "Write or review a Dockerfile and compose setup for a production service: multi-stage builds that ship only the runtime, pinned base images, layer order for cache hits, non-root user, minimal image size, correct signal handling and health checks, secrets kept out of layers, and language-specific patterns for Node, Python, Go, and Java. Use when containerising a service, when images are large or slow to build, when a container ignores SIGTERM, or when a security scan flags the image." argument-hint: "(optional) the Dockerfile or the language and framework to containerise" --- A production image contains exactly what the process needs to run, built reproducibly, running as a non-root user, and stopping cleanly when asked. Everything below follows from those four properties. ## Structure: multi-stage ```dockerfile # syntax=docker/dockerfile:1.7 FROM node:22.11-bookworm-slim AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile --prod=false FROM deps AS build COPY . . RUN pnpm build && pnpm prune --prod FROM node:22.11-bookworm-slim AS runtime ENV NODE_ENV=production WORKDIR /app RUN groupadd -r app && useradd -r -g app -d /app app COPY --from=build --chown=app:app /app/node_modules ./node_modules COPY --from=build --chown=app:app /app/dist ./dist COPY --chown=app:app package.json ./ USER app EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s CMD node dist/healthcheck.js ENTRYPOINT ["node", "dist/server.js"] ``` Build stages hold compilers and dev dependencies; the runtime stage copies artifacts only. ## Rules **Base images** - Pin to a specific version tag (`22.11-bookworm-slim`), and for reproducible builds pin the digest (`@sha256:...`) with a tool (Renovate, Dependabot) to bump it. - Prefer `-slim` or distroless over `alpine` for glibc-dependent runtimes (Node native modules, Python wheels); alpine for Go static binaries is fine. - One process per container; no supervisord. **Layers and cache** - Order from least to most frequently changed: base, system packages, dependency manifests, dependency install, source, build. Copy lockfiles before source so dependency layers cache. - `RUN` steps combined with `&&` and cleanup in the same step (`apt-get install ... && rm -rf /var/lib/apt/lists/*`); a later `rm` does not shrink an earlier layer. - `.dockerignore` with `.git`, `node_modules`, build output, `.env*`, tests, docs; without it the build context and the cache invalidation are both wrong. - BuildKit cache mounts for package managers: `RUN --mount=type=cache,target=/root/.npm npm ci`. **Security** - `USER` non-root before `ENTRYPOINT`; files copied with `--chown`. Bind to a port above 1024. - No secrets in `ENV`, `ARG`, or copied files; they persist in layers and history. Use `RUN --mount=type=secret,id=npmrc` for build-time secrets and runtime injection (env at run, mounted files, a secrets manager) for the rest. - Read-only root filesystem where possible (`--read-only` with `tmpfs` for `/tmp`); drop capabilities; no `--privileged`. - Scan (`docker scout`, `trivy image`) in CI; fail on critical CVEs with a fix available. **Runtime behaviour** - `ENTRYPOINT` in exec form (JSON array) so the process is PID 1 and receives signals; `CMD` for default arguments only. Shell form wraps in `sh -c` and swallows SIGTERM. - The application handles SIGTERM: stop accepting, drain in-flight requests, close connections, exit within the orchestrator's grace period (default 30 s). If the runtime cannot (some shell scripts), use `tini` as the init (`ENTRYPOINT ["tini", "--", ...]`). - `HEALTHCHECK` (or the orchestrator's probe) hits a cheap endpoint that checks dependencies the process needs; readiness separate from liveness in Kubernetes. - Logs to stdout/stderr, unbuffered (`PYTHONUNBUFFERED=1`); no log files in the container. - `EXPOSE` documents the port; it does not publish it. **Size** - Check with `docker image ls` and `dive`. Typical targets: Go static under 20 MB (distroless/static), Node under 200 MB, Python under 250 MB. - Do not install dev dependencies, docs, or test files in the runtime stage. ## Language notes - **Node**: `npm ci` / `pnpm install --frozen-lockfile`; `NODE_ENV=production`; `node` directly, not `npm start` (npm does not forward signals); set `--max-old-space-size` from the container limit or use a version that respects cgroups. - **Python**: `python:3.12-slim`; `pip install --no-cache-dir -r requirements.txt` (or `uv sync --frozen`) in a build stage into a venv copied to runtime; `PYTHONDONTWRITEBYTECODE=1`; run with `gunicorn`/`uvicorn` directly; `--workers` from CPU limit. - **Go**: build stage `golang:1.23`, `CGO_ENABLED=0 go build -ldflags="-s -w"`, runtime `gcr.io/distroless/static-debian12:nonroot`, copy the binary and CA certs. - **Java**: build with the JDK, run on a JRE (`eclipse-temurin:21-jre`), layered jar (`spring-boot:build-image` or `jarmode=layertools`), `-XX:MaxRAMPercentage=75`. ## Compose for local development `compose.yaml` with the service built from the Dockerfile's build stage (`target: build`), source bind-mounted for hot reload, dependencies (db, cache) with named volumes and health checks, `depends_on` with `condition: service_healthy`, env from `.env` (git-ignored) with an `.env.example` committed. ## Review checklist Pinned base; multi-stage; lockfile copied before source; `.dockerignore`; non-root; exec-form `ENTRYPOINT`; signals handled; health check; no secrets in layers (`docker history` shows none); scan clean; size within target; builds reproducibly twice with the same digest.