Minimal Images
scratch is empty and distroless is nearly empty, and both still need CA certificates, timezone data and a non-root user.
FROM gcr.io/distroless/static-debian13:nonrootOnce the binary is static, the image around it can be almost nothing. Small images pull faster and contain fewer programs for a security scanner to object to or an intruder to use. There are two common choices.
scratch
FROM scratch is the empty image: no files at all. You copy in the binary and that is the entire filesystem. Nothing is provided for you, so anything the program reads from disk has to be copied in as well.
distroless
Google's distroless images have no shell and no package manager, but do carry the few files a normal program expects. The static image, gcr.io/distroless/static-debian13, documents its contents as ca-certificates, an /etc/passwd entry for a root user, a /tmp directory, and tzdata. Its :nonroot tag runs as the user nonroot, which is UID and GID 65532.
What a tiny image still needs
- CA certificates. Verifying an HTTPS server means checking its certificate against a bundle of trusted authorities. With no bundle, every outbound HTTPS call fails verification with an
x509error. Onscratch, copy/etc/ssl/certs/ca-certificates.crtout of the build stage. - Timezone data.
time.LoadLocation("Europe/Berlin")needs zoneinfo files. Importingtime/tzdataembeds a copy of the database in the binary, for about 450 KB, and-tags timetzdatadoes the same without touching the source. - A user. Run as somebody other than root. On
scratchthere is no/etc/passwdto look a name up in, soUSERmust be numeric:USER 65532:65532, notUSER nonroot.
With no shell, kubectl exec -- sh cannot work and neither can a shell-form health check. The :debug and :debug-nonroot tags add a busybox shell for when you need to look around.
Example
// Command when formats a fixed instant in a named zone.
// It works in an empty image because the zone database travels in the binary.
package main
import (
"fmt"
"time"
_ "time/tzdata" // embedded copy of the timezone database, about 450 KB
)
func main() {
berlin, err := time.LoadLocation("Europe/Berlin")
if err != nil {
// Without tzdata, in the image or in the binary, this is where it fails.
fmt.Println("no timezone data:", err)
return
}
start := time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
fmt.Println(start.In(berlin).Format(time.RFC3339))
}
// Output:
// 2026-03-01T13:00:00+01:00When to use it
- Cut a service image to a couple of megabytes without breaking its HTTPS calls.
- Satisfy a policy that containers must not run as root and must have no shell.
- Fix "x509: certificate signed by unknown authority" from a service that works fine locally.
More examples
scratch, with the three things it lacks
Each COPY line answers a failure you would otherwise meet in production: certificate verification errors on every outbound call, an unknown time zone error, and a container running as root. Pick scratch when you want to be able to list every file in the image from memory.
FROM golang:1.27 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app
FROM scratch
# scratch has no files. HTTPS needs the trusted certificate bundle.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Only needed if the program does not import time/tzdata itself.
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/app /app
# There is no /etc/passwd here, so the user must be a number.
USER 65532:65532
ENTRYPOINT ["/app"]distroless, which brings those files with it
Two megabytes or so, still no shell, and the files most services miss on scratch are already there. This is the safer default for a service that makes HTTPS calls or formats times in a named zone. Note it is static-debian13: the base-debian13 image adds glibc, which you only want if the binary was built with cgo.
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app
# Already contains ca-certificates, tzdata, /tmp and an /etc/passwd entry.
# The nonroot tag runs as UID 65532, so no USER line is strictly needed.
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]Kubernetes asking for the same guarantees
runAsNonRoot makes the kubelet check the image's user before starting it, and it cannot verify a name, only a number. readOnlyRootFilesystem is easy with a Go binary that writes nothing, but anything using os.CreateTemp needs a writable /tmp mounted in, which is what the emptyDir volume is for.
securityContext:
runAsNonRoot: true # refuse to start if the image would run as root
runAsUser: 65532
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp # readOnlyRootFilesystem leaves nowhere to write
mountPath: /tmp
# ...
volumes:
- name: tmp
emptyDir: {}
Discussion