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(useCOPY). - 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
muslcan 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=1or 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); useCOPYunless you need auto‑extract or remote URLs. - Keep packages minimal; don’t run
apt-get upgradein 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
ENTRYPOINTfor the binary,CMDfor default args users can override. - For process reaping, either use the Docker runtime flag
--initor 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; avoidADD. - [ ] No secrets in layers; use
--secret. - [ ] Exec
ENTRYPOINT/CMD; HEALTHCHECK included. - [ ] OCI labels and pinned base image (or digest).
- [ ]
.dockerignoreprunes build context.
One‑minute adoption plan
- Convert your Dockerfile to multi‑stage.
- Reorder steps: lockfiles → deps → source. Enable BuildKit and cache mounts.
- Switch to a non‑root
USER, addHEALTHCHECK, and exec‑formENTRYPOINT. - Replace
ADDwithCOPY; remove any secrets and switch to BuildKit--secret. - Add OCI labels and pin the base tag (or digest); commit
.dockerignore.