What is a Scratch Image?
In Docker, the scratch image is not an image in the traditional sense — it is the empty base. When you use FROM scratch in a Dockerfile, you tell Docker: "Start with absolutely nothing." There are no files, no package manager, no shell, no libc, no users, no directories. It is a zero-byte, zero-layer starting point that represents the bare minimum a container needs to exist.
Contrast this with FROM alpine or FROM ubuntu, which ship a full root filesystem including hundreds of binaries, libraries, and configuration files. A scratch-based image contains only what you explicitly copy into it. This is the ultimate form of minimalism in container building.
A Mental Model for Scratch
Think of FROM scratch as a blank canvas. If your application is a statically compiled binary that requires no runtime dependencies — no shared libraries, no configuration files, no external data — you can place that single binary into a scratch image and run it directly. The resulting container will have exactly one file: your executable. Nothing else.
Why Scratch Images Matter
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →1. Security: Minimal Attack Surface
Every additional package, library, and utility in a container image is a potential attack vector. A scratch-based image strips away shells (/bin/sh, /bin/bash), package managers (apt, apk), and common tools like curl or wget that attackers often exploit for lateral movement or reverse shells. If an attacker compromises your application running in a scratch container, they land in an environment with no tools to escalate the attack. There is nothing to exploit because there is nothing present.
2. Image Size: Kilobytes, Not Megabytes
A Go static binary compiled with stripping and placed in a scratch image typically results in an image size of 1–8 MB. The same application on Alpine might be 15–30 MB, and on Ubuntu it could balloon to 100 MB or more. In large-scale deployments with thousands of nodes pulling images constantly, this size difference translates directly into faster deployments, reduced bandwidth costs, and lower registry storage expenses.
3. Supply Chain Clarity
When you use a distribution-based base image, your software supply chain includes everything in that distribution. With scratch, your supply chain is reduced to your compiled binary and nothing else. Vulnerability scanners will find nothing to flag because there are no known CVEs in packages that don't exist. This dramatically simplifies compliance and auditing.
4. Performance: No Overhead
Scratch containers start just as fast as any other container, but they consume zero disk space for the base layer. On a heavily loaded Docker host with hundreds of images cached, this matters. There is also no init system, no cron daemon, no background processes — just your application, running as the single process in the container's PID namespace.
How to Use Scratch Images
The Basic Pattern: Static Binary + Scratch
The canonical use case for scratch is deploying a statically compiled binary. Here is the simplest possible example — a Go application compiled without any C dependencies:
# ---- Dockerfile ----
# Stage 1: Build the static binary
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY main.go .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o myapp .
# Stage 2: Create the scratch image
FROM scratch
COPY --from=builder /app/myapp /myapp
EXPOSE 8080
ENTRYPOINT ["/myapp"]
Key details in this Dockerfile:
CGO_ENABLED=0disables C bindings, ensuring the binary links no C libraries dynamically.-ldflags="-s -w"strips debug symbols and DWARF information, reducing binary size.GOOS=linuxtargets Linux explicitly — the container will run on Linux, so cross-compilation awareness is essential.- The
COPY --from=builderdirective pulls only the compiled artifact from the build stage into the final scratch image. ENTRYPOINTuses JSON array syntax to avoid invoking a shell (which doesn't exist).
A Complete Go Application Example
Here is the main.go source that works with the Dockerfile above:
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func handler(w http.ResponseWriter, r *http.Request) {
log.Printf("Request from %s: %s %s", r.RemoteAddr, r.Method, r.URL.Path)
fmt.Fprintf(w, "Hello from a scratch container!\n")
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", handler)
log.Printf("Listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
Building and Running
# Build the image
docker build -t myapp-scratch .
# Run the container
docker run --rm -p 8080:8080 myapp-scratch
# Test it
curl http://localhost:8080
This works because the Go runtime is compiled directly into the binary. The application handles HTTP, logging, and signal processing without any external dependencies.
Non-Go Languages: Rust and Zig
The scratch pattern works with any language that can produce a true static binary. Rust is another excellent candidate:
# ---- Dockerfile for Rust ----
FROM rust:1.77-alpine AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
EXPOSE 8080
ENTRYPOINT ["/myapp"]
The critical detail is targeting x86_64-unknown-linux-musl, which links against musl libc statically. Rust binaries default to dynamic linking on many platforms; the musl target forces static linking.
Zig, C, and C++ can also produce static binaries when compiled with the right flags. The principle is universal: if ldd on your binary outputs "not a dynamic executable" or shows zero dynamic dependencies, it's ready for scratch.
Verifying Static Linking
Before committing to scratch, verify your binary is truly static:
# On Linux, check dynamic dependencies
ldd ./myapp
# A static binary will show:
# not a dynamic executable
# OR:
# statically linked
# Alternative: use file command
file ./myapp
# Look for "statically linked" in the output
If ldd lists any .so files, the binary will fail in a scratch container with an error like exec /myapp: no such file or directory — a cryptic message that actually means "the dynamic linker/loader couldn't be found."
Best Practices
1. Always Use Multi-Stage Builds
Never attempt to compile your application directly in a scratch base. Scratch has no compiler, no shell, no tools. Multi-stage builds let you compile in a rich builder environment and extract only the final artifact:
FROM golang:1.22 AS builder
# ... build steps ...
FROM scratch
COPY --from=builder /app/binary /binary
ENTRYPOINT ["/binary"]
This pattern keeps the final image minimal while giving you full development capabilities during the build.
2. Handle TLS and CA Certificates Correctly
If your application makes HTTPS requests (to external APIs, databases, or cloud services), it needs access to root CA certificates. In a scratch container, there is no /etc/ssl/certs directory. You have several options:
Option A: Copy CA certificates from the builder stage
FROM alpine:3.19 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
Your application code must then reference the certificate file explicitly. In Go, you can set environment variables or configure the TLS client:
// Go: Point to the CA bundle if using custom transport
import "crypto/x509"
// The standard library's default transport reads from well-known locations.
// On scratch, you may need to set SSL_CERT_FILE or use a custom pool.
func init() {
os.Setenv("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt")
}
Option B: Embed certificates at compile time (Go-specific)
Go 1.16+ supports embed for including files in the binary:
package main
import (
"crypto/x509"
"embed"
"io/fs"
)
//go:embed certs/*.pem
var certsFS embed.FS
func loadCerts() *x509.CertPool {
pool := x509.NewCertPool()
files, _ := fs.ReadDir(certsFS, "certs")
for _, f := range files {
data, _ := certsFS.ReadFile("certs/" + f.Name())
pool.AppendCertsFromPEM(data)
}
return pool
}
This eliminates the need for any external certificate files entirely.
3. Handle Timezone Data
If your application relies on timezone conversions (e.g., formatting dates in specific timezones), the /usr/share/zoneinfo directory doesn't exist in scratch. Solutions mirror the CA certificate problem:
FROM alpine:3.19 AS tzdata
RUN apk add --no-cache tzdata
FROM scratch
COPY --from=tzdata /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=tzdata /usr/share/zoneinfo/UTC /etc/localtime
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
Alternatively, if you only need UTC, many languages can operate in UTC mode without zoneinfo data. In Go, set TZ=UTC environment variable or use time.LoadLocation("UTC") which doesn't require zoneinfo files.
4. Ensure Proper Signal Handling (PID 1 Responsibilities)
In a scratch container, your application runs as PID 1. This comes with special responsibilities that the kernel imposes:
- PID 1 is responsible for reaping zombie processes (if your application spawns child processes).
- PID 1 cannot be killed by SIGTERM or SIGKILL if it doesn't explicitly handle signals — the kernel has special rules for PID 1.
- Signals that are normally handled by default handlers may be ignored for PID 1 unless explicitly caught.
In Go, the runtime handles most signals correctly by default, but you should explicitly listen for SIGTERM and SIGINT to enable graceful shutdown:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080"}
// Start server in goroutine
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Forced shutdown:", err)
}
log.Println("Server stopped")
}
If your application spawns child processes and you cannot guarantee their proper cleanup, consider using tini or dumb-init as PID 1:
FROM alpine:3.19 AS tools
RUN apk add --no-cache tini
FROM scratch
COPY --from=tools /sbin/tini /tini
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/tini", "--", "/myapp"]
5. Structure Your Filesystem Minimally
Scratch gives you no directories at all — not even /tmp, /var, or /etc. If your application writes to any of these locations, you must create them in the Dockerfile:
FROM scratch
# Create required directories
COPY --from=builder /app/myapp /myapp
# Use a temporary builder to create empty directories
# (scratch has no mkdir command)
FROM scratch
COPY --from=builder /app/myapp /myapp
Wait — scratch has no RUN capability. You cannot execute commands in scratch. To create directories, either:
- Have your application create directories at runtime (code-level solution).
- Copy directories from a builder stage using
COPY --from=builder /empty-dir/ /tmp/(though Docker handles this inconsistently across versions). - Use a tiny intermediate layer with
mkdirand then flatten.
The cleanest approach: design your application to create any required directories on startup. Here's a Go helper:
func ensureDir(path string) error {
return os.MkdirAll(path, 0755)
}
func main() {
ensureDir("/tmp")
ensureDir("/var/cache/myapp")
// ... rest of initialization
}
6. Use a Non-Root User When Possible
By default, processes in a scratch container run as root (UID 0) because there is no /etc/passwd or user management. To run as a non-root user, you need to construct the necessary entries. The easiest way:
FROM alpine:3.19 AS user-builder
RUN adduser -D -u 10001 appuser
FROM scratch
COPY --from=user-builder /etc/passwd /etc/passwd
COPY --from=user-builder /etc/group /etc/group
COPY --from=builder /app/myapp /myapp
USER 10001
ENTRYPOINT ["/myapp"]
This creates a user entry that Docker can reference for the USER directive. The user has no home directory and no shell, but the numeric UID exists for file permission checks.
Common Pitfalls
Pitfall 1: Dynamic Linking Surprises
Symptom: Container exits immediately with exec /myapp: no such file or directory or ./myapp: not found.
Root Cause: Your binary is dynamically linked. The error message is misleading — the file does exist, but the dynamic linker (/lib/ld-linux.so or equivalent) that the kernel needs to load the binary does not exist in the scratch filesystem.
Diagnosis:
# Check linking type
ldd ./myapp
# If you see any .so references, it's dynamic
# Check the ELF interpreter
readelf -l ./myapp | grep interpreter
# If you see something like /lib64/ld-linux-x86-64.so.2, it needs that interpreter
Fix: Recompile with static linking flags. For Go, set CGO_ENABLED=0. For C/C++, use -static. For Rust, target x86_64-unknown-linux-musl. For Zig, it's static by default.
Pitfall 2: Missing CA Certificates for HTTPS
Symptom: Application works locally but fails in the container with TLS errors like x509: certificate signed by unknown authority or tls: failed to verify certificate.
Root Cause: The scratch filesystem has no root CA bundle. Your TLS library cannot verify server certificates.
Fix: Copy CA certificates from Alpine or embed them in the binary (see Best Practice #2 above).
Pitfall 3: DNS Resolution Failures
Symptom: Application cannot resolve hostnames. Connections to external services fail with "no such host" or timeout.
Root Cause: This is usually not a scratch-specific problem but a networking configuration issue. However, scratch images lack /etc/nsswitch.conf and /etc/hosts (Docker injects /etc/hosts automatically, but nsswitch.conf may be needed for certain name resolution paths).
Fix: Copy a minimal nsswitch.conf:
FROM alpine:3.19 AS nss
RUN echo 'hosts: files dns' > /etc/nsswitch.conf
FROM scratch
COPY --from=nss /etc/nsswitch.conf /etc/nsswitch.conf
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
Most applications work fine without it, but some libc-based static binaries rely on NSS configuration for hostname lookups.
Pitfall 4: No Debugging Tools
Symptom: You cannot docker exec -it container sh to inspect a running scratch container. There is no shell, no ls, no ps, no netstat.
Consequence: Debugging production issues becomes extremely difficult. You cannot attach to the container and poke around.
Mitigation Strategies:
- Structured logging to stdout: Your application must log everything to stdout/stderr. There is no log file to inspect inside the container.
- Health check endpoints: Implement a
/healthor/debugHTTP endpoint that exposes internal state (goroutine count, memory stats, connection pools). - External monitoring: Use metrics exporters (Prometheus, OpenTelemetry) to observe the application from outside the container.
- Ephemeral debug containers: Kubernetes supports ephemeral debug containers (
kubectl debug) that inject a sidecar with debugging tools into the pod namespace. This works around the scratch limitation. - Docker debug images: Build a parallel debug version of your image based on Alpine with all tools included. Switch to it for troubleshooting.
# Debug version Dockerfile (alongside production scratch)
FROM alpine:3.19
COPY --from=builder /app/myapp /myapp
RUN apk add --no-cache curl busybox-extras tcpdump strace
ENTRYPOINT ["/myapp"]
# docker run --rm -it myapp-debug sh
Pitfall 5: File Write Assumptions
Symptom: Application crashes with permission errors or "no such file or directory" when trying to write to /tmp, /var/run, or create log files.
Root Cause: Scratch has no pre-existing writable directories. /tmp does not exist unless your application creates it.
Fix: Create directories at application startup (see Best Practice #5) or write only to locations your application explicitly creates.
Pitfall 6: Timezone and Locale Assumptions
Symptom: Application outputs timestamps in unexpected formats, or timezone conversions silently default to UTC.
Root Cause: No /usr/share/zoneinfo or /etc/localtime in scratch. Libraries fall back to UTC or crash.
Fix: Copy zoneinfo data or design your application to work in UTC-only mode (see Best Practice #3).
Pitfall 7: Ignoring PID 1 Signal Masking
Symptom: docker stop takes 10 seconds then kills the container forcefully. Application doesn't gracefully shut down.
Root Cause: PID 1 has special signal handling semantics. SIGTERM is ignored by default for PID 1 in the kernel unless the process explicitly registers a handler.
Fix: Explicitly handle SIGTERM and SIGINT in your application code (see Best Practice #4). Test with docker stop --time 30 and verify graceful shutdown behavior.
Pitfall 8: Assuming Environment Variables from Base Images
Symptom: Application behaves differently in scratch vs. Alpine, especially around locale, timezone, or library configuration.
Root Cause: Base images like Alpine set environment variables (LANG, LC_ALL, PATH) that your application may implicitly depend on. Scratch sets none.
Fix: Explicitly set any required environment variables in your Dockerfile:
FROM scratch
ENV TZ=UTC \
LANG=C.UTF-8 \
PATH=/
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
Pitfall 9: Multi-Architecture Confusion
Symptom: Image works on x86_64 but fails on ARM64 with the same "not found" error.
Root Cause: You built the binary for the wrong architecture. Scratch images inherit the platform from the build context; the binary inside must match the target kernel architecture.
Fix: Use Docker Buildx with proper cross-compilation:
# Build multi-arch scratch image
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myapp-scratch:latest \
--push .
Your build stage must cross-compile for each target platform. Go and Rust handle this well; C/C++ requires appropriate cross-compilation toolchains.
Debugging Scratch Containers: A Systematic Approach
When a scratch container fails, you lose the ability to docker exec inside it. Here is a systematic debugging workflow:
- Check container logs:
docker logs <container>— ensure your application logs to stdout/stderr. - Inspect the exit code:
docker inspect <container> | grep ExitCode— exit code 127 often means "exec format error" or missing interpreter. - Verify the binary outside the container: Copy the binary from the image and test it on a host system with
lddandfile. - Build a debug image: Create a parallel image based on Alpine with the same binary and debugging tools installed.
- Use
docker run --entrypoint: If your binary supports a--versionflag, override entrypoint to test:docker run --rm --entrypoint /myapp myapp-scratch --version.
When Not to Use Scratch
Scratch is not a universal solution. Avoid it when:
- Your application requires a runtime interpreter (Python, Node.js, Ruby, JVM languages). These cannot be compiled into static binaries easily (though GraalVM Native Image for Java is an exception).
- Your application depends on system libraries that cannot be statically linked (some C libraries with complex plugin systems).
- You need frequent interactive debugging in production (though this is a process smell, not a technical limitation).
- Your team lacks experience with static compilation and you don't have time to invest in learning it — the debugging friction may outweigh the benefits.
Conclusion
Docker scratch images represent the ultimate minimal container — a single static binary with zero runtime dependencies. They offer unparalleled security benefits through a radically reduced attack surface, deliver the smallest possible image sizes, and simplify vulnerability management by eliminating unnecessary software from your supply chain. The pattern works beautifully with Go, Rust, Zig, and statically compiled C/C++ applications.
However, scratch images demand discipline. You must handle CA certificates, timezone data, signal handling (PID 1 semantics), and directory creation explicitly in your application code or Dockerfile. The absence of a shell and debugging tools means you must invest in structured logging, health check endpoints, and external observability. The "no such file or directory" error will become your most frequent debugging companion until you internalize the static linking requirement.
Start with a working multi-stage build, verify static linking with ldd, copy CA certificates from Alpine when needed, handle SIGTERM gracefully, and always maintain a parallel debug image for troubleshooting. With these practices in place, scratch images become a powerful tool for shipping secure, lean, and fast containers in production environments.