Skip to content
CalliCoder

Building Docker Containers for Go Applications

DevOps 11 min read

From a 900 MB image to about 12 MB: multi-stage builds, why CGO_ENABLED=0 decides whether your binary runs on distroless, the COPY order that makes dependency layers cacheable, and the CA certificates scratch does not give you.

Go is unusually well suited to containers: the compiler produces one statically linkable binary with no runtime to install. That makes the difference between a careless Dockerfile and a careful one larger here than almost anywhere else: roughly two orders of magnitude in image size, from the same source.

Written against Go 1.22 and Docker 25.

The application

package main

import (
	"encoding/json"
	"log"
	"net/http"
	"os"
)

type health struct {
	Status  string `json:"status"`
	Version string `json:"version"`
}

var version = "dev" // overwritten at build time

func main() {
	http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(health{Status: "ok", Version: version})
	})

	addr := ":" + os.Getenv("PORT")
	if addr == ":" {
		addr = ":8080"
	}
	log.Printf("listening on %s (version %s)", addr, version)
	log.Fatal(http.ListenAndServe(addr, nil))
}

Reading the port from the environment is not decoration. A container that hardcodes a port cannot be run twice on one host without argument, and every orchestrator expects to be able to tell a process where to listen.

The obvious Dockerfile, and why it is wrong

FROM golang:1.22
WORKDIR /src
COPY . .
RUN go build -o /app ./...
CMD ["/app"]

This works. It also ships the entire Go toolchain: compiler, standard library source, module cache — to production, in the region of 900 MB for a binary of about 8 MB. Everything beyond the binary is attack surface you are not using: a shell, a package manager, a compiler.

Worse, COPY . . before the build means any source change invalidates the layer that downloads dependencies, so every build re-downloads every module.

Multi-stage

Two stages: one that has a compiler, one that has your binary and nothing else.

# ---- build ----
FROM golang:1.22 AS build
WORKDIR /src

# dependencies first: this layer is cached until go.mod or go.sum changes
COPY go.mod go.sum ./
RUN go mod download

# then the source
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
      -trimpath \
      -ldflags="-s -w -X main.version=${VERSION}" \
      -o /out/server ./cmd/server

# ---- run ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Every line in the build stage is doing something specific.

COPY go.mod go.sum before COPY . . is the single highest-value line in the file. Docker caches layers in order, so putting the dependency download above the source copy means editing a handler does not re-download modules. Rebuilds drop from a minute to a couple of seconds.

CGO_ENABLED=0 is what makes the binary genuinely static. With cgo enabled, and it is enabled by default when a C toolchain is present, Go links against the system libc, and the resulting binary will not run on distroless/static or scratch. The failure is a bare exec /server: no such file or directory on a file that plainly exists, which is one of the more confusing error messages in the ecosystem. It means the dynamic loader is missing, not the binary.

Note that CGO_ENABLED=0 also switches net to the pure-Go DNS resolver. That is usually preferable in a container, where /etc/nsswitch.conf may not exist, but it does mean DNS behaves slightly differently from the host.

-ldflags="-s -w" strips the symbol table and DWARF debug information, typically a quarter off the binary. Do not use it if you need readable panic traces with line numbers from production.

-X main.version=${VERSION} stamps the version variable at link time, so /healthz reports which build is running. Pass it in: docker build --build-arg VERSION=$(git rev-parse --short HEAD) .

-trimpath removes local filesystem paths from the binary, which makes builds reproducible and avoids leaking your directory layout.

Build it and compare:

$ docker build -t notes-api:latest .
$ docker images notes-api
REPOSITORY   TAG      SIZE
notes-api    latest   12.4MB

Choosing the runtime base

BaseContainsUse when
gcr.io/distroless/staticCA certificates, timezone data, /etc/passwd, no shellDefault for a static Go binary
scratchNothing at allYou want the absolute minimum and will add certs yourself
alpinebusybox shell, apkYou genuinely need to exec into the container

