Safeguard
Containers

Go and Docker: Building Secure Container Images

Go and Docker pair well because Go compiles to a static binary that fits in a tiny, near-empty image. This guide shows how to build that image securely.

Karan Patel
Platform Engineer
5 min read

Go and Docker fit together unusually well, because a Go program compiles to a single static binary that can run in an almost empty container image, which is both faster and far more secure than a typical interpreted-language image. The catch is that the naive Dockerfile throws that advantage away. This guide shows how to build a Go Docker container that is small, minimal, and hardened.

Why the Go and Docker combination is special

Most containerized apps ship a full runtime: a Python or Node image carries an interpreter, a package manager, a shell, and a base OS, all of which are attack surface. A Go binary is statically linked by default (for pure-Go code), so it does not need any of that at runtime. It can run in a scratch image containing literally nothing but your binary.

Less in the image means less to exploit, fewer CVEs to patch, and a smaller thing to pull and scan. The goal of a good Go Dockerfile is to end up with just the binary and the handful of files it genuinely needs.

Use a multi-stage build

The single most important technique is the multi-stage build. You compile in a full Go toolchain image, then copy only the resulting binary into a tiny final image. The build tools never ship to production.

# Stage 1: build
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/server

# Stage 2: minimal runtime
FROM gcr.io/distroless/static-nonroot
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

A few details are doing real work here. CGO_ENABLED=0 forces a fully static binary with no libc dependency, which is what lets it run in a near-empty image. The -ldflags="-s -w" strips debug symbols to shrink the binary. And the final image is distroless, not Ubuntu.

Choose scratch or distroless, not a full OS

For the final stage you have three sensible options:

  • scratch is completely empty. Your static binary and nothing else. Maximum minimalism, but you must manually add anything the binary needs, such as CA certificates for outbound TLS and timezone data.
  • gcr.io/distroless/static-nonroot contains CA certificates, timezone data, and a non-root user, but no shell or package manager. This is the pragmatic default for most Go services.
  • A full base like alpine or debian is only justified if you truly need a shell or additional runtime tooling, and each one adds surface and CVEs.

If you go with scratch and make outbound HTTPS calls, remember to copy the certificate bundle in, or your TLS handshakes will fail with an opaque error:

COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

Run as a non-root user

By default a container runs as root, and a root process that escapes the container is a much bigger problem than a non-root one. Distroless offers a -nonroot variant that runs as an unprivileged user out of the box. On scratch, set a numeric user explicitly:

USER 10001:10001

Using a numeric UID rather than a name means Kubernetes can enforce runAsNonRoot: true without needing to resolve a user database that a minimal image does not have.

Pin your base images by digest

FROM golang:1.22 can silently change what you get as the tag is re-pushed upstream, which means a compromised or simply different base can flow into your build without any change on your side. Pin by digest so the input to your build is fixed:

FROM golang:1.22@sha256:...   # exact, immutable

This is the same reasoning behind lockfiles for your Go modules. A reproducible build starts with reproducible inputs.

Do not skip dependency scanning

A minimal image reduces OS-level attack surface, but it does nothing about vulnerabilities in the Go modules you import. Those are compiled directly into your binary. Run govulncheck to find known vulnerabilities that your code actually reaches:

# Reports only vulnerabilities in code paths your program calls
govulncheck ./...

govulncheck is valuable because it does reachability analysis: it filters out advisories in dependencies you pull in but never call. Complement it with a broader software composition analysis pass in CI so the full dependency graph, including build-time tooling, stays monitored over time and not just at one moment.

Putting it together

A production-grade Go Docker setup looks like: multi-stage build, CGO_ENABLED=0 for a static binary, a distroless or scratch final image, a non-root numeric user, base images pinned by digest, and govulncheck plus SCA in the pipeline. Each step is small, and together they produce an image that is a few megabytes, has almost no attack surface, and stays that way.

FAQ

Why is Go especially good for small Docker images?

Go compiles to a single statically linked binary (with CGO_ENABLED=0), so it needs no interpreter, package manager, or base OS at runtime. That lets it run in a scratch or distroless image just a few megabytes in size, which is both faster and lower-risk.

Should I use scratch or distroless for a Go container?

Distroless (static-nonroot) is the pragmatic default because it includes CA certificates, timezone data, and a non-root user without a shell. Use scratch when you want absolute minimalism and are willing to add certificates and other files manually.

Do I still need to scan a minimal Go image?

Yes. A minimal base removes OS-level surface, but vulnerabilities in your imported Go modules are compiled into the binary. Run govulncheck for reachable vulnerabilities and an SCA scan for the full dependency graph.

How do I run a Go container as non-root?

Use the distroless -nonroot variant, or set a numeric user with USER 10001:10001 on scratch. A numeric UID lets Kubernetes enforce runAsNonRoot without a user database that minimal images lack.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.