# Recipe — Dockerfile & compose construction (+ the audit)

> The **image-building runbook**: how to construct Dockerfiles and `compose.yaml` files that are
> cache-fast, small, non-root, secret-free, and provenance-labeled — ending in a **runnable audit** an
> agent can execute against any repo ("point me at a Dockerfile, I grade it"). This is the earned
> judgment the fleet's image consumers assume: [`ecs-service.aws.md`](../../ecs-service.aws.md) pulls
> what you build here, [`ci-runner.aws.md`](../../ci-runner.aws.md) builds it in CI (DinD), and a future
> `k8s-service.aws.md` will deploy it.
>
> **Candor:** authored 2026-07-16; **audit DOGFOODED 2026-07-17** against a real legacy repo (2016-era
> single-stage Dockerfile + compose) — all 12 checks ran end-to-end on docker 29 and graded correctly
> after 2 check fixes the dogfood forced (normalized-`depends_on` form; wider secret-name pattern —
> both folded into §5). hadolint absent on the run machine proved the "optional" annotations honest.
> Patterns here are deliberately boring — the value is that every agent applies the *same* boring
> patterns.

```
Legend  ✔ check · ⚠ gotcha — this recipe creates nothing in any cloud (no 🔴/💥; the registry push
        rides the consuming plan's gates)
```

## What you end up with

A repo whose images build in seconds on a warm cache, run as non-root from a pinned slim base, carry
provenance labels, contain zero secrets in any layer, and pass a graded audit — plus a `compose.yaml`
that gives every developer (and every agent) the same one-command local stack.

```
Dockerfile (§1) ──build──► image ──§3 tag+label+push──► registry ──► ecs-service / ci-runner / k8s-*
compose.yaml (§2) ──────► the LOCAL parity stack (dev/test)          (their plans own the deploy gates)
hygiene loop (§4) ──lint/scan── every build            audit (§5) ──grades any repo against this doc
```

## §1 Dockerfile construction

### Base image — pin it, and know the musl trap

- **Pin by digest**, not just tag: `FROM node:22-slim@sha256:<digest>` — a bare `:22-slim` is a moving
  target; the digest makes builds reproducible and the audit checkable. Renovate-style bumps become
  explicit diffs instead of silent drift.
- **Default ladder:** `*-slim` (Debian, glibc, small) → `distroless` (runtime stage only — no shell,
  no package manager, tiny attack surface) → full image (only when you truly need build tooling at
  runtime, which you almost never do behind multi-stage).
- ⚠ **alpine is not the safe default** for python/node-with-native-deps: it's **musl**, not glibc —
  prebuilt wheels/binaries (`manylinux` wheels, some npm native modules) don't exist or recompile from
  source. The fleet hit the same wheel-platform wall on Lambda (`lambda-layer.aws.md`'s
  `--platform manylinux2014_x86_64` pin); alpine reproduces it in a container. Use `-slim` unless you
  measure a reason.

### Package managers — the mixed-image reality

Choosing a base picks a **distro family**, and the family picks the package manager + its hygiene
flags. Mixed fleets mean every "just install curl" line is family-specific — know the map instead of
relearning it per image:

| family (typical bases) | manager | the correct install line | why those flags |
|---|---|---|---|
| Debian/Ubuntu (`*-slim`, `ubuntu:*`) | `apt-get` | `RUN apt-get update && apt-get install -y --no-install-recommends <pkg> && rm -rf /var/lib/apt/lists/*` | update+install+clean in **one RUN** (a separate `update` layer goes stale and cache-poisons installs); `--no-install-recommends` halves the payload; the lists are pure layer bloat |
| Alpine (`*-alpine`) | `apk` | `RUN apk add --no-cache <pkg>` | `--no-cache` skips the index files entirely — no clean step needed |
| RHEL-family (UBI, Amazon Linux) | `microdnf`/`dnf`/`yum` | `RUN microdnf install -y <pkg> && microdnf clean all` | `microdnf` on minimal/UBI-micro images; `clean all` drops the metadata |
| distroless / `scratch` | **none — a feature** | install in the **builder** stage, `COPY --from=build` the artifacts | no manager = nothing to exploit at runtime; if you're tempted to install into it, you want `-slim` |

For **cross-image tooling** — scripts that must run against whatever base a project chose (CI helpers,
the §5 audit, an onboarding script) — detect, don't assume:

