Skip to content

ADR-004: Use minimal, multi-stage Docker images for the service runtime container

Field Value
Date 2026-03-20
Status Accepted
Deciders TODO
Supersedes
Superseded by

Context and Problem Statement

The service requires a runtime Docker image. The choice of base image for the runtime stage determines the attack surface, image size, CVE exposure, and the availability of debugging tools in production.

The containerization.md guideline mandates distroless or equivalently minimal runtime images. The generated image/Dockerfile implements a three-stage build:

  1. builder — full toolchain, compiles the artifact.
  2. release — minimal runtime, no shell, no package manager. Used in production.
  3. debug — a package-manager-having base (e.g. debian:bookworm-slim, or the JRE-Alpine image already used for release) with a shell and basic investigation tools (bash, curl, make) installed on top. Used for manual, time-boxed incident investigation only — never deployed automatically (see ADR-006-ci_pipeline_design.md, "Production debugging").

Decision Drivers

  • No shell or package manager in the production image — reduced attack surface.
  • No CVEs from unused OS packages.
  • Smallest possible image size for the release stage.
  • The debug stage must allow shell access and basic investigation tooling (curl, make, etc.) for troubleshooting, without shipping any of that to production.
  • Where the release build strips debug symbols, the debug stage must still be able to symbolicate stack traces — without resorting to a differently-behaving (e.g. sanitizer-enabled) build (see ADR-006-ci_pipeline_design.md, "Build Configurations").

Considered Options

  1. Alpine Linux (*-alpine) for all runtime stages.
  2. Debian slim for all runtime stages.
  3. Distroless or language-minimal JRE images (language-dependent).
  4. Scratch (fully empty image).

Decision Outcome

Chosen option: Option 3 — language-appropriate minimal images. See image/Dockerfile for the concrete base images used by this project.

Key constraint: no shell in the release image

The release stage does not contain a shell. docker exec <container> sh will fail against a running production container.

To get a shell (and curl/make) for debugging:

# Build the debug variant locally:
docker build --target debug -t <name>:debug .

# Or pull the debug tag published by CI:
docker run --rm -it <registry>/<name>:<tag>-debug bash

Debug stage: tooling and debug symbols

The debug stage trades the release stage's minimalism for investigation capability — see specs/guidelines/containerization.md ("Debug Runtime Stage") for the full rationale:

  • Base image: not distroless — a real package manager (apt/apk) is required to install bash, curl, make, etc. on top.
  • Binary/JAR: reused as-is from the release build wherever it already retains debug symbols (e.g. C++, Java); rebuilt with the same flags minus any stripping where the release build explicitly strips them (e.g. Go's -ldflags="-s -w"). Either way, this is the same optimization level and runtime behavior as release — not a different (e.g. sanitizer-enabled) build configuration.

Positive Consequences

  • Minimal attack surface in production: release has no shell, no curl, no apt/apk.
  • Smallest viable image size for the language's release stage.
  • CI builds the image with Kaniko — no privileged containers required.

Negative Consequences / Risks

  • Teams must use the debug image tag (or a sidecar) for interactive debugging.
  • OS-level security fixes require a full image rebuild — not resolved by apt-get upgrade. Schedule regular pipeline runs or image rebuilds to pick up base image updates.

Pros and Cons of the Options

Option 1 — Alpine Linux

  • Pro: Shell available; familiar; easy to add tools with apk.
  • Con: Shell and package manager present at runtime — unnecessary attack surface.

Option 2 — Debian slim

  • Pro: glibc-compatible; familiar.
  • Con: Larger than Alpine or distroless; includes apt, bash, many utilities — precisely why it is used for the debug stage instead, rather than for release.

Option 3 — Distroless / JRE-Alpine (chosen)

  • Pro: Minimal attack surface; smallest image; no unnecessary tools in production.
  • Con: No shell or tooling in the release image (by design). Use the debug stage (a non-distroless base, see above) for troubleshooting.

Option 4 — Scratch

  • Pro: Absolute minimum size; no OS layer.
  • Con: Requires fully static binary including TLS certificates; adds build complexity.
  • image/Dockerfile — three-stage Dockerfile for this project
  • specs/guidelines/containerization.md — Containerization Guidelines ("Debug Runtime Stage")
  • specs/guidelines/tool_chain.md — hermetic CI build requirements for CI build images (separate from runtime)
  • specs/architecture/ADRs/ADR-006-ci_pipeline_design.md — "Build Configurations" and "Production debugging" sections this decision's debug stage design relies on