caduh

Dockerfile Best Practices — fast builds, small images, safer containers

5 min read

Multi-stage builds, cache-friendly layering, non-root users, and sane defaults for ENTRYPOINT/CMD, healthchecks, and secrets. Copy‑paste patterns for Node, Python, and Go.

TL;DR

  • Prefer multi‑stage builds; keep the final image tiny (only runtime deps).
  • Make builds cache‑friendly: copy lockfiles first, pin versions, and use BuildKit caches.
  • Run as a non‑root user, set a WORKDIR, and avoid ADD (use COPY).
  • Don’t bake secrets into images; use BuildKit secrets and --mount=type=cache.
  • Use exec form ENTRYPOINT/CMD, add a HEALTHCHECK, and log to stdout/stderr.
  • Publish OCI labels and pin base images with digests for reproducibility.

1) Pick the right base (and pin it)

  • Choose a base that matches your runtime needs: *-slim, distroless for minimal runtime, or full Debian/Ubuntu when you need tools.
  • Pin versions (and ideally digests) for reproducible builds:
FROM node:22-slim@sha256:... AS base
  • Alpine caveat: great for size, but musl can break native deps; avoid for heavy native builds unless you know they work.
  • For Go, a distroless/static final image is ideal; if you need shell/CA tools, use debian:stable-slim.

2) Multi‑stage patterns

Go (static binary → tiny runtime)

# ---- Build stage ----
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/app ./cmd/app

# ---- Runtime stage ----
FROM gcr.io/distroless/static:nonroot
USER nonroot:nonroot
COPY --from=builder /bin/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
# HEALTHCHECK in distroless needs your app to expose an endpoint or use a sidecar

Node (cache deps → copy app)

# syntax=docker/dockerfile:1.7
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
RUN --mount=type=cache,target=/root/.npm     --mount=type=cache,target=/root/.pnpm-store     bash -lc '       if [ -f pnpm-lock.yaml ]; then npm i -g pnpm && pnpm i --frozen-lockfile;       elif [ -f yarn.lock ]; then corepack enable && yarn install --frozen-lockfile;       else npm ci; fi     '

FROM node:22-slim AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["node","server.js"]

Python (build wheels → copy only venv)

# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS build
WORKDIR /app
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1
COPY pyproject.toml poetry.lock* requirements*.txt* ./
RUN --mount=type=cache,target=/root/.cache/pip python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Prefer pyproject/poetry; fall back to requirements.txt
RUN bash -lc '   if [ -f poetry.lock ]; then pip install poetry && poetry install --only main --no-root;   elif ls requirements*.txt >/dev/null 2>&1; then pip install -r requirements.txt; fi'

FROM python:3.12-slim AS run
ENV VIRTUAL_ENV=/opt/venv PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY --from=build /opt/venv /opt/venv
COPY . .
USER 10001:10001
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import sys,urllib.request as u;   sys.exit(0 if u.urlopen('http://127.0.0.1:8000/health',timeout=2).getcode()==200 else 1)"
ENTRYPOINT ["gunicorn","-b","0.0.0.0:8000","app.wsgi:application"]

3) Make the cache work for you

  • Copy lockfiles first, install deps, then copy the rest of the source.
  • Group commands to avoid invalidating layers unnecessarily, but keep steps readable.
  • Enable BuildKit (Docker 18.09+): DOCKER_BUILDKIT=1 or in daemon config.
  • Use cache mounts for package managers:
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
RUN --mount=type=cache,target=/go/pkg/mod go build ./...
RUN --mount=type=cache,target=/root/.npm npm ci
  • For CI: docker buildx build --cache-from=type=registry,ref=... --cache-to=type=registry,mode=max,ref=....

4) Security & permissions

  • Create and use a non‑root user; set ownership with COPY --chown:
RUN useradd -u 10001 -m appuser
USER appuser
COPY --chown=appuser:appuser . /app
  • Avoid ADD (it does tar/URL magic); use COPY unless you need auto‑extract or remote URLs.
  • Keep packages minimal; don’t run apt-get upgrade in images. Install exactly what you need and clean caches:
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl   && rm -rf /var/lib/apt/lists/*
  • Don’t bake secrets (tokens, SSH keys) into layers. With BuildKit:
RUN --mount=type=secret,id=npm_token     bash -lc 'echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > ~/.npmrc && npm ci'
  • Send logs to stdout/stderr; don’t write to files inside the container by default.

5) ENTRYPOINT vs CMD (and signals)

  • Use exec form so PID 1 receives signals properly:
ENTRYPOINT ["myapp"]
CMD ["--serve"]
  • Prefer ENTRYPOINT for the binary, CMD for default args users can override.
  • For process reaping, either use the Docker runtime flag --init or include a tiny init (e.g., tini).

6) HEALTHCHECK (be gentle)

HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD curl -fsS http://127.0.0.1:8080/health || exit 1
  • Keep intervals sensible; over‑eager checks can become a self‑DDOS.
  • If your base lacks curl, consider a small inline script or use your app’s built‑in check.

7) Metadata & SBOM

Add OCI labels for traceability:

LABEL org.opencontainers.image.title="myapp"       org.opencontainers.image.description="Does things"       org.opencontainers.image.source="https://github.com/acme/myapp"       org.opencontainers.image.version="1.2.3"       org.opencontainers.image.revision="abcdef"       org.opencontainers.image.licenses="Apache-2.0"

Produce an SBOM in CI (buildx): docker buildx build --sbom=true --provenance=true ....


8) .dockerignore (big wins)

Create a .dockerignore to avoid sending junk to the daemon:

.git
node_modules
dist
build
__pycache__
*.pyc
.env
.DS_Store

This speeds context upload and reduces cache invalidations.


9) Common pitfalls & fast fixes

| Pitfall | Why it hurts | Fix | |---|---|---| | Single-stage builds | Huge images, slow deploys | Use multi‑stage; copy only runtime bits | | Copying source before deps | Cache busts every build | Copy lockfiles first, install, then source | | Running as root | Bigger blast radius | USER non‑root; COPY --chown | | Using ADD by habit | Surprise tar/URL behavior | Prefer COPY | | Baking secrets into layers | Permanent leakage | Use BuildKit secrets mounts | | apt-get upgrade in image | Non‑reproducible, slow | Install only needed pkgs; clean caches | | No healthcheck | Undetected bad pods | Add HEALTHCHECK with reasonable intervals | | Shell form ENTRYPOINT | Signals ignored | Use exec form | | Alpine with native deps | Build/runtime failures | Prefer -slim or ensure musl support |


Quick checklist

  • [ ] Multi‑stage build with a minimal final image.
  • [ ] Cache‑friendly order; lockfiles first; BuildKit caches enabled.
  • [ ] Non‑root user; COPY --chown; avoid ADD.
  • [ ] No secrets in layers; use --secret.
  • [ ] Exec ENTRYPOINT/CMD; HEALTHCHECK included.
  • [ ] OCI labels and pinned base image (or digest).
  • [ ] .dockerignore prunes build context.

One‑minute adoption plan

  1. Convert your Dockerfile to multi‑stage.
  2. Reorder steps: lockfiles → deps → source. Enable BuildKit and cache mounts.
  3. Switch to a non‑root USER, add HEALTHCHECK, and exec‑form ENTRYPOINT.
  4. Replace ADD with COPY; remove any secrets and switch to BuildKit --secret.
  5. Add OCI labels and pin the base tag (or digest); commit .dockerignore.