```bash
pkg_install() {   # usage: pkg_install curl git   — works across the families above
  if   command -v apt-get  >/dev/null; then apt-get update && apt-get install -y --no-install-recommends "$@" && rm -rf /var/lib/apt/lists/*
  elif command -v apk      >/dev/null; then apk add --no-cache "$@"
  elif command -v microdnf >/dev/null; then microdnf install -y "$@" && microdnf clean all
  elif command -v dnf      >/dev/null; then dnf install -y "$@" && dnf clean all
  elif command -v yum      >/dev/null; then yum install -y "$@" && yum clean all
  else echo "no known package manager (distroless-class image?)" >&2; return 1; fi
}
```

⚠ In a **Dockerfile** you know your base — write the family's line directly (the table), don't ship the
detector. The detector is for scripts that outlive any one base choice.

### Interrogating a candidate base ✔

Before adopting a base image, ask it what it is — three commands answer the questions that matter
(family, manager, libc, size):

```bash
IMG="candidate@sha256:<digest>"
docker run --rm --entrypoint sh "$IMG" -c \
  '. /etc/os-release 2>/dev/null && echo "distro: $ID $VERSION_ID"; \
   for pm in apt-get apk microdnf dnf yum; do command -v "$pm" && break; done; \
   (ldd --version 2>&1 || true) | head -1'          # glibc vs musl — the wheel-compat question
docker image inspect --format 'size: {{.Size}} bytes' "$IMG"
```

Read the failure too: if `--entrypoint sh` itself errors, the image has **no shell** — that's
distroless-class (a runtime-stage-only candidate, and a good one). Decision rule of thumb from the
answers: need prebuilt wheels/native modules → glibc family; runtime stage of a multi-stage build →
distroless; need to install OS packages at runtime → `-slim`; none of the above pulling you elsewhere →
`-slim` stays the default.

### The kernel is shared — what `uname` actually tells you

- ⚠ **`uname -a` in a container reports the HOST kernel** — there is no container kernel; containers
  share the host's. It answers nothing about your image (use `/etc/os-release` for that, above). What
  `uname -m` *does* answer is **architecture** — and that's the modern trap:
- **Apple-Silicon builds are arm64 by default.** Build on an M-series Mac, push, deploy to x86 ECS →
  `exec format error` at task start (the container equivalent of the fleet's darwin-wheels-on-Lambda
  scar). When your build machine and your runtime disagree, pin the platform *everywhere it appears*:
  `docker build --platform linux/amd64`, and in cross-compiling multi-stage files
  `FROM --platform=$BUILDPLATFORM` for the builder stage. Verify the artifact, not the intent:
  `docker image inspect --format '{{.Os}}/{{.Architecture}}' "$IMG"` must equal the runtime's platform.
- Running a foreign-arch image locally works via qemu emulation — **slowly, and with native-module
  breakage** — which is how an arch mismatch hides until deploy. The inspect check above is the
  10-second way to not find out in production.

### Layer order = cache order

Copy the **dependency manifest first**, install, *then* copy source — a source edit must never bust the
dependency layer:

```dockerfile
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .            # only THIS layer rebuilds on a code change
```

### Multi-stage: build tooling never ships

```dockerfile
# syntax=docker/dockerfile:1
FROM node:22-slim@sha256:<digest> AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm ci --omit=dev

FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
LABEL org.opencontainers.image.source="<repo-url>" \
      org.opencontainers.image.title="example-api" \
      managed-by="ephemera" plan-version="2026-07-16"
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]
```

The python shape differs only in the middles — venv in the builder, copy the venv:

```dockerfile
FROM python:3.12-slim@sha256:<digest> AS build
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir -r requirements.txt
COPY . .

FROM python:3.12-slim@sha256:<digest> AS runtime
WORKDIR /app
COPY --from=build /venv /venv
COPY --from=build /app/src ./src
RUN useradd --system --uid 10001 app
USER app
HEALTHCHECK --interval=30s --timeout=3s CMD ["/venv/bin/python","-c","import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/healthz')"]
CMD ["/venv/bin/python","-m","src.server"]
```

### The non-negotiables

