# Writing Dockerfiles

> Dockerfile development best practices. Use when creating, modifying, or reviewing Dockerfiles, .dockerignore files, or container image build configurations.

- Skill: `chogos/writing-dockerfiles` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add chogos/writing-dockerfiles`
- Raw SKILL.md: https://api.skillmd.com/api/skills/chogos/writing-dockerfiles/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Chogos (https://skillmd.com/u/chogos)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/chogos/writing-dockerfiles

---


# Dockerfile Development Best Practices

## Base Images

- **Always use the current stable release** when generating Dockerfiles. Don't copy version numbers from these templates — check the latest: [go.dev/dl](https://go.dev/dl/), [hub.docker.com/_/node](https://hub.docker.com/_/node/tags), [hub.docker.com/_/python](https://hub.docker.com/_/python/tags), [hub.docker.com/_/rust](https://hub.docker.com/_/rust/tags).
- Pin to specific version: `FROM node:24.13-alpine3.21`. Never `latest` or untagged.
- Pin by digest for reproducibility: `FROM node:24.13-alpine3.21@sha256:abc123...`.
- Prefer minimal bases: distroless > alpine/slim > full.
  - `distroless`: no shell, no package manager — smallest attack surface, production only.
  - `alpine`: ~5MB, has shell and apk — good default for Go/Rust static binaries. Can cause issues with Python/Node/Java native modules due to musl libc.
  - `slim`: ~70MB Debian with minimal packages — preferred for Python, Node.js, Java (glibc compatibility).
  - `scratch`: empty image, zero overhead — for fully static Go or Rust binaries with no libc dependency:
    ```dockerfile
    FROM scratch
    COPY --from=build /app/server /server
    EXPOSE 8080
    CMD ["/server"]
    ```
- Cross-architecture: `FROM --platform=linux/amd64 image:tag`.

## Layer Ordering & Caching

Order instructions from least-changed to most-changed:

```dockerfile
# 1. System deps (rarely change)
RUN apt-get update && apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

# 2. App dependency files (change occasionally)
COPY package.json package-lock.json ./
RUN npm ci

