Dev Skills

Docker, Makefile, and running in a container

A 15MB image with no operating system in it, and the two runtime settings a container needs.

Topic 84 of 98 · 5 min to read · 50 min to build
After this topic you can
  • 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

Dockerfile
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # cached unless the deps change
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /api ./cmd/api
FROM gcr.io/distroless/static-debian12
COPY --from=build /api /api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]
The final image has no shell, no package manager, and no libc: just your binary, CA certificates and time zone data. About 15MB, and almost nothing to attack.

Why each flag is there

  • `CGO_ENABLED=0`: a fully static binary with no libc dependency. This is what lets you use the static distroless 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 .go file 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

env
GOMEMLIMIT=450MiB # ~90% of the container's memory limit
GOMAXPROCS=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

docker-compose.yml
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

Makefile
.PHONY: run test lint build up down migrate check
run: ; go run ./cmd/api
test: ; go test -race -cover ./...
lint: ; go vet ./... && test -z "$$(gofmt -l .)"
build: ; CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/api ./cmd/api
up: ; docker compose up -d
down: ; docker compose down
migrate: ; migrate -path migrations -database "$(DATABASE_URL)" up
check: ; test -z "$$(gofmt -l .)" && go vet ./... && go test -race ./... && govulncheck ./...

Cross-compiling is two environment variables

terminal
GOOS=linux GOARCH=amd64 go build ./cmd/api
GOOS=linux GOARCH=arm64 go build ./cmd/api
GOOS=darwin GOARCH=arm64 go build ./cmd/api
GOOS=windows GOARCH=amd64 go build ./cmd/api
go tool dist list # everything Go can target
No cross-toolchain, no container, no configure script. This works as long as CGO_ENABLED=0.
The short version
  • Multi-stage build + distroless gives a ~15MB image with no shell and no package manager.
  • CGO_ENABLED=0 is what makes both the static binary and cross-compiling work.
  • Set GOMEMLIMIT in every container, and GOMAXPROCS too before Go 1.25. They prevent OOM kills and CPU throttling.
  • A Makefile with check gives you one command to run before every push.
Remember: Go’s deployment story is one static binary. Do not undo it by shipping a full OS image or letting the runtime guess your container limits.
Build this before moving on
Dockerise the project, run it with compose, and check the image size with docker images. Then set GOMEMLIMIT and confirm the app still starts.
Then try these in your own editor
  1. 1Build the image and check its size with docker images. Then build without CGO_ENABLED=0 and see what breaks.
  2. 2Try docker exec -it <container> sh on the distroless image. There is no shell — that is the point.
  3. 3Set GOMEMLIMIT far too low and watch the GC work harder with GODEBUG=gctrace=1.
  4. 4Cross-compile for GOOS=darwin GOARCH=arm64 and confirm the binary is produced with no extra toolchain.
Check yourselfTake the Production test