---
name: docker-expert
description: "Dockerfiles and images: multi-stage builds, BuildKit cache and secret mounts, slim or distroless bases, non-root, healthchecks, scanning, multi-arch, Compose for dev. Use when writing, shrinking or hardening a container build."
metadata:
  risk: safe
  source: multi-agent-pipeline
  date_added: "2026-09-25"
---

# Docker Images and Compose

An image is a deployable artifact. It should be small enough to pull quickly,
reproducible enough that the same commit gives the same result, and boring
enough at runtime that nothing in it surprises an operator. This skill is the
checklist for getting there, ordered by how much each item usually buys.

Pipeline wiring (where the image is built, pushed and promoted) belongs to
`ci-cd-pipelines`; workflow YAML belongs to `github-actions-templates`.

## Review order

1. Does the build context contain only what the build needs?
2. Is the layer order stable-first, volatile-last, so the cache survives edits?
3. Does the final stage carry only runtime files?
4. Does the process run as a non-root user with a read-only-friendly layout?
5. Are secrets kept out of every layer and of the build history?
6. Can an orchestrator tell whether the container is healthy and stop it cleanly?
7. Are base images pinned and scanned?

## 1. Build context

Everything in the context is sent to the builder and can invalidate `COPY . .`.
Start `.dockerignore` from a deny list and widen only when a build fails:

```
.git
**/node_modules
**/__pycache__
**/.venv
dist
coverage
*.log
.env*
Dockerfile*
compose*.yaml
```

A `.env` file that reaches the context ends up in a layer the moment someone
writes `COPY . .`. Ignoring it is cheaper than remembering.

## 2. Layer order and caching

Copy dependency manifests, install, then copy source. Source changes daily;
the lockfile changes weekly.

```dockerfile
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
```

BuildKit cache mounts keep the package manager cache between builds without
putting it in the image. Equivalents:

| Tool | Mount target |
|------|--------------|
| npm | `/root/.npm` |
| pnpm | `/root/.local/share/pnpm/store` |
| pip / uv | `/root/.cache/pip`, `/root/.cache/uv` |
| Go | `/go/pkg/mod` and `/root/.cache/go-build` |
| apt | `/var/cache/apt` and `/var/lib/apt` with `sharing=locked` |

For apt, remove the image's `docker-clean` hook or the cache mount stays empty,
and always pair `update` and `install` in one `RUN`.

## 4. Runtime user and filesystem

```dockerfile
FROM python:3.13-slim
RUN groupadd --system app && useradd --system --gid app --home /app app
WORKDIR /app
COPY --from=build --chown=app:app /app/.venv /app/.venv
COPY --chown=app:app src/ ./src/
USER app
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
CMD ["python", "-m", "src.products_api"]
```

- Numeric `USER 10001` works with Kubernetes `runAsNonRoot` checks, which cannot
  resolve names.
- Write only to declared volumes or `/tmp`, so the container can run with
  `read_only: true`.
- Listen on a port above 1024; a non-root process cannot bind lower ones
  without extra capabilities.

## 5. Secrets

Never `ARG` or `ENV` a token: both are visible in `docker history` and in the
image config. Use a secret mount, which exists only for that `RUN`:

```dockerfile
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN \
    npm ci
```

```bash
docker buildx build --secret id=npm_token,env=NPM_TOKEN .
```

For private Git dependencies use `--mount=type=ssh` with `--ssh default`.
Runtime secrets come from the orchestrator (mounted files or env injected at
start), never from the image.

## 6. Process behaviour

- Exec form (`CMD ["bin", "arg"]`) so the app is PID 1 and receives `SIGTERM`.
  Shell form wraps it in `/bin/sh -c`, which does not forward signals.
- Handle `SIGTERM`: stop accepting work, finish requests already running, exit within
  the orchestrator's grace period. Add `--init` or `tini` when the app spawns
  children it does not reap.
- Log to stdout and stderr; let the platform collect them.
- `HEALTHCHECK` is honoured by Docker and Compose but ignored by Kubernetes,
  which uses its own probes. Keep the health endpoint cheap and dependency-free
  for liveness; check dependencies only in readiness.

```dockerfile
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
  CMD ["/app/.venv/bin/python", "-c", "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2)"]
```

Distroless images have no `curl`; ship a tiny health binary or rely on the
orchestrator's probe.

## 7. Pinning, scanning, provenance

- Pin bases by digest in release images (`FROM node:22-bookworm-slim@sha256:...`)
  and let Renovate or Dependabot bump the digest.
- Scan in CI and fail on fixable critical findings: `docker scout cves`,
  `trivy image`, or `grype`.
- Emit an SBOM and provenance attestation with
  `docker buildx build --sbom=true --provenance=mode=max`.
- Add OCI labels (`org.opencontainers.image.source`, `.revision`, `.version`)
  so a running image leads back to its commit.

## Diagnosing a build

| Symptom | First check |
|---------|-------------|
| Every build reinstalls dependencies | a `COPY . .` before the install step, or a missing lockfile copy |
| Context upload is large | `.dockerignore`; run `docker buildx build --progress=plain` and read the context size |
| Image is hundreds of MB | `docker history --no-trunc` or `dive` to find the heavy layer |
| Works locally, fails on arm64 | a prebuilt native binary for one architecture only |
| Container ignores stop and is killed after 10s | shell-form `CMD`, or no `SIGTERM` handler |
| `permission denied` after adding `USER` | files copied without `--chown`, or writes outside a writable path |

## Done means

- `docker buildx build` succeeds from a clean cache and from a warm one.
- Final image runs as non-root, has no compilers or package manager caches.
- No secret appears in `docker history` or `docker inspect`.
- Scanner reports no fixable critical vulnerability.
- The container stops within its grace period on `SIGTERM`.

## References

- `references/patterns.md`: multi-stage Dockerfiles and choosing a runtime base, buildx multi-arch builds, and Compose v2 for local development. Load it when the task reaches those topics.
