Docker, Makefile, and running in a container
A 15MB image with no operating system in it, and the two runtime settings a container needs.
- Write a multi-stage Dockerfile that produces a small static Go image.
- Explain the two runtime settings a container needs (GOMEMLIMIT and GOMAXPROCS).
- Cross-compile for another OS and architecture with GOOS and GOARCH.
What containers give a Go program
A container is your program packaged with everything it needs to run, so it behaves the same on your machine, a colleague's, and a server. An image is the packaged thing; a container is a running copy of it.
Go is unusually well suited to this. Because go build produces a single file with no runtime to install, the image can contain almost nothing but your binary: no operating system, no shell, no package manager. That makes it small and gives an attacker very little to work with.
The catch is that Go's runtime does not know the container's memory limit, and before Go 1.25 it did not know the CPU limit either. Two environment variables fix that.
A multi-stage Dockerfile
FROM golang:1.25-alpine AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod download # cached unless the deps changeCOPY . .RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /api ./cmd/apiFROM gcr.io/distroless/static-debian12COPY --from=build /api /apiUSER nonroot:nonrootEXPOSE 8080ENTRYPOINT ["/api"]Why each flag is there
- `CGO_ENABLED=0`: a fully static binary with no libc dependency. This is what lets you use the
staticdistroless base, and it keeps cross-compiling free of a C toolchain. - `-ldflags="-s -w"`: strips the symbol table and DWARF debug info. A few MB smaller. Panic stack traces still show function names; you lose debugger support.
- Copy `go.mod` first: Docker caches that layer, so changing a
.gofile does not re-download every dependency. - `USER nonroot`: if something does go wrong, it does not go wrong as root.
The two runtime settings a container needs
GOMEMLIMIT=450MiB # ~90% of the container's memory limitGOMAXPROCS=2 # match the CPU limit, not the host's core count`GOMEMLIMIT` is a soft limit on the memory the Go runtime uses. Without it, the garbage collector sizes the heap from GOGC alone: by default it lets the heap grow to about twice the live data before collecting. A service with 300MB of live data can then reach 600MB, and a 512MB container gets OOM-killed. With the limit set, the collector works harder as memory approaches it.
`GOMAXPROCS` used to default to the *machine’s* CPU count, not your container’s limit. On a 64-core node with a 2-core quota, Go runs Go code on up to 64 threads at once, uses up the quota early in each period, and the kernel throttles the container until the next one. Latency spikes. Since Go 1.25, on Linux, the runtime reads the cgroup CPU limit itself, but only when your go.mod says go 1.25 or later. On older versions, set it explicitly or use go.uber.org/automaxprocs.
docker compose for local development
services: db: image: postgres:16 environment: POSTGRES_PASSWORD: secret POSTGRES_DB: api ports: ["5432:5432"] healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s volumes: ["pgdata:/var/lib/postgresql/data"] api: build: . depends_on: db: { condition: service_healthy } environment: DATABASE_URL: postgres://postgres:secret@db:5432/api?sslmode=disable JWT_SECRET: dev-secret-at-least-32-characters-long GOMEMLIMIT: 450MiB ports: ["8080:8080"]volumes: pgdata:A Makefile so you stop retyping
.PHONY: run test lint build up down migrate checkrun: ; go run ./cmd/apitest: ; go test -race -cover ./...lint: ; go vet ./... && test -z "$$(gofmt -l .)"build: ; CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/api ./cmd/apiup: ; docker compose up -ddown: ; docker compose downmigrate: ; migrate -path migrations -database "$(DATABASE_URL)" upcheck: ; test -z "$$(gofmt -l .)" && go vet ./... && go test -race ./... && govulncheck ./...Cross-compiling is two environment variables
GOOS=linux GOARCH=amd64 go build ./cmd/apiGOOS=linux GOARCH=arm64 go build ./cmd/apiGOOS=darwin GOARCH=arm64 go build ./cmd/apiGOOS=windows GOARCH=amd64 go build ./cmd/apigo tool dist list # everything Go can target- Multi-stage build + distroless gives a ~15MB image with no shell and no package manager.
CGO_ENABLED=0is what makes both the static binary and cross-compiling work.- Set
GOMEMLIMITin every container, andGOMAXPROCStoo before Go 1.25. They prevent OOM kills and CPU throttling. - A Makefile with
checkgives you one command to run before every push.
docker images. Then set GOMEMLIMIT and confirm the app still starts.- 1Build the image and check its size with
docker images. Then build withoutCGO_ENABLED=0and see what breaks. - 2Try
docker exec -it <container> shon the distroless image. There is no shell — that is the point. - 3Set
GOMEMLIMITfar too low and watch the GC work harder withGODEBUG=gctrace=1. - 4Cross-compile for
GOOS=darwin GOARCH=arm64and confirm the binary is produced with no extra toolchain.