docker — container-craft

SkillCloud & infra

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

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the docker — container-craft skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/docker/SKILL.md and read by ahel’s review.

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…OwnerWhy it's not you
CI build, registry push, deploy to host, rollback../deployment/SKILL.mdYou build + harden the image; it moves the image through CI to prod
k8s manifests, HPA autoscaling, ingressscalingOrchestration above a single image
App SQL injection, secrets in source, dep CVE triage../secure-coding/SKILL.mdYou cover only image/container hardening
Install/operate a self-hosted PaaS../coolify/SKILL.mdHost operation, not the image
Containerless PaaS deploy mechanics../railway/SKILL.md, ../fly-io/SKILL.mdPlatform deploy, not the Dockerfile

Pick a base image (2026 reality)

BaseSizeCVE / patch velocityDebuggable?Wins when
*-slim (e.g. bookworm-slim)~30–80 MBmoderate, glibcyes (shell, apt)pragmatic default, native deps / Python wheels
distroless (gcr.io/distroless/*)~2–25 MBconvenient but patches slowerno shellstatic/compiled runtimes, want minimal surface
Chainguard / Wolfitiny, glibclowest live CVE count, SLSA L3 attestationsminimalsecurity-first; real scans found high-sev CVEs where the Chainguard equivalent had zero
alpine~5 MB, muslsmall surfaceyes (apk)tiny static services — but musl breaks many Python wheels / native deps
scratch0nothing to patchnoa 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 2422 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 — 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

BadGoodWhy
FROM node:latestFROM 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 10001PID 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 cicopy lockfile → install → copy sourcesource edits bust the dep cache layer every build
single FROM, ship the SDKmulti-stage, copy only the artifactruntime carries compilers & dev CVE surface
apt-get install xapt-get install --no-install-recommends x && rm -rf /var/lib/apt/lists/*recommends + apt lists bloat the layer
no .dockerignoremirror .gitignore + build outputswhole context ships; .env/.git can leak
version: "3.8" / docker-compose v1omit version:; docker compose v2the 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 HEALTHCHECKHEALTHCHECK CMD [...]nothing for Compose/orchestrators to gate readiness on (condition: service_healthy)

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026

ahel review

  • S4info
    community integration — published by ericrisco, not docker

Automated review, not a security audit. Ruleset v1.

Advanced
Catalog kind
skill
Gateway key
docker-ericrisco
Source
github.com/ericrisco/rsc-harness