Stage 7 · Master
Phase 19 — Production Deployment
Docker
Write a multi-stage, multi-target, multi-arch Dockerfile with BuildKit's TARGETOS/TARGETARCH, add a non-root test stage, and prove the resulting images run correctly before anything downstream depends on them.
One Dockerfile, Two Runtime Targets, Every Architecture the Fleet Runs
The multi-stage build/runtime split and the go.work build-context trap were settled when organization-service was first containerized, and distroless non-root runtimes were settled when user-service shipped. This Dockerfile inherits all of that unchanged. What it adds is a requirement none of those lessons had: services/organization must now produce three distinct artifacts from one source tree — an API server, a one-shot migration runner, and both of those for two architectures, because the fleet mixes amd64 CI runners with arm64 production nodes. BuildKit's named stages plus its automatic TARGETOS/TARGETARCH arguments make that one file instead of four.
# syntax=docker/dockerfile:1.7
ARG GO_BUILDER_IMAGE=golang:1.26.0-bookworm
ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian12:nonroot
ARG MIGRATE_VERSION=v4.19.1
# ---- deps: a workspace generated for this image, naming only what it copies ----
FROM ${GO_BUILDER_IMAGE} AS deps
WORKDIR /src
# The repository's own go.work names every module in the monorepo, including
# ten services this image has no reason to contain. Copying it would make the
# build fail immediately — go refuses to load a workspace whose members are
# missing from disk. So the image generates its own workspace instead, naming
# exactly the modules copied below and nothing else.
COPY services/organization/go.mod services/organization/go.sum* services/organization/
# Each pkg/* directory is its own module with its own go.mod. Copying the
# manifests alone keeps this layer cached until a dependency actually changes.
COPY pkg/apperr/go.mod pkg/apperr/go.sum* pkg/apperr/
COPY pkg/buildinfo/go.mod pkg/buildinfo/go.sum* pkg/buildinfo/
COPY pkg/config/go.mod pkg/config/go.sum* pkg/config/
COPY pkg/logging/go.mod pkg/logging/go.sum* pkg/logging/
RUN go work init ./services/organization ./pkg/apperr ./pkg/buildinfo ./pkg/config ./pkg/logging
RUN --mount=type=cache,target=/root/go/pkg/mod,sharing=locked \
go mod download -C services/organization
FROM deps AS source
COPY services/organization/ services/organization/
COPY pkg/ pkg/
# ---- test: proves the source compiles and passes as a non-root user, on the
# exact builder image the runtime binary is compiled with — never root ----
FROM source AS test
RUN groupadd --system gotest && useradd --system --gid gotest --no-create-home gotest
RUN chown -R gotest:gotest /src
USER gotest
RUN --mount=type=cache,target=/home/gotest/go/pkg/mod,sharing=locked,uid=$(id -u gotest) \
--mount=type=cache,target=/home/gotest/.cache/go-build,sharing=locked,uid=$(id -u gotest) \
GOCACHE=/home/gotest/.cache/go-build GOPATH=/home/gotest/go \
go test -C services/organization -race -count=1 ./...
# ---- build-api: cross-compiled for whatever platform buildx targets ----
FROM source AS build-api
ARG VERSION=dev
ARG COMMIT=unknown
ARG TARGETOS
ARG TARGETARCH
RUN --mount=type=cache,target=/root/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -C services/organization -trimpath \
-ldflags "-s -w \
-X github.com/hoa-platform/backend/pkg/buildinfo.Version=${VERSION} \
-X github.com/hoa-platform/backend/pkg/buildinfo.Commit=${COMMIT}" \
-o /out/organization-api \
./cmd/api
# ---- build-migrate: the migration CLI, from the pinned upstream module ----
FROM ${GO_BUILDER_IMAGE} AS build-migrate
ARG MIGRATE_VERSION
ARG TARGETOS
ARG TARGETARCH
RUN --mount=type=cache,target=/root/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
GOBIN=/out CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go install -tags 'pgx5' github.com/golang-migrate/migrate/v4/cmd/migrate@${MIGRATE_VERSION}
# ---- api: the image Kubernetes runs — no shell, no package manager, non-root ----
FROM ${RUNTIME_IMAGE} AS api
WORKDIR /app
COPY --from=build-api /out/organization-api ./organization-api
EXPOSE 8081 9090
ENTRYPOINT ["/app/organization-api"]
# ---- migrate: a one-shot runner, scoped to organization's own migrations only ----
FROM ${RUNTIME_IMAGE} AS migrate
WORKDIR /app
COPY --from=build-migrate /out/migrate ./migrate
COPY --from=source /src/services/organization/migrations ./migrations
ENTRYPOINT ["/app/migrate"]The go work init line is the one people delete first and regret. A workspace is not a list of suggestions — go loads every module a go.work names, and errors out if one is absent from disk, so shipping the repository's own go.work into an image containing a single service is guaranteed to fail. Generating a workspace that names precisely the copied modules keeps first-party imports resolvable while leaving the other ten services out of the build context entirely. The alternative, GOWORK=off, is worse: the service module would then look for pkg/... in the module cache, where first-party code has never been published. The test stage runs before build-api in the graph but is invoked with its own --target, so building the api or migrate target never pays for it twice.
golang-migrate's CLI has no environment-variable DSN it reads automatically the way some tools do — -path, -database, and up/down must always be supplied as explicit arguments. Baking a default -database value into this image would mean either hardcoding one environment's connection string into every build or shipping an image that silently does nothing useful. Leaving ENTRYPOINT as the bare binary and requiring every caller — Compose's command:, or a Kubernetes Job's command: — to supply the full argument list keeps that DSN out of the image entirely.
Root can read and write files no other user can, silently masking a bug where the application later fails under its own non-root runtime user — for example, a migration writing a lock file to a path the real deployed process cannot access. Running the test stage as an unprivileged gotest user surfaces that class of bug at build time instead of in a production rollout.
TARGETOS and TARGETARCH Are Set by BuildKit, Not by You
ARG TARGETOS and ARG TARGETARCH are automatic platform arguments BuildKit populates from the --platform flag on the *build* invocation — they are not environment variables the Dockerfile author sets. A single docker buildx build --platform linux/amd64,linux/arm64 invocation runs the build-api stage twice, once per requested platform, each time with different values injected into the same ARG names.
| Flag | Effect |
|---|---|
| --platform linux/amd64,linux/arm64 | Requests BuildKit run every stage once per listed platform, setting TARGETOS/TARGETARCH accordingly each time |
| --load | Loads a single-platform result into the local Docker daemon — incompatible with more than one --platform value |
| --push | Publishes a multi-platform manifest list directly to the registry — the only way to produce a real multi-arch image |
Never Publish or Depend on a Mutable Tag
The tag above is the short commit SHA, not latest and not a branch name. A mutable tag like latest can be silently overwritten by the next build, which means a Kubernetes node that already pulled organization-api:latest and a node that pulls it five minutes later after a new push can end up running two different binaries under an identical name — an untraceable, unreproducible deployment. Every reference to this image from Compose, Kubernetes, or Helm onward in this course uses either the immutable commit SHA tag or, once Lesson 6's promotion pipeline exists, the resolved content digest.
Prove Both Images Run Correctly Before Anything Depends on Them
Applied exercise
Add a Dockerfile for services/gateway-service with the same non-root test discipline
services/gateway-service has no Dockerfile yet. Unlike services/organization it produces exactly one binary — no migration target — but it must meet the same cross-arch and non-root standards.
- Write a multi-stage Dockerfile with deps, source, test, and build-api stages mirroring services/organization's structure, generating its own go.work from the modules gateway actually imports.
- Ensure the test stage creates and switches to a non-root user before running go test.
- Build it for linux/amd64 and linux/arm64 using buildx with --push to a real or local registry.
- Run the resulting image with --read-only and a non-root numeric UID, publish both its API and internal ports, and verify /healthz responds on the internal one.
Deliverable
A committed services/gateway-service/Dockerfile, a successful multi-arch buildx push, and a verified non-root, read-only container run.
Completion checks
- No stage in the Dockerfile runs go test as the root user, and the generated workspace names only modules the image copies.
- The image is tagged with an immutable commit SHA, never latest.
- docker run --read-only --user 65532:65532 succeeds and /healthz returns 200 on the internal port, not the API port.
Why must docker buildx build use --push instead of --load when building with --platform linux/amd64,linux/arm64?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.