Understanding Docker Scratch Images
A Docker scratch image is the most minimal base image possible — it is completely empty.
Unlike alpine, ubuntu, or even busybox, the scratch “image” contains
no files, no package manager, no shell, no libraries, and not even a minimal root filesystem.
It is essentially a blank layer that tells Docker to start from nothing. When you use FROM scratch
in a Dockerfile, you are committing to supply every single file your application needs at runtime.
Because of its emptiness, scratch is not an image you can pull from a registry — it is a reserved,
virtual name that represents the lowest possible foundation. It works exclusively with statically compiled
binaries that do not depend on dynamic libraries. In practice, languages like Go, Rust, and C (when compiled
with musl and static flags) produce executables that can run directly on scratch.
Why Scratch Images Matter for Production
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Production environments demand security, small attack surfaces, and fast deployment. Scratch images deliver on all three:
- Zero OS-level CVEs: Since there are no packages, libraries, or shells, vulnerability scanners find no operating system vulnerabilities inside the image. This drastically reduces security noise and the need to patch base images.
- Minimal attack surface: An attacker who compromises the container gains no shell, no tools, and no package manager to pivot or download additional payloads. This aligns perfectly with the principle of least privilege.
- Tiny image size: A typical scratch-based Go application image weighs only a few megabytes, compared to hundreds for traditional distributions. This leads to faster pulls, quicker cold starts, and lower storage costs.
- Forced separation of concerns: Because the runtime image has no build tools, you must adopt multi-stage builds. This naturally enforces clean build-time vs. runtime-time separation.
However, this minimalism comes with real tradeoffs. Without a shell, you cannot docker exec into a
running container for debugging. There are no CA certificates by default, so TLS connections will fail.
Timezone data, DNS resolution helpers, and user/group databases are also absent. Production use requires
you to consciously address each of these gaps.
Building a Production-Ready Scratch Image: A Step-by-Step Guide
Let's walk through creating a secure, production-grade Docker image from scratch for a typical Go
microservice. The same principles apply to Rust, C, or any language that can produce a truly static binary.
Step 1: Create a Statically Compiled Binary
The binary must not rely on any dynamic linker or shared libraries. In Go, a static binary is produced by
disabling CGO and setting the target operating system:
# Build a static, Linux-amd64 binary
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o myapp .
For Rust, you might use the musl target: rustup target add x86_64-unknown-linux-musl and build with
that target. For C, you can link against musl and use -static. The key is to verify the binary is
completely static by running ldd myapp on a Linux system — you should see “not a dynamic executable”
or a message indicating no shared libraries.
Step 2: Write a Multi-Stage Dockerfile
Use a build stage to compile your application and a final scratch stage to copy only the necessary
artifacts. Here’s a minimal example:
# ---- Build Stage ----
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp .
# ---- Runtime Stage ----
FROM scratch
# Copy the static binary
COPY --from=builder /app/myapp /myapp
# Optionally copy additional files (CA certs, zoneinfo) – covered later
EXPOSE 8080
ENTRYPOINT ["/myapp"]
CMD ["serve"]
This Dockerfile produces an image that contains exactly one file: /myapp. The binary must handle all
configuration via arguments, environment variables, or embedded defaults.
Step 3: Handle TLS and Certificates
One of the most common surprises when moving to scratch is that HTTPS requests fail with
“x509: certificate signed by unknown authority”. The runtime has no root CA certificates. To fix this,
copy the CA bundle from a trusted intermediate image:
# ---- Build Stage (same as before) ----
FROM golang:1.21-alpine AS builder
# ... build steps ...
# ---- Certificates Intermediate Stage ----
FROM alpine:latest AS certs
RUN apk add --no-cache ca-certificates
# ---- Final Scratch Stage ----
FROM scratch
COPY --from=builder /app/myapp /myapp
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/myapp"]
CMD ["serve"]
Most TLS libraries (including Go’s crypto/tls) look for the CA file at well-known paths like
/etc/ssl/certs/ca-certificates.crt. Adjust the destination path according to your application’s
TLS library. For Alpine’s CA bundle, the standard path is as shown above.
Step 4: Include Essential Files (Time Zones, DNS Config)
Beyond TLS, other missing OS data can cause subtle failures:
-
Timezone data: If your app formats times in local timezones, it needs the timezone database.
Copy
/usr/share/zoneinfofrom an Alpine stage. You can also copy only a single relevant file like/etc/localtimeif you only need UTC or one fixed zone. -
DNS resolution helpers: Go’s net package uses
/etc/nsswitch.confto decide resolver behavior (e.g., host file vs. DNS order). Without it, DNS might not work correctly in some environments. Copy a minimalnsswitch.conffrom Alpine or create one with justhosts: dns. -
User/group database: If you use
USERwith a non-numeric name (like “nobody”), the container runtime needs/etc/passwdand/etc/groupfiles. Forscratch, it’s safer to use numeric UID/GID directly (e.g.,USER 1000:1000), which requires no extra files.
A common practice is to copy the whole /etc/ssl, /usr/share/zoneinfo, and a minimal
/etc/nsswitch.conf from a lightweight intermediate image. Here’s how to combine them:
FROM alpine:latest AS os-files
RUN apk add --no-cache ca-certificates tzdata
# Create minimal nsswitch.conf
RUN echo "hosts: dns" > /etc/nsswitch.minimal
FROM scratch
COPY --from=builder /app/myapp /myapp
COPY --from=os-files /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=os-files /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=os-files /etc/nsswitch.minimal /etc/nsswitch.conf
USER 1000:1000
EXPOSE 8080
ENTRYPOINT ["/myapp"]
CMD ["serve"]
Step 5: Set User, Entrypoint, and Default CMD
Since scratch has no adduser or useradd, you must define the user numerically.
Using a high-numbered user like 1000 avoids clashing with well-known system UIDs.
The ENTRYPOINT and CMD are critical — they define what runs when the container starts.
Prefer the “exec form” (JSON array) to avoid shell interpretation.
# Inside the final FROM scratch stage
USER 1000:1000
ENTRYPOINT ["/myapp"]
CMD ["serve"]
Step 6: Build and Run the Image
Build the image as usual and run it with port mapping:
docker build -t myapp:prod .
docker run -d -p 8080:8080 --name myapp-instance myapp:prod
Verify the container is running and check that TLS, timezone, and DNS-dependent features work correctly.
Because there is no shell, you cannot docker exec into it. Instead, use your application’s health
endpoints, logs, or debugging tools that attach from the host (e.g., strace, gdb, or
docker inspect).
Best Practices for Scratch Images in Production
- Always use multi-stage builds. Keep the build environment completely separate from the runtime. Never include compilers, build tools, or package managers in the final image.
-
Prefer statically compiled languages. Go and Rust are natural fits. For Python or Node.js,
scratchis impractical because they require a runtime interpreter. Usedistrolessas an alternative if you need glibc or a minimal environment. - Copy only what’s necessary. Audit every file you add to the image. Include CA certificates only if you make outbound HTTPS calls. Include timezone data only if you format times. Over-copying dilutes the benefits of a minimal image.
-
Use numeric user IDs. Avoid
USER nobody— it requires/etc/passwd. Instead, useUSER 1000:1000(or another high-numbered UID) to keep the image clean. -
Handle health checks intelligently. Docker’s
HEALTHCHECKinstruction typically needs a shell or external tool. Forscratch, you can implement an internal health endpoint and have the orchestrator (Kubernetes, Docker Swarm) probe it, or embed a tiny static binary likebusyboxsolely for health checks. Alternatively, skipHEALTHCHECKin Dockerfile and rely on external probes. - Tag images with unique versions. Use semantic versioning and immutable tags for production deployments. This ensures repeatable builds and easy rollbacks.
- Scan your application dependencies. While the OS is empty, your binary may still have vulnerabilities in its Go modules or Rust crates. Regularly run vulnerability scanners against your application layer.
-
Plan for debugging. Create a separate “debug” image that includes a shell,
busybox, and debugging tools. Build it from the same base binary but add a debugging layer. Use it only in development or when troubleshooting, never in production. -
Use
COPYinstead ofADD.COPYis explicit and does not automatically unpack tar archives, reducing surprises. - Test thoroughly. Validate TLS connectivity, DNS resolution, timezone-aware code, and file permission boundaries before promoting to production.
Common Pitfalls and How to Avoid Them
Even experienced developers hit stumbling blocks with scratch. Here are the most frequent issues
and their remedies:
-
“exec /myapp: no such file or directory” error: This occurs when the binary is not
statically compiled and the dynamic linker is missing. Fix it by ensuring true static linking.
Use
lddto confirm zero shared library dependencies. - TLS certificate errors: As described, copy the CA bundle from a trusted intermediate stage. If you forget this step, all HTTPS outbound calls will fail.
-
DNS resolution failures: Without
/etc/nsswitch.conf, Go’s resolver may behave unexpectedly. Always provide a minimal configuration file withhosts: dns. -
Time displayed as UTC despite configuration: The timezone database is missing.
Copy
/usr/share/zoneinfoand optionally set/etc/localtimeas a symlink or file to the desired zone. -
Permission denied for non-root user: When you set
USER 1000, ensure the binary and all copied files are readable and executable by that user. Usechmodduring the build stage if necessary. Also ensure any directories your app writes to (like a volume mount) are owned by the same UID. -
Cannot
docker execinto container: This is by design — there is no shell. Use alternative debugging methods such as attaching withstracefrom the host, inspecting container logs, or temporarily deploying a debug image.
Conclusion
Docker scratch images are the pinnacle of minimalism in containerized deployments. They strip away everything except your application, resulting in tiny, fast, and exceptionally secure runtime artifacts. However, this power comes with the responsibility to explicitly supply every dependency your app needs — from CA certificates and timezone data to DNS configuration and user IDs. By following the multi-stage build pattern, copying only essential files from lightweight intermediates, and rigorously testing for common pitfalls, you can confidently run scratch-based containers in production. The result is a lean, focused runtime that aligns perfectly with modern microservice principles and significantly reduces your operational overhead.