DEV Community

Cover image for Stop Shipping Your OS. Ship Only What Runs.
Olamide Adebayo
Olamide Adebayo

Posted on

Stop Shipping Your OS. Ship Only What Runs.

A practical guide to Docker's scratch base image — what it actually is, why it matters for production Go services, and every hidden pitfall that will catch you out.


92% of IT professionals now ship software in containers. The most common source of container vulnerabilities is not your application code — it is the bloated base image sitting underneath it, full of binaries you never asked for and will never use.

FROM scratch is Docker's answer to that problem. This article walks through what it actually means, when to use it, and the four things that will silently break your production service if you do not account for them upfront.


What actually is FROM scratch?

Every Docker image starts from something. ubuntu:22.04 gives you a full Linux userland. alpine:3.19 gives you a stripped-down but still functional shell environment. scratch gives you absolutely nothing.

That is not a metaphor. scratch is a reserved, empty image in Docker. There is no filesystem layer. No shell. No libc. No /bin, no /etc, no /tmp. The only thing inside your final container is exactly what you explicitly copy into it.

Go is uniquely positioned to exploit this. Unlike Node.js or Python, a Go binary compiled with CGO_ENABLED=0 is completely self-contained. It links statically, carries its own runtime, and needs no interpreter. You can drop it into an empty container and it runs.


The size comparison that makes the case

Base image Approximate size
golang:1.23 (builder) ~630 MB
ubuntu:22.04 ~77 MB
alpine:3.19 ~7 MB
gcr.io/distroless/static ~2 MB
scratch (your binary only) ~4–8 MB

Smaller images mean faster pulls in CI, reduced cloud egress costs, quicker cold starts in serverless environments, and less pressure on your container registry. Over hundreds of deployments a week, these savings compound noticeably.


The pitfalls nobody warns you about

The appeal of scratch is obvious. It also breaks several silent assumptions your application makes at runtime. Here are the four that will catch you out.

1. TLS certificates

The error: x509: certificate signed by unknown authority

Your Go binary trusts HTTPS endpoints by delegating to the OS certificate store at /etc/ssl/certs. A scratch image has no such directory. The moment your service makes an outbound HTTPS call — to a payment provider, an AWS service, or any external API — it will fail with this error in production while working perfectly in your local environment.

The fix is to copy the CA bundle from your builder stage into the scratch image at the correct path. Once it exists there, Go's TLS stack finds it and everything behaves as expected.

2. Timezone data

The error: panic: time: missing Location in call to Date

Go's time.LoadLocation("Europe/London") looks for the IANA timezone database on disk under /usr/share/zoneinfo. That directory does not exist in scratch. Any application that processes timestamps, schedules jobs, or converts between timezones will panic on startup or at the first timezone operation it attempts.

You have two options: copy the zoneinfo directory from your builder, or embed timezone data directly into the binary using Go's time/tzdata package, which removes the filesystem dependency entirely.

3. Running as root

The problem: Container runs as UID 0 by default.

Without a USER directive, Docker containers run as root. In a standard image this is a one-liner fix. In a scratch image, you cannot create users at build time because there is no adduser or useradd binary. The non-root user must be created in the builder stage, and the relevant /etc/passwd and /etc/group files copied across. A container breakout on a root-running container means the attacker has root on the host node.

4. Dynamic linking and CGO

The error: standard_init_linux.go:211: exec user process caused "no such file or directory"

If your Go code uses CGO — common with drivers like sqlite3 or packages with C bindings — the compiled binary will look for shared libraries like glibc at runtime. Scratch has none of them. The Linux kernel refuses to execute the binary before it even starts. You must build with CGO_ENABLED=0 to produce a fully static binary. Some CGO-dependent packages will need replacing with pure-Go equivalents.


The complete, production-ready Dockerfile

This single pattern addresses all four pitfalls in one pass.

# ── Stage 1: Builder ─────────────────────────────────────────
FROM golang:1.23-alpine AS builder

# CA certs + timezone data — critical for scratch targets
RUN apk add --no-cache ca-certificates tzdata && update-ca-certificates

# Create a non-root user (scratch has no adduser binary)
ENV USER=appuser UID=10001
RUN adduser \
    --disabled-password --gecos "" \
    --home "/nonexistent" --shell "/sbin/nologin" \
    --no-create-home --uid "${UID}" "${USER}"

WORKDIR /app
COPY . .

# CGO_ENABLED=0 produces a fully static binary — non-negotiable for scratch
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o myapp .

# ── Stage 2: The scratch image ────────────────────────────────
FROM scratch

# User identity from builder
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group

# TLS certificate chain
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Timezone database
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo

# The binary
COPY --from=builder /app/myapp /myapp

# Drop privileges
USER appuser:appuser

ENTRYPOINT ["/myapp"]
Enter fullscreen mode Exit fullscreen mode

One detail worth noting: -ldflags="-s -w" strips debug symbols and DWARF information from the binary, cutting another 20–30% off the final image size with no impact on runtime behaviour. Always include it for production builds targeting scratch.


When scratch is the right choice — and when it is not

Scratch is the right choice when you are deploying a statically linked program you fully control. Pure Go APIs, gRPC services, background workers, and CLI tools are natural fits.

It becomes the wrong choice the moment you need a debugging workflow inside the container. There is no shell, no ls, no curl, no cat. You cannot exec into a scratch container and look around. If your production incident response involves running commands inside a live container, scratch will frustrate you more than it saves.

The rational middle ground: Google's Distroless images sit between Alpine and scratch. They include libc, CA certs, and timezone data but strip the shell and package manager entirely. For teams that occasionally need to exec into containers for diagnostics, Distroless gives most of the security benefit with significantly less build ceremony.

For services at scale where every megabyte and every CVE matters, scratch remains the gold standard.


The security argument, properly framed

The conventional pitch for scratch focuses on image size. The more important argument is attack surface.

A container with no shell cannot be hijacked via shell injection. A container with no package manager cannot have dependencies tampered with at runtime. A container running as a non-root user limits the blast radius of any container escape vulnerability.

Container escapes are rare but not theoretical. When one occurs, the attacker's capability is determined by what exists inside the container. A scratch-based service gives them a single static binary and nothing else.

Consider also supply chain exposure. Every package in your base image is a potential vulnerability. Ubuntu images routinely carry 200+ installed packages, most of them irrelevant to your application. Each one must be patched, monitored, and accounted for in your CVE scanning. Scratch eliminates that debt entirely.


TL;DR

Use a multi-stage Dockerfile. Build with CGO_ENABLED=0. Copy your CA certs, timezone data, and a non-root user definition into the scratch stage. Everything else stays in the builder. The result is a container that carries only your application, runs unprivileged, and gives an attacker essentially nothing to work with.

For Go services, there is very little reason not to do this.


Tags: #docker #go #devops #security

Top comments (0)