Stage 7 · Master
Shipping It — Docker & Kubernetes
Multi-Stage Docker Builds
Fieldwork builds each Go service once, then ships only the compiled binary and the minimum runtime baggage it actually needs.
Why We Stopped Shipping Build Toolchains
Fieldwork is a go.work repository with several deployable services: api-gateway, auth-service, and tasks-service all have their own cmd/ entrypoints and internal/ packages. The first time you containerize a Go service, it is tempting to write one Dockerfile that does everything in a single stage: install Go, copy the repo, compile the binary, and run that binary from the same image. It works. It also ships the entire build toolchain, module cache, shell, package manager metadata, and whatever else happened to be in the builder image. That is a lot of unnecessary surface area for code that only needs one compiled process and a CA bundle at runtime.
We rejected single-stage builds because their hidden cost shows up everywhere later: slower image pulls, larger registry storage, noisier vulnerability scans, and more room for accidental runtime dependency on tools that should not exist in production. If a service starts depending on bash inside the container, that is not convenience. That is a design leak. Multi-stage builds let us keep the build environment rich and the runtime environment boring.
| Approach | Benefit | Cost in Fieldwork |
|---|---|---|
| Single-stage Go image | Fastest thing to get working | Huge images and runtime drift because compilers and package tools stay in production |
| Multi-stage + scratch | Tiny runtime image | Harder debugging and certificate/user management for services that still need sane defaults |
| Multi-stage + distroless | Small runtime plus certificates and non-root base | Chosen. Minimal surface without making ordinary operations painful |
A smaller image is nice, but the bigger win was removing accidental capabilities from production. The best container for tasks-service is not one that can compile Go code or run curl. It is one that can start the binary, make TLS connections, and little else.
How the Dockerfile Was Structured
The pattern we standardized on was three stages. First, a dependency stage copied go.work, go.work.sum, and the relevant service module files so go mod download could be cached aggressively. Second, a build stage copied the rest of the source and produced a statically linked Linux binary with CGO disabled. Third, a distroless runtime stage copied only the binary and exposed the service port. That split sounds mechanical, but the ordering matters. Copying the entire repo before downloading modules would invalidate the cache on every content or README change, which is exactly the kind of invisible CI tax that multiplies in a multi-service repository.
# syntax=docker/dockerfile:1.7
FROM golang:1.24-bookworm AS deps
WORKDIR /workspace
COPY go.work go.work.sum ./
COPY services/tasks-service/go.mod services/tasks-service/go.sum ./services/tasks-service/
COPY services/shared/go.mod services/shared/go.sum ./services/shared/
RUN go work sync && go mod download -C services/tasks-service
FROM golang:1.24-bookworm AS build
WORKDIR /workspace
COPY --from=deps /go/pkg /go/pkg
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags='-s -w' \
-o /out/tasks-service ./services/tasks-service/cmd/tasks-service
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=build /out/tasks-service /app/tasks-service
EXPOSE 8080
ENTRYPOINT ["/app/tasks-service"]The dependency copy is intentionally narrow for cache stability, the build output is a static Linux binary, and the runtime image contains only that binary on a non-root distroless base.
The important trade-off was choosing what not to copy. We did not copy .git, tests, migration source, or local tooling into the runtime layer. We also avoided embedding environment-specific config into the image. The image had one job: be the same immutable artifact in staging and production, with configuration supplied later by Kubernetes. That separation paid off in deployment debugging because any difference in behavior between environments had to come from config, dependencies, or traffic shape, not from secretly different builds.
Why Distroless Won Over Alpine and Scratch
We evaluated Alpine because it is familiar and comparatively small, and scratch because it is the smallest possible runtime base. Distroless won because Fieldwork's services needed a practical middle ground. Alpine would still leave a package manager and shell-adjacent environment in production, and it sometimes complicates expectations around libc behavior for teams who later enable CGO or vendor native dependencies. Scratch looked elegant until you remember the operational details: certificate bundles, user identity, and the general friction of an environment with absolutely nothing available when you need to reason about a failure.
Distroless static images gave us CA certificates and sane non-root defaults while remaining far leaner than a general-purpose distribution. That was the actual Fieldwork trade-off. We did not choose the smallest image. We chose the smallest image that still supported straightforward TLS connections to Postgres, metrics scrapers, and upstream APIs without turning every runtime assumption into manual container assembly.
If the question is "what is the absolute minimum image I can produce," scratch often wins. If the question is "what runtime base keeps my services small without forcing custom certificate and user plumbing everywhere," distroless is usually the more maintainable answer.
Building Multiple Services Without Copy-Paste
Because Fieldwork is multi-module, the next trap was duplicating near-identical Dockerfiles for every service and letting them drift. We kept one Dockerfile pattern per service because the build context and binary path differ, but we standardized the structure so CI could parameterize builds cleanly. The repository convention was simple: every deployable service owns cmd/<service-name>/main.go, exposes /health/ready and /metrics, and can be built with a service-specific Dockerfile that accepts the image tag from CI. That made selective builds in the next module possible without inventing custom shell logic for every directory.
jobs:
build-images:
runs-on: ubuntu-latest
strategy:
matrix:
service: [api-gateway, auth-service, tasks-service]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: ./services/${{ matrix.service }}/Dockerfile
push: true
tags: ghcr.io/thesyscoder/fieldwork/${{ matrix.service }}:${{ github.sha }}The Dockerfiles stay service-local, but CI can treat them uniformly because the stage structure and binary output conventions are consistent across the repo.
- Copy module manifests before source files so go mod download remains cacheable.
- Build static Linux binaries for predictable distroless runtime behavior.
- Keep runtime images free of shells, compilers, and environment-specific config.
- Standardize Dockerfile layout across services even when paths differ.
What We Actually Shipped
Fieldwork shipped multi-stage Dockerfiles for each Go service, with narrow dependency-copy steps, static builds, and distroless non-root runtime images. That decision reduced image size, but more importantly it removed build-time tooling from production and made container behavior far more predictable across environments. The image became an immutable application artifact instead of a mini Linux box with a Go compiler accidentally left inside.
The rejected alternatives were not wrong in general. Single-stage builds are fine for the first hour of a prototype, and scratch is fine when you intentionally want to own every runtime detail. They were wrong for Fieldwork's stage of maturity. We needed fast CI caches, low-friction secure runtimes, and consistency across several services. Multi-stage builds with distroless gave us exactly that and nothing extra.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.