# Docker

> 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`).

- Skill: `ericrisco/docker` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add ericrisco/docker`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/docker/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/docker

---


# 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.** 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](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. 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`, 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`) |

