Skip to content

Dockerfile Guidelines

General Rule

Images are self-contained. Containers do not load libraries, tools, or plug-ins etc. at runtime. Of cause, containers may operate on files mountend into them. For example, a container from an build image operates on source code mounted into the container.

Multi-Stage Builds

Always use multi-stage builds — separate build and runtime concerns:

  1. Build stage: full SDK image (language-specific builder or debian:bookworm-slim with tools installed).
  2. Runtime stage: minimal or distroless image. Copy only the compiled artifact and required runtime files.

Preferred base images for the release (production) runtime stage:

Language Runtime image
C++ gcr.io/distroless/cc-debian12
Go gcr.io/distroless/static-debian12
Java gcr.io/distroless/java25-debian13
TypeScript (frontend-react) nginx:alpine (pinned)

Static Frontend Runtime Stage — the One Documented Distroless Exception

The frontend-react profile's release stage is the one documented exception to the distroless-only rule above: a pinned nginx:alpine serves the static assets produced by the builder stage (vite build's dist/ output). There is no application process running — no interpreted or compiled program to keep off a general-purpose base image — only a static file server, so the attack-surface argument for distroless does not apply the same way it does to a service with a running application binary. No debug stage variant is needed here either: nginx already ships a shell, and there is no compiled binary whose debug symbols could be stripped.

Debug Runtime Stage

Service Dockerfiles add a third stage, debug, alongside builder and release — for manual, time-boxed incident investigation only. It is never deployed automatically (see specs/architecture/ADRs/ADR-004-ci_pipeline_design.md, "Production debugging").

Unlike release, the debug stage is not distroless — it needs a package manager to hold investigation tooling, which a minimal image cannot provide by design.

  • Runtime artifact: wherever the release build already retains debug symbols (e.g. C++ without an explicit strip, or a Java JAR compiled without -g:none), the debug stage reuses that exact same binary/JAR — no separate build. Where the release build strips them (e.g. Go's -ldflags="-s -w"), the debug stage uses a second build with the same flags minus the stripping — same optimization level and behavior, just with debug info retained. This is not the sanitizer-enabled Debug build configuration (see ADR-004-ci_pipeline_design.md, "Build Configurations") — it must not change runtime behavior or timing.
  • Extra tooling: the debug stage's base image provides a real shell (bash) and basic investigation tools (curl, make, etc.) via its package manager — e.g. debian:bookworm-slim with apt-get install for glibc-linked binaries (C++, Go), or the same Alpine-based JRE already used for the release stage (Java) with apk add.

Base Images

  • Pin base images to a specific version tag — never use latest.
  • Keep base images up to date; automate with Dependabot or Renovate.

Layer Caching

  • Copy dependency manifests first, install dependencies, then copy source — maximize cache reuse.
  • Each RUN instruction should do one logical thing.

Security

  • Run as a non-root user: create a dedicated user in the build stage; apply it in the runtime stage with USER.
  • No secrets in any image layer; use --mount=type=secret for build-time secrets, environment variables at runtime.
  • Scan images for vulnerabilities in CI (e.g., Trivy).

Labels

Apply standard OCI labels:

LABEL org.opencontainers.image.version="<version>"
LABEL org.opencontainers.image.revision="<git-sha>"
LABEL org.opencontainers.image.source="<repo-url>"

Entrypoint

  • Use ENTRYPOINT for the main binary; CMD for default arguments.
  • Use exec form ["binary", "arg"], not shell form — avoids signal propagation issues.
  • Entrypoint scripts (if needed) reside in image/.

Directives

  • Always use multi-stage builds: build stage (SDK) + runtime stage (distroless/minimal)
  • Runtime images: C++ → gcr.io/distroless/cc-debian12, Go → gcr.io/distroless/static-debian12, Java → gcr.io/distroless/java25-debian13
  • Pin all base image versions — never use latest
  • Copy dependency manifests first, then source (maximize cache reuse); each RUN does one logical thing
  • Run as non-root user; no secrets in any layer; scan with Trivy in CI
  • Use ENTRYPOINT ["binary", "arg"] exec form — not shell form
  • The debug runtime stage is not distroless: it may add a shell and investigation tools (bash, curl, make) via a package manager, and — only where the release build strips debug symbols — a debug-symbol-retaining build of the same binary; never deployed automatically
  • The frontend-react profile's static-asset runtime is the one documented exception to distroless: a pinned nginx:alpine serves the built assets, since no application process runs