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
| Base | Contains | Use when |
|---|---|---|
gcr.io/distroless/static | CA certificates, timezone data, /etc/passwd, no shell | Default for a static Go binary |
scratch | Nothing at all | You want the absolute minimum and will add certs yourself |
alpine | busybox shell, apk | You 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.