Containerising It
Compile in one stage, ship the static binary in a distroless image, and read every setting from the environment.
CGO_ENABLED=0 go build -trimpath -o /out/api ./cmd/api # then COPY --from=build /out/api /apiA container image is a program plus the files it needs, packed as one unit so any machine can run it. A Go build produces a single executable, so the image can be almost nothing else. Two decisions make that work: a multi-stage build, and configuration read from the environment.
Two stages
A multi-stage build is one Dockerfile with more than one FROM line. The first stage starts from golang:1.27, which carries the compiler, and builds the binary. The second starts from a tiny base image and copies only that binary across. Build with CGO_ENABLED=0 so the binary is static, meaning it needs no C library at run time. A static binary can then live in gcr.io/distroless/static-debian13, an image with no shell and no package manager. Source, compiler and module cache never ship.
Configuration from the environment
The same image has to run on a laptop and in production, so nothing environment-specific can be compiled in. An environment variable is a named value handed to the process when it starts. Read every setting from one, once, at startup, into a config struct. If a required value is missing, print which one and exit non-zero. A failure at startup is visible in the deploy; the same failure on the first request is an outage. os.Getenv returns an empty string for both unset and empty, while os.LookupEnv also returns a boolean saying which it was.
What trips people
Passing a secret with --build-arg or ENV stores it in the image layers, where anyone who can pull the image can read it. Secrets arrive at run time, from the environment.
Example
// Command notes reads every setting from the environment, which is what
// lets one image run unchanged in every environment.
// In production: PORT=8080 DATABASE_URL=postgres://... ./api
package main
import (
"errors"
"fmt"
"os"
"time"
)
// config is the whole surface between the image and the cluster.
type config struct {
port string
databaseURL string
readTimeout time.Duration
version string
}
// version is set at link time: -ldflags "-X main.version=$GIT_SHA".
var version = "dev"
// load takes the lookup function instead of calling os.Getenv itself, so a
// test can hand it a fixed set of values.
func load(getenv func(key string) string) (config, error) {
get := func(key, fallback string) string {
if v := getenv(key); v != "" {
return v
}
return fallback
}
c := config{
port: get("PORT", "8080"),
databaseURL: getenv("DATABASE_URL"),
version: version,
}
d, err := time.ParseDuration(get("READ_TIMEOUT", "5s"))
if err != nil {
return config{}, fmt.Errorf("READ_TIMEOUT: %w", err)
}
c.readTimeout = d
if c.databaseURL == "" {
// Fail here, not at the first request.
return config{}, errors.New("DATABASE_URL is required")
}
return c, nil
}
func main() {
// The real service passes os.Getenv. A fixed map here, so the output is
// the same on every machine.
env := map[string]string{"DATABASE_URL": "postgres://db:5432/notes"}
c, err := load(func(key string) string { return env[key] })
if err != nil {
fmt.Fprintln(os.Stderr, "config:", err)
os.Exit(1)
}
fmt.Println("version:", c.version)
fmt.Println("port:", c.port)
fmt.Println("read timeout:", c.readTimeout)
}
// Output:
// version: dev
// port: 8080
// read timeout: 5sWhen to use it
- Ship a 12MB image containing one binary, with no shell for an attacker to use.
- Run the same image in development, staging and production with only the environment variables differing.
- Have the running service report the exact commit it was built from, through a version linked in at build time.
More examples
The Dockerfile
Two stages, and nothing from the first one reaches the second except the binary: no compiler, no source, no module cache. COPY --from=build names the stage, and COPY --from=0 by position means the same thing. The result is a few megabytes, with no shell and no package manager for an intruder or a scanner to find. Use this Dockerfile shape for every Go service; the only lines that usually change are the build path and the exposed port.
# syntax=docker/dockerfile:1
FROM golang:1.27 AS build
WORKDIR /src
# Dependencies first: editing a .go file then reuses this cached layer.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
# CGO_ENABLED=0 makes the binary static, so it needs no C library at run
# time. -trimpath keeps build paths out of it. -X sets a variable at link
# time, which is how the running service can report its own version.
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/api /api
# The numeric form of the nonroot user in that image. Kubernetes cannot
# resolve a name to a number, so give it the number.
USER 65532:65532
# ENV PORT=8080 and the older "ENV PORT 8080" set the same variable; the
# form with = is the one to write.
ENV PORT=8080
EXPOSE 8080
# Exec form, a JSON array. The shell form would need a /bin/sh to parse it
# and this image has none.
ENTRYPOINT ["/api"]Building and tagging it
The image is tagged with the commit it was built from, never only with latest. A tag that moves makes two problems impossible to solve: you cannot tell which code a pod is running, and a rollback has no earlier name to go back to. VERSION is passed as a build argument and linked into the binary, so the running service can report the same string the tag carries. Do this in CI, where the commit is already known.
# Keep the build context small, and secrets out of it entirely.
printf '.git\n.env\nbin/\n*.md\n' > .dockerignore
SHA=$(git rev-parse --short HEAD)
# --platform matters: an Apple-silicon machine builds arm64 by default and
# the cluster runs amd64.
docker build --platform linux/amd64 \
--build-arg VERSION="$SHA" \
-t registry.example.com/notes:"$SHA" .
docker push registry.example.com/notes:"$SHA"
# How small did it get?
docker image ls registry.example.com/notes
# REPOSITORY TAG SIZE
# registry.example.com/notes 9f3c1ab 12.4MBRunning it locally the way the cluster will
Most container surprises are the difference between how you ran it and how the cluster runs it. Three flags remove that difference: --read-only catches any code that writes to disk, --user catches a program that needs root, and -e proves the config loader really does read everything from the environment. Run this once before the first deploy and again whenever the Dockerfile changes. The last command is worth trying once, so the missing shell is a thing you already know rather than a surprise during an incident.
# Read-only root filesystem, non-root user, settings from the environment.
# If it starts here, it will start in the cluster.
docker run --rm --name notes \
--read-only --tmpfs /tmp \
--user 65532:65532 \
-p 8080:8080 \
-e PORT=8080 \
-e DATABASE_URL="postgres://app:[email protected]:5432/notes" \
registry.example.com/notes:"$SHA"
curl -i localhost:8080/healthz
# A missing setting fails immediately, with the name of what is missing.
docker run --rm registry.example.com/notes:"$SHA"
# config: DATABASE_URL is required
# There is no shell in the image, so this cannot work. Read the logs instead.
docker exec -it notes sh
# exec: "sh": executable file not found in $PATH
Discussion