docker — container-craft
You own one question, deeply: how do I turn this app into a small, secure, fast-building
image — and run a frictionless local stack with Compose? Your artifacts are the Dockerfile,
the .dockerignore, and the compose.yaml: their layers, their attack surface, their build
cache, their dev loop. You do not touch what happens after the image is built.
source → Dockerfile (multi-stage, cache mounts) → small + scanned image + compose.yaml (dev loop)
└──────────────┬──────────────┘
hand off to ../deployment/SKILL.md
The hand-off is sharp: the moment you have a green, scanned image and a working
compose.yaml, you are done. Everything downstream is ../deployment/SKILL.md — it already
references Dockerfiles and leans on you for the image internals.
When NOT to use — and who owns it instead
| You're asked for… | Owner | Why it's not you |
|---|---|---|
| CI build, registry push, deploy to host, rollback | ../deployment/SKILL.md |
You build + harden the image; it moves the image through CI to prod |
| k8s manifests, HPA autoscaling, ingress | scaling |
Orchestration above a single image |
| App SQL injection, secrets in source, dep CVE triage | ../secure-coding/SKILL.md |
You cover only image/container hardening |
| Install/operate a self-hosted PaaS | ../coolify/SKILL.md |
Host operation, not the image |
| Containerless PaaS deploy mechanics | ../railway/SKILL.md, ../fly-io/SKILL.md |
Platform deploy, not the Dockerfile |
Pick a base image (2026 reality)
| Base | Size | CVE / patch velocity | Debuggable? | Wins when |
|---|---|---|---|---|
*-slim (e.g. bookworm-slim) |
~30–80 MB | moderate, glibc | yes (shell, apt) | pragmatic default, native deps / Python wheels |
distroless (gcr.io/distroless/*) |
~2–25 MB | convenient but patches slower | no shell | static/compiled runtimes, want minimal surface |
| Chainguard / Wolfi | tiny, glibc | lowest live CVE count, SLSA L3 attestations | minimal | security-first; real scans found high-sev CVEs where the Chainguard equivalent had zero |
alpine |
~5 MB, musl | small surface | yes (apk) | tiny static services — but musl breaks many Python wheels / native deps |
scratch |
0 | nothing to patch | no | a fully static binary (Go), nothing else |
Rule of thumb: glibc app with native deps → *-slim or Wolfi. Static Go binary → scratch or
distroless/static. Security mandate → Chainguard. Reach for alpine only when you've confirmed
your wheels/native libs build against musl. Per-language tag maps, multi-stage templates for the
stacks not shown below (Rust, JVM jlink, static SPA → nginx) and multi-arch buildx live in
references/base-images-and-stages.md.
Multi-stage skeletons
Node — npm ci with a cache mount → distroless nonroot. Bad: single-stage node:20 ≈ 1.1 GB.
Good: this ≈ 180 MB. Tags track Node 24 (the active LTS as of mid-2026); Node 22 is in Maintenance
LTS, so swap 24→22 only when you deliberately want the conservative maintenance line.
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM gcr.io/distroless/nodejs24-debian13:nonroot
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER nonroot
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD ["node", "dist/healthcheck.js"]
CMD ["dist/server.js"]
Python — uv into a venv → slim, non-root. Bad: python:3.12 + pip ≈ 1 GB. Good: ≈ 130 MB.
# syntax=docker/dockerfile:1
FROM python:3.13-slim-bookworm AS build
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
ENV UV_PROJECT_ENVIRONMENT=/opt/venv
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
FROM python:3.13-slim-bookworm
RUN useradd --create-home --uid 10001 app
COPY --from=build --chown=app:app /opt/venv /opt/venv
COPY --chown=app:app . /app
WORKDIR /app
ENV PATH="/opt/venv/bin:$PATH"
USER app
EXPOSE 8000
HEALTHCHECK CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Go — static binary → scratch. Bad: golang:1.26 ≈ 900 MB. Good: ≈ 12 MB. Pin a
supported toolchain: Go maintains only the latest two minors (1.26/1.25 as of mid-2026), so a
golang:1.23 build image is two minors past EOL and no longer gets security patches.
# syntax=docker/dockerfile:1
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/server
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app /app
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["/app"]
The cache-mount target differs per ecosystem: /root/.npm, /root/.cache/uv (or
/root/.cache/pip), /go/pkg/mod. The cache persists across builds without bloating any layer.
.dockerignore — load-bearing, not optional
Without it the entire context (including .git, node_modules, .env) ships to the daemon: it
slows the build and can bake secrets into a layer via COPY ..
.git
.gitignore
node_modules
dist
.env
.env.*
*.log
.venv
__pycache__
Dockerfile
.dockerignore
README.md
Mirror .gitignore, then add build outputs and the Dockerfile itself.
Harden the runtime
Non-root in the image (already shown above): create a user, COPY --chown, USER. Then lock the
runtime down where the container actually runs:
docker run \
--read-only --tmpfs /tmp \
--security-opt=no-new-privileges \
--cap-drop=ALL \
myimage:1.4.0
--read-only rootfs + --tmpfs /tmp for scratch space; no-new-privileges blocks setuid
escalation; --cap-drop=ALL then add back only what you truly need (--cap-add=NET_BIND_SERVICE
to bind port < 1024).
Build secrets — never ARG/ENV. An ARG TOKEN lands in the image history and any
ENV SECRET=... persists in a layer. Use a BuildKit secret mount; it is never written to a layer:
# Bad — leaks into image history: ARG NPM_TOKEN
# Good:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
docker build --secret id=npm_token,env=NPM_TOKEN .
Compose for dev
Canonical filename is compose.yaml; omit the obsolete version: key; use docker compose
(v2 subcommand), never the standalone docker-compose v1. One concern per container — the app, the
database and the seed job are separate services. develop.watch is GA since Compose 2.22.0 and
gives you a tight dev loop.
services:
api:
build: .
ports: ["8000:8000"]
depends_on:
db:
condition: service_healthy
develop:
watch:
- action: sync # hot-reload: copy changed files into the container
path: ./src
target: /app/src
- action: rebuild # lockfile / compiled langs: rebuild the image
path: ./uv.lock
- action: sync+restart # config change: sync then restart the process
path: ./config.yaml
target: /app/config.yaml
db:
image: postgres:17-bookworm
environment:
POSTGRES_PASSWORD: dev
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
seed:
profiles: ["tools"] # only runs with: docker compose --profile tools up
build: .
command: ["python", "-m", "app.seed"]
volumes:
pgdata:
Run the dev loop with docker compose watch (or docker compose up --watch). Fuller patterns —
watch-per-stack, multi-service healthcheck graphs, override files, env_file vs secrets, and
when to graduate to deployment/scaling — are in
references/compose-recipes.md.
Verify the image
These are exactly what scripts/verify.sh automates (it skips a tool gracefully if absent):
hadolint Dockerfile— static lint of the Dockerfile (no error-level findings).docker compose config -q— thecompose.yamlparses and resolves.trivy image myimage:1.4.0— CVE + misconfig + secret scan of the built image.trivy config .— scan the Dockerfile/compose for misconfiguration without a build.dockle myimage:1.4.0— CIS-style image hygiene.- size check — assert the final image is under your threshold (
docker image inspect -f '{{.Size}}').
Anti-patterns
| Bad | Good | Why |
|---|---|---|
FROM node:latest |
FROM node:24-bookworm-slim, or pin a digest |
:latest drifts under you; builds + scans become non-reproducible |
runs as root (no USER) |
USER nonroot / USER 10001 |
PID 1 defaults to root (UID 0), so a container escape == host compromise |
ARG TOKEN / ENV SECRET= |
RUN --mount=type=secret,... |
ARG/ENV persist in image history & layers |
COPY . . then RUN npm ci |
copy lockfile → install → copy source | source edits bust the dep cache layer every build |
single FROM, ship the SDK |
multi-stage, copy only the artifact | runtime carries compilers & dev CVE surface |
apt-get install x |
apt-get install --no-install-recommends x && rm -rf /var/lib/apt/lists/* |
recommends + apt lists bloat the layer |
no .dockerignore |
mirror .gitignore + build outputs |
whole context ships; .env/.git can leak |
version: "3.8" / docker-compose v1 |
omit version:; docker compose v2 |
the key is obsolete; v1 is EOL |
CMD npm start (shell form) |
CMD ["node", "server.js"] (exec) |
shell form wraps the process in /bin/sh -c, which swallows SIGTERM — no graceful shutdown |
no HEALTHCHECK |
HEALTHCHECK CMD [...] |
nothing for Compose/orchestrators to gate readiness on (condition: service_healthy) |