The “how small can you make a Go Docker image” contest ends at FROM scratch, which gives you a few MB of statically linked Go binary and nothing else. In practice you usually want at least three extra things: CA certificates, the tzdata blob so time.LoadLocation works, and a non-root user. Copying them from a distro stage is cheap.

# syntax=docker/dockerfile:1.7

FROM golang:1.23-alpine AS build
WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-trimpath
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    go build -ldflags="-s -w" -o /out/app ./cmd/app

FROM alpine:3.20 AS certs
RUN apk --no-cache add ca-certificates tzdata

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

USER 65534:65534 is nobody:nogroup on most distros. CGO_ENABLED=0 keeps the binary static so it actually runs on scratch. The --mount=type=cache bits make rebuilds fast in CI without bloating the final image.

See also /posts/admission-webhook-crash-loop/.