# Docker

> Docker: Production Image and Compose Rules

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

---


# Docker: Production Image and Compose Rules

From production, not defaults. Claude's Dockerfiles tend to be wrong in the same few ways. Fix them here.

## Dockerfile

- **Multi-stage builds, always.** Build in a stage that has the compilers and dev dependencies, then `COPY --from=build` only the artifacts into a slim runtime stage. The runtime image carries no build tools, which cuts size hard (a real build went from ~2GB to ~200MB) and removes a pile of CVEs.
```dockerfile
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
ENTRYPOINT ["node", "dist/server.js"]
```

- **Order layers by how often they change: stable first, source last.** Docker caches each layer and rebuilds every layer after the first one whose inputs changed. So copy the dependency manifest and install deps BEFORE copying source: `COPY package*.json ./` then `RUN npm ci` then `COPY . .`. Copy source first and a one-line code change reinstalls every dependency, every build.

- **The runtime command is `ENTRYPOINT`/`CMD` in exec form, never `RUN`.** `RUN` executes at build time; the container's process belongs in `ENTRYPOINT ["node","server.js"]` (a JSON array). Use exec form, not shell form (`ENTRYPOINT node server.js`): shell form runs your app under `/bin/sh -c`, so `sh` is PID 1, it swallows `SIGTERM`, and your app never shuts down gracefully (Docker waits out the grace period then SIGKILLs it) and its exit code is lost. Exec form makes your process PID 1 so signals and exit codes propagate. `ENTRYPOINT` for the executable, `CMD` for default args.

- **Pin base image versions.** `FROM node:22.3.0-slim`, not `node:latest`. `latest` makes builds non-reproducible and shifts under you silently. Pin a tag (or a digest for full reproducibility), and expose it as an `ARG` so it's easy to bump deliberately.

- **Add a `.dockerignore`.** Exclude `node_modules`, `.git`, `.env`, `dist`, and local junk. Without it the whole directory ships as build context (slow), busts the cache on unrelated changes, and can bake a stale `node_modules` or a secret file into the image.

- **Run as non-root.** Containers run as root by default. Add a `USER` (for example `USER node`) before the entrypoint so a container escape isn't root on the host.

- **Add a `HEALTHCHECK`.** Swarm and Compose use it to know a container is actually ready, which is what makes rolling updates and automatic rollback work. Without it, "running" only means the process started, not that it serves traffic.

- **Combine and clean in one `RUN`.** `apt-get update && apt-get install -y ... && rm -rf /var/lib/apt/lists/*` in a single layer. A separate `update` layer goes stale behind the cache, and the cleanup only shrinks the image if it happens in the same layer that added the files.

## Compose and Swarm

- **`init: true` on every service.** The one always missed. It runs a tiny init (tini) as PID 1 that forwards signals to your process and reaps zombie children. Without it, signal handling and zombie reaping become your app's problem and `docker stop` often hangs to the timeout. Pair it with an exec-form ENTRYPOINT.

- **Set resource `limits` AND `reservations`.** Reservations make the scheduler place the container only where the resources exist; limits cap it so a runaway container can't take down the node. Both, not one.

- **Configure rolling updates and rollback.** `update_config` with `parallelism: 1`, a `delay`, `order: stop-first` (or `start-first` for blue-green), and `failure_action: rollback`. Combined with a HEALTHCHECK, a bad deploy rolls itself back instead of taking the service down.

- **Reference services by name, never by IP.** Container IPs change on every restart, scale, and update. Use the service name and let Docker DNS resolve it (`backend-service:8080`). A hardcoded IP is a guaranteed future outage.

- **Substitute env with defaults.** `replicas: ${NGINX_REPLICAS:-2}` and `image: registry/app:${BUILD_VERSION:-latest}` keep one file working across environments through `.env`.

## Secrets and config

- **Never bake secrets into the image.** `ARG` and `ENV` values are stored in image layers and show up in `docker history`, so a secret written into the Dockerfile is a leaked secret. Use BuildKit build secrets (`RUN --mount=type=secret,id=...`) for build time, and Docker secrets (mounted at `/run/secrets/<name>`) or runtime env for run time. Docker secrets are encrypted at rest, never appear in `docker inspect`, and are scoped to the services that mount them.

- **Secrets for sensitive, configs for the rest.** Docker `secret` for certs, keys, and passwords (encrypted); Docker `config` for non-sensitive files like an IP blocklist (not encrypted, but versioned and injected the same way). Neither goes in the image, both are injected at deploy.

- **Log to stdout/stderr, not to files inside the container.** Write to `/dev/stdout` and `/dev/stderr` so Docker's logging driver and your aggregator collect them. Logging to a file inside the container hides the output and fills the writable layer.

---

This skill is built to grow. Add a rule when a real Dockerfile or stack failure has a stable, defensible fix.