- **`USER` non-root** in the runtime stage — distroless `:nonroot` variants, or `useradd --system`.
  Root-in-container is the single most common audit failure and the cheapest to fix.
  ⚠ Its twin trap: **`COPY` writes files as root** regardless of `USER` — a non-root app that must
  write what you copied needs `COPY --chown=app:app`, or it fails with `EACCES` only at runtime.
- **`.dockerignore` exists** and covers at least: `.git`, `.env*`, `node_modules`/`.venv`,
  `**/secrets*`, build output you re-create. ⚠ Without it, `COPY . .` ships your `.git` history —
  including anything ever committed — into a layer.
- **`HEALTHCHECK`** on anything long-running: compose (§2) and ECS both key off it; an image without
  one turns `depends_on: condition: service_healthy` into a lie. ⚠ On **distroless** there's no shell
  and no curl/wget — the probe must be an **in-runtime** exec-form command (the python exemplar's
  `urllib` probe; node: `CMD ["node","-e","fetch(...)..."]`). A shell-form healthcheck on distroless
  fails forever and marks a healthy service unhealthy.
- **`EXPOSE` documents; it does not publish.** Ports open via `ports:`/`-p` at run time — `EXPOSE`
  alone reachable-from-the-host is a myth that survives every generation of newcomers.
- **OCI provenance labels** — the TAGS movement applied to images (`ManagedBy`/`Source` →
  `org.opencontainers.image.*` + custom keys). Same doctrine as the plans: label what you create,
  and "who manages this" travels with the artifact.

### ⚠ Secrets never enter a layer — not even a deleted one

- **Never `ARG SECRET` / `ENV SECRET`** — ARG values persist in `docker history`; ENV values sit in
  `docker inspect` of every derived container, forever, for anyone who can pull the image. This is the
  fleet's pipe-not-argv doctrine (EPHEMERA.md, Credentials & secrets) applied to layers.
- Need a secret at **build** time (private registry, license)? BuildKit secret mounts — exists only
  during that RUN, never in a layer:
  ```dockerfile
  RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
  ```
  ```bash
  docker build --secret id=npmrc,src=$HOME/.npmrc .
  ```
- Need a secret at **run** time? It comes from the platform (ECS task secrets from SSM, compose
  `secrets:`, Worker secrets) — never baked in. The audit's history/inspect checks (§5) assert the
  negative.

## §2 compose.yaml construction

**Stance: compose is local-dev parity — prod is the cloud plans.** A `compose.yaml` gives every
developer and agent the same one-command stack (`docker compose up`); it is not a deployment target.
The moment "compose on a VM" tempts you, that's `ecs-service.aws.md` / `k8s-service` territory.

```yaml
services:
  api:
    build: .                       # dev builds locally; CI/prod consume the pushed image (§3)
    ports: ["3000:3000"]
    env_file: .env.local           # non-secret config; the file is .dockerignore'd + .gitignore'd
    init: true                     # PID-1 reaper — see "Process capping" below
    pids_limit: 256                # fork-bomb ceiling; generous for an app, fatal for a bomb
    mem_limit: 512m                # caps are OPT-IN — unset means "the whole host"
    cpus: 2
    security_opt: ["no-new-privileges:true"]
    depends_on:
      db:        { condition: service_healthy }   # ⚠ bare depends_on = "container started",
      migrate:   { condition: service_completed_successfully }  # NOT "ready" — gate on health
    develop:
      watch: [{ action: sync, path: ./src, target: /app/src }]

  db:
    image: postgres:16@sha256:<digest>
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password   # _FILE convention, not a literal env value
    secrets: [db_password]
    volumes: [dbdata:/var/lib/postgresql/data]           # named volume — survives `down`, owned by docker
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10

  migrate:                          # one-shot: runs, exits 0, gates the api via completed_successfully
    build: .
    command: ["npm","run","migrate"]
    depends_on:
      db: { condition: service_healthy }
    profiles: [dev]                 # profiles keep optional services out of the default `up`

secrets:
  db_password:
    file: ./.secrets/db_password    # .gitignore'd; NEVER environment: with a literal

volumes:
  dbdata:
```

The load-bearing choices:

- **`depends_on` with conditions** — `service_healthy` / `service_completed_successfully`. Bare
  `depends_on` orders *starts*, not *readiness*; it's the #1 source of flaky local stacks.