scratch is smaller by a couple of megabytes and costs you two things you probably need:

FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]

Without the certificate bundle, every outbound HTTPS call fails with x509: certificate signed by unknown authority — and it fails only when the code first makes a request, which may be well after the container reports healthy. Without zoneinfo, time.LoadLocation("Europe/Berlin") returns an error. distroless/static includes both, which is why it is the better default.

Run as a non-root user in all three cases. distroless provides the :nonroot tag; on scratch you COPY an /etc/passwd or use the numeric form USER 65532:65532.

.dockerignore

.git
.github
Dockerfile
*.md
bin/
dist/
**/*_test.go
.env

Without this, COPY . . sends the whole working directory to the daemon as build context — including .git, which is frequently larger than the source. It also stops a stray .env becoming part of a layer, where it stays even if a later step deletes it.

Volumes, for data rather than code

A compiled binary in an image has nothing to hot-reload, so a bind mount of your source is not useful here the way it is with an interpreted language. Mount what the process needs to keep or read:

$ docker run --rm -p 8080:8080 \
    -e PORT=8080 \
    -v "$(pwd)/config:/etc/notes:ro" \
    -v notes-data:/var/lib/notes \
    notes-api:latest

A read-only bind mount for configuration, a named volume for state. :ro on anything the process should not write is worth the four characters — it turns a bug into an error.

For a development loop, keep a second Dockerfile stage on the golang image and run the compiler inside it, or simply run go run . on the host. Mounting source into a distroless image accomplishes nothing, because there is no toolchain to compile it.

A health check the orchestrator can use

distroless has no shell and no curl, so the usual HEALTHCHECK CMD curl -f ... cannot run. Either let the orchestrator probe the port over HTTP — which Kubernetes does natively and is the better answer — or compile a tiny check into the same binary:

HEALTHCHECK --interval=10s --timeout=2s --start-period=5s \
  CMD ["/server", "-healthcheck"]

with a flag in main that makes one request to localhost and exits non-zero on failure. One binary, no shell, no extra image layers.

Frequently asked questions

Why is my Go image so large?

The final stage is still the golang base, which carries the whole toolchain. Use a multi-stage build and copy only the compiled binary into a minimal runtime image.

What does exec /server: no such file or directory mean when the file exists?

The binary is dynamically linked and the image has no dynamic loader. Build with CGO_ENABLED=0 for a static binary, or use a base image with libc.

Do I need CGO_ENABLED=0?

For scratch and distroless/static, yes. Also note it switches Go to its pure-Go DNS resolver, which is generally what you want inside a container.

Why do my builds re-download all modules every time?

COPY . . comes before go mod download, so any source change invalidates the dependency layer. Copy go.mod and go.sum first and download before copying the rest.

scratch or distroless?

distroless/static unless you have a reason. It includes CA certificates, timezone data and a non-root user — the three things people forget when they choose scratch.

Why do HTTPS calls fail from my container?

No CA bundle. Copy /etc/ssl/certs/ca-certificates.crt from the build stage, or use a base that already has it.

How do I get the version into the running binary?

-ldflags="-X main.version=$VERSION" at link time, with VERSION passed as a build argument. Reading it from a file at runtime means the file has to be in the image too.

Should I use -ldflags=“-s -w”?

It saves roughly a quarter of the binary size by stripping symbols and debug data. Skip it if you need line numbers in production panics.

How do I run as a non-root user?

Use the :nonroot distroless tag, or USER 65532:65532 on scratch. Do not rely on the orchestrator’s security context alone — the image should be correct on its own.

Can I add a HEALTHCHECK to a distroless image?

Not with curl, since there is no shell or binary for it. Add a -healthcheck flag to your own binary, or let the orchestrator probe the HTTP endpoint directly.

Where should I go next?

Running this image alongside a database is Docker Compose, and running it in a cluster is Kubernetes. The DevOps guides cover the surrounding tooling, and the Golang guides cover the language this binary is written in.