# 3. Source code (changes frequently)
COPY src/ src/
```

- Each `RUN`, `COPY`, `ADD` creates a layer. `ENV`, `LABEL`, `EXPOSE`, `WORKDIR` are metadata-only.
- Combine install + cleanup in the same `RUN` — separate layers retain deleted files.
- Use `COPY --link` for independent layers that build in parallel.
- Use `COPY --parents` (BuildKit) to preserve directory structure: `COPY --parents src/app/*.py ./`.

### CI/CD Build Caching

Local layer cache doesn't persist between CI runs. Use external cache backends:

- **GitHub Actions**:
  ```yaml
  - uses: docker/build-push-action@v6
    with:
      context: .
      cache-from: type=gha
      cache-to: type=gha,mode=max
  ```
  `mode=max` caches all layers (including intermediate stages), not just the final image.
- **Registry cache**: push cache layers to a registry, pull on next build.
  ```bash
  docker buildx build \
    --cache-from type=registry,ref=ghcr.io/org/myapp:cache \
    --cache-to type=registry,ref=ghcr.io/org/myapp:cache,mode=max \
    -t ghcr.io/org/myapp:latest .
  ```
- **Local cache** (self-hosted runners):
  ```bash
  docker buildx build \
    --cache-from type=local,src=/tmp/.buildx-cache \
    --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=max .
  ```
  Rotate cache directories to prevent unbounded growth.

## Multi-stage Builds

Always multi-stage. Name every stage — never reference by index.

```dockerfile
# syntax=docker/dockerfile:1
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY tsconfig.json ./
COPY src/ src/
RUN npm run build

FROM node:24-alpine AS runtime
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
```

Always start with `# syntax=docker/dockerfile:1` — enables cache mounts, secret mounts, SSH mounts, heredocs, and `COPY --link`.

- Build stage: compilers, dev deps. Runtime stage: only artifacts.
- `COPY --from=builder /path /path` to copy between stages.
- Separate test target for CI: `docker build --target test .`.
- Full language-specific templates: see [patterns/multi-stage-templates.md](patterns/multi-stage-templates.md).

## Instructions

- **COPY over ADD** always. `ADD` only for local tar auto-extraction. For URLs, use `curl`/`wget` in `RUN`.
- **Exec form** for `CMD` and `ENTRYPOINT`:
  ```dockerfile
  CMD ["node", "server.js"]        # correct: receives signals
  # CMD node server.js             # wrong: wraps in /bin/sh -c, breaks signals
  ```
- **ENTRYPOINT + CMD combo**: ENTRYPOINT for the binary, CMD for default args (overridable at `docker run`):
  ```dockerfile
  ENTRYPOINT ["python", "-m", "myapp"]
  CMD ["--port=8080"]
  ```
- **ARG vs ENV**: ARG is build-time only (and visible in `docker history`). ENV persists in the image. ARG before FROM is scoped to the FROM line only.
- **WORKDIR /app** over `RUN mkdir -p /app && cd /app`. Creates the dir and sets it for all subsequent instructions.
- **EXPOSE** is documentation only — does not publish ports.

## Security

- Run as non-root. Add `USER` after all `RUN` instructions that need root. Platform-specific user creation: see [patterns/security-hardening.md — Non-root users](patterns/security-hardening.md#non-root-users).
- Never bake secrets into images. No `ARG SECRET`, no `COPY .env`, no `ENV API_KEY=...`. Use BuildKit `--mount=type=secret` (see [patterns/security-hardening.md — BuildKit secrets](patterns/security-hardening.md#buildkit-secrets)).
- **Sign images**: `cosign sign --key cosign.key ghcr.io/org/myapp@sha256:abc...`. Verify in CI or Kubernetes admission controller (Kyverno, Connaisseur) before deploy.
- **Verify before pull**: `cosign verify --key cosign.pub ghcr.io/org/myapp@sha256:abc...`. Reject unsigned images in the deploy pipeline.
- **SBOM + provenance attestations**: generate and attach via BuildKit — required for SLSA Level 2+ and supply chain compliance:
  ```bash
  docker buildx build \
    --sbom=true \
    --provenance=mode=max \
    -t ghcr.io/org/myapp:1.0.0 \
    --push .
  ```
  Attestations are stored as OCI artifacts alongside the image. Verify with `cosign verify-attestation`.
- `COPY --chown=appuser:appgroup` to set ownership without extra layers.
- Clean package caches in the same `RUN` (see Layer Ordering example above). Or use cache mounts: see [patterns/optimization-patterns.md — Cache mounts](patterns/optimization-patterns.md#cache-mounts).

## .dockerignore

Always create one. Without it, the entire directory (`.git`, `node_modules`, local env) goes into the build context — on large repos this can send GBs to the daemon and dramatically slow builds.

Universal + language-specific templates: see [patterns/optimization-patterns.md — .dockerignore patterns](patterns/optimization-patterns.md#dockerignore-patterns).

## Labels & Metadata

Use [OCI image-spec annotation keys](https://github.com/opencontainers/image-spec/blob/main/annotations.md) (`org.opencontainers.image.*`). All values must be strings. Custom keys use reverse-domain notation (`com.example.mykey`).

At minimum, include `title`, `version`, `revision`, `created`, `source`, and `licenses`. Full label template and formatting rules: see [instruction-reference.md — LABEL](instruction-reference.md#label).

## Health Checks

```dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD ["curl", "-f", "http://localhost:8080/healthz"]
```

For distroless (no curl): compile a static healthcheck binary or use the runtime's built-in health endpoint.

## Signal Handling

- Exec form `CMD ["binary"]` runs as PID 1 and receives signals directly.
- Shell form `CMD binary` runs under `/bin/sh -c` — SIGTERM goes to shell, not the app.
- For scripts as entrypoints: `exec` to replace the shell, or use `tini`:
  ```dockerfile
  RUN apk add --no-cache tini
  ENTRYPOINT ["tini", "--"]
  CMD ["node", "server.js"]
  ```

## Common mistakes

- `FROM node:latest` or untagged — non-reproducible builds. Pin version + digest.
- `ADD` for copying files — use `COPY`. `ADD` only for local tar extraction. ([instruction-reference.md — COPY vs ADD](instruction-reference.md#copy-vs-add))
- Shell form `CMD node server.js` — wraps in `/bin/sh -c`, breaks signal handling. Use exec form `CMD ["node", "server.js"]`.
- `ARG SECRET=...` or `COPY .env` — secrets baked into image layers, recoverable with `docker history`. Use `--mount=type=secret`. ([patterns/security-hardening.md — BuildKit secrets](patterns/security-hardening.md#buildkit-secrets))
- Separate `RUN apt-get update` and `RUN apt-get install` — cache cleaned in a later layer is still stored in the earlier layer. Combine in one `RUN`.
- Missing `USER` — container runs as root. Always add a non-root user before `CMD`.
- `VOLUME /data` then `RUN` modifying `/data` — changes silently discarded. ([instruction-reference.md — VOLUME](instruction-reference.md#volume))
- Missing `.dockerignore` — sends `.git`, `node_modules`, `.env` into build context.

## New Dockerfile workflow

```
- [ ] Choose minimal base image with pinned version
- [ ] Create .dockerignore
- [ ] Design multi-stage build (deps -> build -> runtime)
- [ ] Order layers for cache efficiency
- [ ] Add non-root USER
- [ ] Add HEALTHCHECK
- [ ] Add OCI labels
- [ ] Run validation loop (below)
```

## Validation loop

1. `hadolint Dockerfile` — fix all warnings (DL=Dockerfile rules, SC=ShellCheck rules)
2. `docker build --no-cache -t test .` — fix build errors
3. `trivy image test` or `grype test` — fix CVEs in base image or deps
4. `docker run --rm test` — verify the container starts and works
5. Repeat until hadolint is clean, scan is acceptable, container runs correctly

## Deep-dive references

**Multi-stage templates**: See [patterns/multi-stage-templates.md](patterns/multi-stage-templates.md) for Go, Node.js, Python, Java, Rust
**Security hardening**: See [patterns/security-hardening.md](patterns/security-hardening.md) for distroless, secrets, scanning
**Optimization**: See [patterns/optimization-patterns.md](patterns/optimization-patterns.md) for cache mounts, BuildKit, image size
**Instruction gotchas**: See [instruction-reference.md](instruction-reference.md) for per-instruction cheatsheet

## Official references

- [Dockerfile reference](https://docs.docker.com/reference/dockerfile/) — all instructions, syntax, escape directives
- [Docker Build best practices](https://docs.docker.com/build/building/best-practices/) — official guidance on layers, caching, multi-stage
- [OCI image spec: annotations](https://github.com/opencontainers/image-spec/blob/main/annotations.md) — full list of `org.opencontainers.image.*` keys and formatting rules
- [hadolint rules](https://github.com/hadolint/hadolint#rules) — DL/SC rule reference
