docker · diff

git:20260602.004e38d to git:20260729.a707f94

12 added, 44 removed. Audit A to A.

---
name: docker
- description: "Use when authoring or auditing a Dockerfile/Containerfile, shrinking a bloated image, hardening a container that runs as root, choosing a base image, or wiring a Compose dev loop with hot reload. Triggers: 'dockerize this app', 'my node image is 1.2GB make it smaller', 'the build cache keeps busting', 'harden this Dockerfile, trivy flags it', 'docker compose watch for local dev', 'distroless vs alpine vs slim', 'la imagen pesa demasiado', 'el build de docker tarda siglos'. NOT the CI/CD pipeline or deploy-to-host (that is deployment), NOT k8s autoscaling/HPA (that is scaling), NOT app-level SQL injection or secrets-in-code (that is secure-coding)."
+ description: "Use when authoring or auditing a Dockerfile, shrinking a bloated image, hardening a container that runs as root, picking a base image, or wiring a Compose dev loop with hot reload. NOT CI builds or deploy-to-host (that is `deployment`), NOT k8s autoscaling (that is `scaling`), NOT app-level injection or secrets-in-code (that is `secure-coding`)."
tags: [docker, containers, dockerfile, compose, image-security]
recommends: [deployment, secure-coding, scaling, github-actions]
origin: risco
---
# 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.
```text
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.** Pushing to a registry, building in CI, deploying to a host,
- rollback — that is `../deployment/SKILL.md`. It already references Dockerfiles; you are the
- image-internals specialist it leans on.
-
- ## When to use
-
- - Authoring or auditing a `Dockerfile` for any stack (Node, Python, Go, Rust, JVM, static SPA).
- - An image is too big (800 MB+ Node image), builds too slowly, or fails `trivy`/`hadolint`.
- - A container runs as root and you need least privilege.
- - Standing up local dev with `compose.yaml` + `docker compose watch` (sync / rebuild / sync+restart).
- - Choosing a base image and getting multi-stage + BuildKit cache mounts right.
+ `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 |
- ## The five rules
-
- 1. **Multi-stage, always.** Build deps (compilers, dev packages, the full SDK) must never reach
- the runtime image. Multiple `FROM` stages; copy only the built artifact into a minimal final
- stage. *Why: a Go binary needs ~10 MB, not a 900 MB toolchain — and every extra package is CVE
- surface.*
- 2. **Choose the base deliberately and pin it — never `:latest`.** Pin a tag (ideally a digest)
- so a rebuild is reproducible. *Why: `:latest` silently changes under you, breaking builds and
- busting the scan you just passed.*
- 3. **Install deps before copying source.** Order layers so the lockfile install is cached and use
- BuildKit cache mounts. *Why: copying everything first invalidates the dependency layer on every
- one-character source edit.*
- 4. **Run as a non-root `USER`.** Default PID 1 is root (UID 0); a container escape from root is a
- host compromise. *Why: least privilege is the cheapest blast-radius reduction you can ship.*
- 5. **One concern per container; EXEC-form `CMD`; add a `HEALTHCHECK`.** *Why: shell-form `CMD foo`
- wraps your process in `/bin/sh -c`, which swallows `SIGTERM` so the container ignores graceful
- shutdown; a healthcheck lets Compose/orchestrators gate on readiness.*
-
## 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 live in
+ 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](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.
```dockerfile
# 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.
```dockerfile
# 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.
```dockerfile
# 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 .`.
```gitignore
.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:
```bash
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:
```dockerfile
# 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
```
```bash
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. `develop.watch` is GA since Compose
- 2.22.0 and gives you a tight dev loop.
+ (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.
```yaml
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](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` — the `compose.yaml` parses 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` (pin/digest) | `:latest` drifts; builds + scans become non-reproducible |
- | runs as root (no `USER`) | `USER nonroot` / `USER 10001` | root escape == host compromise |
+ | `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 swallows `SIGTERM`; no graceful shutdown |
-
- ## References & siblings
-
- - [references/base-images-and-stages.md](references/base-images-and-stages.md) — per-language
- multi-stage templates (Rust, JVM jlink, static SPA → nginx), tag maps, multi-arch buildx.
- - [references/compose-recipes.md](references/compose-recipes.md) — fuller Compose patterns.
- - Once the image is green and scanned: `../deployment/SKILL.md`. Image/container hardening pairs
- with `../secure-coding/SKILL.md`. Self-hosted host ops: `../coolify/SKILL.md`.
+ | `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`) |