--- name: z-go-dockerfile description: Multi-stage Dockerfile authoring for Go services — a deps layer cached separately from source, BuildKit cache mounts, CGO_ENABLED=0 static builds, and the scratch vs distroless vs alpine call. Use when writing a service Dockerfile, picking a base image, or explaining why a container has no shell. Triggers on "Dockerfile", "multi-stage build", "distroless", "CGO_ENABLED=0". Build/push pipeline is [[z-go-ci]]; local parity [[z-go-makefile]]. --- # Go Dockerfile Multi-stage build: a `deps` stage that caches modules, a `build` stage that compiles a static binary, and a minimal final stage that only carries the binary. Every layer earns its place — no shell, no package manager, no root user in the shipped image unless a specific need demands it. ## Final image choice | Base | Certs & tzdata | Shell / exec debugging | Reach for it when | |---|---|---|---| | `gcr.io/distroless/static-debian13:nonroot` | bundled (ca-certificates + tzdata) | none in `:nonroot`; swap to the `:debug` tag (busybox shell) to triage | **default** for a `CGO_ENABLED=0` Go binary | | `scratch` | none — `COPY` both from the builder yourself | none, ever — no `:debug` variant exists | absolute minimal footprint is a hard requirement and you're fine hand-rolling certs/tzdata | | `alpine` | `apk add --no-cache ca-certificates tzdata` (neither ships by default) | `ash` shell + `apk` built in | you need a shell for an entrypoint script, `kubectl exec` triage, or a musl-linked CGO build | `distroless/static` is the sane default: it already carries what `scratch` forces you to reinvent, and it's smaller than `alpine` with a strictly smaller attack surface (no shell, no package manager). Reach for `scratch` only when a security policy mandates zero non-binary content, and for `alpine` only when something in the running container genuinely needs a shell. ## Reference Dockerfile ```dockerfile # syntax=docker/dockerfile:1 FROM golang:1.26-bookworm AS deps WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download FROM deps AS build ARG VERSION=dev ARG GIT_COMMIT=none ARG BUILD_DATE= COPY . . RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build -trimpath \ -ldflags="-s -w -X main.Version=${VERSION} -X main.CommitHash=${GIT_COMMIT} -X main.BuildDate=${BUILD_DATE}" \ -o /out/app ./cmd/app FROM gcr.io/distroless/static-debian13:nonroot COPY --from=build /out/app /app USER 65532:65532 ENTRYPOINT ["/app"] ``` Numeric UID, not the `nonroot` name: a Pod with `securityContext.runAsNonRoot: true` fails to start against a string `USER` — Kubernetes can't verify non-root status from a name, only a number, and `65532` is the exact UID the `:nonroot` tag already sets up. `go.mod`/`go.sum` land in their own `COPY` before the rest of the source, so `go mod download` only reruns when dependencies actually change — editing a `.go` file never invalidates that layer. ## BuildKit cache mounts `--mount=type=cache,target=...` needs the `# syntax=docker/dockerfile:1` directive on line one; without it the mount flag is silently unsupported. Two mounts cover the two Go caches: - `/go/pkg/mod` — `GOMODCACHE`, the default under the official `golang` image's `GOPATH=/go`. - `/root/.cache/go-build` — `GOCACHE`, the compiler's build-artifact cache (builder stage runs as root, hence `/root`). These caches persist across builds on the same daemon/builder — a cold CI runner still pays the full cost, but a warm local Docker daemon or a CI cache-mount backend (e.g. `--cache-from`/`--cache-to` in `docker buildx build`) turns a `go build` layer into an incremental one. This is the default accelerator, not optional hardening: skip it and every source-touching rebuild redownloads and recompiles the entire module graph. ## Version stamping `-X main.Version / main.CommitHash / main.BuildDate` mirror the `-ldflags` convention in [[z-go-makefile]] — same three variable names in package `main`, so a local `make build` and the image produced by this Dockerfile report identical version strings. Pass all three at build time: `docker build --build-arg VERSION=$(git describe --tags --always --dirty) --build-arg GIT_COMMIT=$(git rev-parse --short HEAD) --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) .`; the pipeline step that computes and passes them belongs in [[z-go-ci]]. `-s -w` strips the symbol table and DWARF debug info — smaller binary, but `dlv` can't symbolicate it. If a container image genuinely needs live `delve` debugging, build a second, unstripped target for that image; don't drop `-s -w` from the production build to accommodate a debugging session that happens rarely. ## ca-certificates and tzdata on scratch `scratch` starts from nothing: no `/etc/ssl/certs`, no `/usr/share/zoneinfo`, no `/etc/passwd`. Any outbound TLS call or `time.LoadLocation` fails without an explicit copy from the builder: ```dockerfile COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo ``` `distroless/static` already bundles both — this is the main reason it beats `scratch` for anything that makes an HTTPS call or handles time zones. ## Healthcheck reality Docker's `HEALTHCHECK CMD` instruction needs a shell or an exec-able binary in the image, and it's meaningless in Kubernetes — the kubelet drives `livenessProbe`/`readinessProbe`/`startupProbe` from the Pod spec and never reads the image's `HEALTHCHECK` metadata. Define probes there (`httpGet`, `grpc`, or `exec` against a tiny health binary), not in the Dockerfile. A Dockerfile `HEALTHCHECK` only matters for `docker run`/Compose/Swarm, and on `scratch`/`distroless:nonroot` there's no shell for the shell form anyway. ## .dockerignore essentials ``` .git bin/ dist/ *.md .env .env.* .github/ testdata/ compose.override.yml ``` Don't ignore `vendor/` if the module actually vendors dependencies — the build stage needs it. Ignoring `.git` matters most: without it, every commit invalidates Docker's build-context cache even when no tracked file changed. ## Multi-arch BuildKit auto-populates `TARGETOS`/`TARGETARCH` for cross-compiling the same Dockerfile to `linux/arm64` and `linux/amd64`: ```dockerfile FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS build ARG TARGETOS ARG TARGETARCH RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/app ./cmd/app ``` `--platform=$BUILDPLATFORM` pins the builder stage to the host's own architecture — Go then cross-compiles natively via `GOOS`/`GOARCH` with no QEMU emulation of the build step. Drop that pin and `buildx --platform linux/arm64` on an amd64 host emulates the entire builder under QEMU instead. The `docker buildx build --platform linux/amd64,linux/arm64` invocation and the CI job that runs it belong in [[z-go-ci]]; this Dockerfile only needs the platform pin and the two `ARG`s to be cross-compile-ready. ## Local Compose stack Commit a `compose.yml` for local dev — one file, `docker compose up` gives the service a real database and cache: ```yaml services: app: build: context: . args: VERSION: ${VERSION:-dev} GIT_COMMIT: ${GIT_COMMIT:-none} BUILD_DATE: ${BUILD_DATE:-} env_file: [.env] ports: - "8080:8080" depends_on: db: condition: service_healthy redis: condition: service_healthy db: image: postgres:17-alpine environment: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app healthcheck: test: ["CMD-SHELL", "pg_isready -U app"] interval: 5s timeout: 3s retries: 5 redis: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 ``` Expose the `9090` management port only on explicit opt-in, in a companion file named `compose.mgmt.yml` — do NOT call it `compose.override.yml`, that exact name auto-loads and would publish `9090` on every plain `up`: ```yaml services: app: ports: ["9090:9090"] ``` A plain `docker compose up` keeps `9090` private — no host access; `docker compose -f compose.yml -f compose.mgmt.yml up` adds it. An optional `otel` profile adds an `otel-collector` service for local trace inspection. The `build.args` names (`VERSION`/`GIT_COMMIT`/`BUILD_DATE`) mirror the stamping vars in [[z-go-makefile]]. Production Compose is not this file: it uses the [[z-selfhost-deploy]] patterns — pinned image tag, no `build:`, `restart: unless-stopped`. This compose is local dev only. Container healthcheck: prefer the app's own subcommand — `test: ["CMD", "/app", "healthcheck"]` — or fall back to `wget -qO- http://localhost:8080/healthz` (see [[z-go-observability]]). Minimal images (`scratch`, `distroless:nonroot`) ship no `curl` and usually no `wget`; a shell-form `HEALTHCHECK` fails outright there. ## Do not - Copy the full source tree before `go mod download` — it collapses the deps-cache layer into the source layer, so every code change re-downloads every module. - Ship `alpine` or a full `golang` image to production because a shell "might be useful someday" — that's exactly the attack surface `distroless` removes. - Add a Dockerfile `HEALTHCHECK` as a substitute for a Kubernetes probe — the kubelet never reads it. - Run the final stage as root — `distroless/static-debian13:nonroot` and an explicit `USER nonroot:nonroot`/numeric UID exist precisely so you don't have to. - Strip `-s -w` from every build "just in case delve is needed" — keep it in prod, build a separate debug target for the rare session that needs symbols. ## Verify ```sh DOCKER_BUILDKIT=1 docker build -t app:local . docker run --rm app:local # exits/serves without a missing-cert or missing-tzdata error docker inspect app:local --format '{{.Config.User}}' # 65532:65532, not root/empty docker history app:local --no-trunc | head # deps layer cached, no shell/package manager baked in ```