- **`secrets:` over environment literals** — compose file stays committable; `docker compose config`
  output stays paste-safe. The `_FILE` env convention is what official images (postgres, mysql) already
  speak.
- **Named volumes over bind mounts for data**; bind mounts (or `develop.watch`) for source you're
  editing. Two bind-mount landmines: ⚠ **shadowing** — mounting `./src` over a path the image built
  into *hides the image's copy* (the classic: `.:/app` makes the container's `node_modules` vanish;
  that's what the anonymous-volume `- /app/node_modules` hack works around — prefer `develop.watch`
  or mount narrower paths); ⚠ **uid mismatch** — on a Linux host, files the container writes into a
  bind mount land owned by the container's uid (root-owned droppings in your repo). macOS's VM
  translation hides this, so it surfaces the first time CI or a Linux box runs the same compose.
- **Variable substitution is a silent-failure machine.** Compose **auto-loads `.env`** from the
  project dir — values flow into `${VAR}` references with nothing in the compose file admitting it
  (dogfooded 2026-07-17: a forgotten 2023-era `.env` was quietly supplying a build arg; `docker
  compose config` was the only honest witness). And an **unset** `${VAR}` substitutes to **empty
  string** with a warning most CI logs swallow. Guard both: reference required vars as
  `${VAR:?set VAR in .env or the environment}` (hard-fails the config parse), and treat
  `docker compose config` — the normalized output — as the thing you review, not the yml.
- **Logs go to stdout/stderr, and the default log driver is unbounded.** An app logging to files
  inside the container fills the writable layer invisibly; an app logging to stdout under the default
  `json-file` driver fills the **host** — set `logging: { driver: json-file, options: { max-size:
  "10m", max-file: "3" } }` (or the daemon-wide default) before a chatty service teaches you why.
- **`profiles:`** for anything optional (one-shot migrations, test harnesses, an admin UI) so plain
  `docker compose up` stays minimal.
- **Networks:** the default network compose creates is right for almost everything — services already
  resolve each other by name. Define networks only to *isolate* (e.g. db reachable from api but not
  from a tools container).

### Process capping & runtime hardening — caps are opt-in; the defaults are "the whole host"

The lessons people usually pay for one incident at a time:

- **PID 1 is your app, and your app is a bad init.** The container's first process inherits init's
  duties — reap zombies, forward signals — and almost no app does either. Symptoms: zombie processes
  accumulating under anything that forks, and `docker stop` hanging 10 s then SIGKILLing (SIGTERM went
  to a process that never handled it). Fix is one line: `init: true` (compose) / `docker run --init` —
  a tiny real init (tini) becomes PID 1. ⚠ Related: **exec-form CMD** (`CMD ["app","arg"]`), never
  shell form — `CMD app arg` makes `sh` PID 1 and it eats your SIGTERM.
- **`pids_limit` is the fork-bomb wall.** Unset, one runaway `while true; fork` takes the host's PID
  space down with it. 256 is generous for a typical service and fatal for a bomb.
- **Memory/CPU caps — and the cgroup-blind runtime trap.** `mem_limit`/`cpus` bound the blast radius,
  but the *second half* nobody plays to level 10: many runtimes size themselves by asking the OS, and
  the OS answer is the **host's** resources, not the cgroup's. A threadpool sized to 16 host cores
  inside a 2-cpu cap thrashes; a heap sized to host RAM inside `mem_limit: 512m` gets OOM-killed.
  Modern JVMs are container-aware; Node (`os.cpus()`), Python (`multiprocessing.cpu_count()`), and
  older everything are not — size pools/heaps **explicitly from the cap**, not from discovery.
  Diagnostic tell: **exit code 137** = SIGKILL, and in a memory-capped container that's the OOM killer
  — check `docker inspect --format '{{.State.OOMKilled}}'` before blaming the app.
- **Privilege hardening, the cheap tier:** `security_opt: ["no-new-privileges:true"]` (no setuid
  escalation) costs nothing and breaks almost nothing; `read_only: true` + a `tmpfs:` for scratch, and
  `cap_drop: [ALL]` + adding back only what's measured, are the next rungs — adopt them per-service as
  compatibility allows, cheapest first.

## §3 The registry seam — where the fleet picks the image up

The image reference is the **hand-off between this recipe and the plans** — the same
Provides/Requires shape the plans use with the cloud:

