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:<N>-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-debian<N>
Go gcr.io/distroless/static-debian<N>
Java gcr.io/distroless/java<V>-debian<N>
TypeScript (frontend-react) nginx:alpine (pinned)

<N> is the Debian release of the build image, <V> the Java version it compiles for. The runtime's glibc and libstdc++ must be at least as new as the build image's; the same Debian release guarantees that. A static Go binary links no C library, so for Go <N> is the current Debian release.

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.

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 — it must not change runtime behavior or timing.
  • Base image: debian:<N>-slim with the Debian release of the build image (C++, Go), or an official JRE image with a package manager, such as eclipse-temurin:<V>-jre (Java). Pin the exact tag.
  • Extra tooling: bash and the tools to look around in a running container and to inspect or edit its configuration files: ca-certificates, curl, jq, procps, ripgrep, vim-tiny (for vi), and yq (Mike Farah's, copied from its official image with COPY --from). Install with --no-install-recommends and remove the package lists, so the image grows by the tools only. Leave out git: with Perl it doubles the image, and whoever needs it installs it in the running container.
  • Updates: Update the packages of the base image first, with its package manager, such as apt-get upgrade -y (Debian, Ubuntu), apk upgrade (Alpine), or dnf upgrade -y (Fedora). A base image lags behind the bug and security fixes of its distribution, and the image scan counts a fix the distribution has released as fixable. Build images differ: they stay pinned and are kept current by a dependency bot.

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-debian<N>, Go → gcr.io/distroless/static-debian<N>, Java → gcr.io/distroless/java<V>-debian<N>, with <N> the Debian release of the build image (for static Go binaries, the current one)
  • 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: debian:<N>-slim or an official JRE image, its packages updated first (e.g. apt-get upgrade -y), with bash, ca-certificates, curl, jq, procps, ripgrep, vim-tiny, and yq, 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