Minimal Images

scratch is empty and distroless is nearly empty, and both still need CA certificates, timezone data and a non-root user.

SyntaxFROM gcr.io/distroless/static-debian13:nonroot

Once 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 x509 error. On scratch, copy /etc/ssl/certs/ca-certificates.crt out of the build stage.
  • Timezone data. time.LoadLocation("Europe/Berlin") needs zoneinfo files. Importing time/tzdata embeds a copy of the database in the binary, for about 450 KB, and -tags timetzdata does the same without touching the source.
  • A user. Run as somebody other than root. On scratch there is no /etc/passwd to look a name up in, so USER must be numeric: USER 65532:65532, not USER 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

Example · go
// 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:00

When 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.

Example · dockerfile
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.

Example · dockerfile
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.

Example · yaml
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

  • Be the first to comment on this lesson.