- **Tag immutably**: `<registry>/<repo>:<git-sha>` (plus a moving `:dev`/`:latest` convenience tag if
  you must — consumers pin the sha). ECR: turn **tag immutability ON**; a re-pushed `:latest` that
  silently changes what ECS pulls is drift by another name.
- **ECR login dance** (region + account come from the consuming plan's §0):
  ```bash
  aws ecr get-login-password --region "$AWS_REGION" \
    | docker login --username AWS --password-stdin "$ECR_REGISTRY"   # piped — never on argv
  docker build -t "$ECR_REGISTRY/$REPO:$GIT_SHA" .
  docker push "$ECR_REGISTRY/$REPO:$GIT_SHA"
  ```
- **Who consumes what:** `ecs-service.aws.md` takes the image ref as its image knob;
  `ci-runner.aws.md` runs this whole recipe *inside* CodeBuild (DinD — its `privileged_mode` exists for
  exactly this); a future `k8s-service.aws.md` consumes the same ref in a deployment manifest. The
  labels you set in §1 are how any of them answers "who built this and from where."

## §4 Hygiene & scanning — the pre-push loop

Run these **before push**, not after deploy (a finding post-deploy is an incident; pre-push it's a
diff):

| tool | catches | invocation |
|------|---------|------------|
| `docker build --check` | Dockerfile lint (built into BuildKit — zero install) | `docker build --check .` |
| `hadolint` | deeper Dockerfile lint (pin, cache, root, apt hygiene) | `hadolint Dockerfile` |
| `trivy` (or `docker scout`) | known CVEs in the built image's packages | `trivy image --severity HIGH,CRITICAL <image>` |
| SBOM | what's actually inside (feeds any later sweep) | `docker buildx build --sbom=true .` or `syft <image>` |

⚠ **Scan the built image, not just the Dockerfile** — the base image contributes most CVEs, and a
digest bump is usually the fix. This section is the seed of the fleet's backlogged security-sweep: the
same battery, swept across every repo.

## §5 The audit ✔ — point an agent here with a repo

Run from the repo root; `IMG` = a locally built tag. Each failure names its remedy section.

```bash
IMG="${IMG:-audit-target:local}"; docker build -t "$IMG" . >/dev/null
```

| ✔ check | command | PASS is | remedy |
|---------|---------|---------|--------|
| base pinned by digest | `grep -E '^FROM .+@sha256:[0-9a-f]{64}' Dockerfile` | every FROM matched | §1 base |
| multi-stage | `grep -c '^FROM' Dockerfile` | ≥ 2 (or a measured reason) | §1 multi-stage |
| non-root runtime | `docker inspect --format '{{.Config.User}}' "$IMG"` | non-empty, not `root`/`0` | §1 non-negotiables |
| .dockerignore guards | `grep -cE '^\.git$|^\.env' .dockerignore` | ≥ 2 (file exists, covers both) | §1 non-negotiables |
| no ENV/ARG secrets | *compound — block below* | **no output** (heuristic — eyeball any hit) | §1 secrets |
| HEALTHCHECK present | `docker inspect --format '{{.Config.Healthcheck}}' "$IMG"` | not `<nil>` (long-running images) | §1 non-negotiables |
| provenance labels | `docker inspect --format '{{json .Config.Labels}}' "$IMG"` | `org.opencontainers.image.*` present | §1 non-negotiables |
| Dockerfile lint | `docker build --check . && hadolint Dockerfile` | clean (hadolint optional) | §1 / §4 |
| image CVEs | `trivy image --exit-code 1 --severity HIGH,CRITICAL "$IMG"` | exit 0 (trivy optional) | §4 |
| compose validates | `docker compose config -q` | exit 0 | §2 |
| readiness-gated deps | *compound — block below* | **no output** (every dep carries a condition) | §2 |
| no compose secret literals | *compound — block below* | **no output** (no literal values) | §2 |
| pkg-install hygiene | *compound — block below* | **no output** (apt has `--no-install-recommends`, apk has `--no-cache`) | §1 package managers |
| arch matches runtime | `docker image inspect --format '{{.Os}}/{{.Architecture}}' "$IMG"` | equals the deploy target's platform (e.g. `linux/amd64`) | §1 kernel/arch |
| process caps present | *compound — block below* | **no output** (long-running services carry `init` + `pids_limit` + a memory cap) | §2 process capping |
| no empty-var substitution | *compound — block below* | **no output** (no `${VAR}` silently becoming empty) | §2 variable substitution |

The compound checks (multi-pipe commands don't survive markdown table cells — run these as written):

```bash
# no ENV/ARG secrets — suspicious names in env or build history; PASS = silence
docker inspect --format '{{json .Config.Env}}' "$IMG" | grep -iE 'key|token|secret|pass|cookie|credential'
docker history --no-trunc "$IMG" | grep -iE 'key=|token=|secret=|pass|cookie|credential'

# readiness-gated deps — grade the NORMALIZED config, not the source file. ⚠ Dogfooded 2026-07-17:
# `docker compose config` canonicalizes bare `depends_on: [svc]` lists — AND legacy `links:` — into
# map form with `condition: service_started`, so grep the normalized marker (started = ungated);
# PASS = silence. (Never grep the raw yml: you'd miss both forms.)
docker compose config | grep -B2 'condition: service_started'

# no compose secret literals — suspicious keys whose value isn't a _FILE / secrets-mount reference;
# PASS = silence. ⚠ Dogfooded: 'password' alone missed RABBITMQ_DEFAULT_PASS and an ERLANG_COOKIE —
# real-world secret env names say PASS/COOKIE/KEY at least as often as PASSWORD.
docker compose config | grep -iE 'pass(word)?[_a-z]*:|secret|token|cookie|credential|api_?key' | grep -vE '_FILE|/run/secrets|secrets:'

# package-manager hygiene (heuristic — hadolint DL3008/DL3009/DL3015/DL3019 grade this properly);
# PASS = silence: every apt-get install carries --no-install-recommends, every apk add carries --no-cache
grep -nE 'apt-get install' Dockerfile | grep -v -- --no-install-recommends
grep -nE 'apk add' Dockerfile | grep -v -- --no-cache

# process caps — every long-running service (has ports/restart) should carry init + pids_limit + a
# memory cap; graded judgment: PASS = silence, or a named reason per hit. Reads the NORMALIZED config.
docker compose config --format json | python3 -c '
import sys,json
for name,svc in json.load(sys.stdin).get("services",{}).items():
    if "ports" in svc or svc.get("restart") in ("always","unless-stopped"):
        missing=[k for k in ("init","pids_limit") if k not in svc]
        if "mem_limit" not in svc and not svc.get("deploy",{}).get("resources",{}).get("limits"): missing.append("memory-cap")
        if missing: print(f"UNCAPPED {name}: missing {missing}")'

# unset-variable substitution — compose warns on STDERR then silently substitutes empty; PASS = silence.
# (Also surfaces which values an auto-loaded .env is supplying: diff config output with the .env moved aside.)
docker compose config 2>&1 >/dev/null | grep -i "is not set"
```

Report like a plan's verify table: check · expected · observed · PASS/FAIL. **The two instant-fail
classes are root-runtime and a secret in a layer** — everything else is graded judgment.

⚠ **Audit-runner discipline (dogfooded 2026-07-17):**
- **Never pipe a graded command** (`trivy … --exit-code 1 | tail` etc.) — the pipe **masks the exit
  code** you're grading (`$?` becomes the pager's/`tail`'s). Run it bare, or capture to a file and read
  that. This footgun bit three separate times in one fleet session before earning this line.
- **Known-benign hits** for the env-secret heuristic: official language base images ship
  `GPG_KEY=<fingerprint>` (a *public* signing-key fingerprint, not a secret). Eyeball, note, move on —
  don't "fix" it, and don't let it train you to ignore the check.

## Deliberately not included

- **Kubernetes manifests / helm** — the future `k8s-service.aws.md`'s job; this recipe stops at a
  pushed, labeled, audited image.
- **compose as a production deploy target** (incl. swarm) — deliberate stance (§2): prod is the
  cloud plans, where gates, ledgers, and teardown live.
- **Advanced buildx** (bake files, multi-arch matrices, remote cache backends) — add when a consumer
  plan actually needs arm64; the fleet's arm64 branch is still unrun even on Lambda.
- **Registry choice beyond ECR** — GHCR/Docker Hub push is the same dance with a different login;
  the consuming plans are AWS-shaped today, so ECR is the worked example.
