{} The Go Reference

Containers · Cloud-Native · Beginner

Dockerizing Go

Packaging a Go service into a tiny, secure image — multi-stage builds, scratch/distroless bases, static linking, non-root users, and the version stamping that makes images traceable.

Containers Beginner ⏱ 5 min read Complete

📦 Analogy

Shipping a Go service in a full Linux image is mailing a screwdriver inside a packed toolbox inside a shipping container — heavy, slow, and full of things a thief could use. A multi-stage build unpacks the screwdriver and mails just that: the single static binary in a near-empty box. Smaller to send, nothing extra to steal, and it does exactly one job. Go’s static binaries make this minimalism trivial in a way few other languages can match.

What Docker actually is

A container packages your application together with everything it needs to run — the binary, files, and a minimal OS userland — into one image that runs identically on any machine with a container runtime. Docker is the standard tool for building and running them; you describe the image in a Dockerfile (a recipe of steps), run docker build to produce the image, push it to a registry, and Kubernetes (or docker run) pulls and runs it. New to it? Think “a go build that also packages the OS around your binary.” To learn Docker and Kubernetes themselves (they’re whole topics beyond this Go-focused page), see the official Docker and Kubernetes docs.

The multi-stage build

The standard pattern: compile in a heavy builder stage, then copy only the binary into a minimal final image.

graph LR
SRC["source + go.mod"] --> B["builder stage<br/>golang:1.x (toolchain)"]
B -->|CGO_ENABLED=0 go build| BIN["static binary"]
BIN -->|COPY --from=builder| F["final stage<br/>distroless / scratch"]
F --> IMG["tiny image (~10–20 MB)<br/>no shell · no compiler · non-root"]
# --- builder ---
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download          # cached layer: deps change rarely
COPY . .
# static, stripped, version-stamped:
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=$(git rev-parse --short HEAD)" \
    -trimpath -o /app ./cmd/server

# --- final ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
USER nonroot:nonroot         # least privilege
EXPOSE 8080
ENTRYPOINT ["/app"]

Order the COPY go.mod/go mod download before COPY . . so dependency downloads cache across builds. Add a .dockerignore (.git, *_test.go, local artifacts) so the build context stays small.

scratch vs distroless

BaseContainsUse when
scratchnothingPure static binary, no TLS to external hosts (or you COPY certs)
distroless/staticCA certs, tzdata, nonroot userThe common default — TLS works, still no shell
alpinemusl libc, shell, apkYou need a shell/tools (bigger, larger attack surface)

Two classic “works locally, breaks in the container” traps both come from scratch being empty: a cgo-linked binary won’t run (set CGO_ENABLED=0), and HTTPS calls fail with x509 errors unless CA certs are present (distroless includes them; for scratch, COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/).

See it: read the embedded build info

Go automatically stamps VCS and module info into the binary — readable at runtime, perfect for a /version endpoint that reports exactly which build a pod is running. This runs here:

version.go — editable & runnable
package main

import (
"fmt"
"runtime/debug"
)

// version can be overridden at build time with:
//   go build -ldflags "-X main.version=$(git rev-parse --short HEAD)"
var version = "dev"

func main() {
fmt.Println("app version:", version)

info, ok := debug.ReadBuildInfo()
if !ok {
	fmt.Println("no build info")
	return
}
fmt.Println("go:", info.GoVersion)
fmt.Println("dependencies:", len(info.Deps))
// In a VCS build, Go also records settings like vcs.revision and vcs.time:
for _, s := range info.Settings {
	if s.Key == "vcs.revision" || s.Key == "GOARCH" {
		fmt.Printf("  %s = %s\n", s.Key, s.Value)
	}
}
}

Expose version on /version, tag the image with the same commit SHA (never rely on :latest in production), and a misbehaving pod can tell you exactly which commit to roll back.

🐹 Small images are a security and speed win

A distroless Go image is often 10–20 MB versus ~1 GB for a naive FROM golang image that ships the whole toolchain and your source. Smaller images pull faster (quicker scaling and deploys), have a dramatically smaller attack surface (no shell, no package manager, no CVEs in utilities you never use), and pair with supply-chain scanning for a tight, auditable artifact. The static binary is what makes this minimalism possible — lean into it.

⚠️ No shell means you debug differently

The flip side of distroless/scratch: there’s no sh, so kubectl exec -it pod — sh fails, and you can’t poke around inside a running container. That’s intentional (an attacker can’t either), but it changes your workflow: debug via logs, metrics, and traces (the observability cluster), use kubectl debug ephemeral containers to attach a toolbox image, and add a /healthz//debug endpoint to the app itself. Build the binary so it explains itself, because you can’t shell in to ask.

See also

Next: running the image in a cluster — Kubernetes basics.

Check your understanding

Score: 0 / 5

1. Why use a multi-stage Dockerfile for a Go service?

A multi-stage build uses a heavy builder stage (the golang image with the toolchain) and a minimal final stage that COPYs only the compiled binary. The shipped image contains none of the build tooling or source — smaller (tens of MB vs ~1 GB), faster to pull, and a far smaller attack surface.

2. What does a 'scratch' or 'distroless' base image give you?

scratch is literally empty; distroless adds just CA certs, timezone data, and (optionally) a non-root user but still no shell or package manager. A static Go binary needs almost nothing else, so these bases produce tiny images with a tiny attack surface — and no shell means an attacker who gets RCE can't drop into bash.

3. What must be true for a Go binary to run on a scratch image?

scratch has no libc, so the binary must be static — set CGO_ENABLED=0 (cgo would dynamically link libc and fail). It also has no CA bundle, so HTTPS calls fail with x509 errors unless you COPY /etc/ssl/certs (or use distroless, which includes them). Those two gotchas account for most 'works locally, breaks in scratch' issues.

4. Why run the container as a non-root user?

Defense in depth: a compromised root process can do far more damage (escape attempts, writing anywhere a volume is mounted). Set USER to a non-root UID; hardened clusters enforce runAsNonRoot and will refuse to start root containers. Bind to a port >1024 since non-root can't bind privileged ports (or use setcap / a service that maps it).

5. How do you stamp a version into the image for traceability?

Build with -ldflags="-X main.version=$GIT_SHA" to bake a version variable, or rely on Go's automatic VCS stamping in debug.ReadBuildInfo. Expose it on a /version endpoint and tag the image identically (not just :latest). Then a running pod can tell you exactly which commit it is — essential for debugging and rollbacks.

Comments

Sign in with GitHub to join the discussion.