In the world of optimized Docker containers, every megabyte matters. You’ve meticulously built your application, stuffed it into a distroless or scratch image, and then… you need a HEALTHCHECK. The default reflex is to install curl or wget, but this one command can undo all your hard work, bloating your minimal image with dozens of megabytes of dependencies like libc. This guide is for experts who need reliable Docker healthcheck tools without the bloat.
We’ll dive into *why* curl is the wrong choice for minimal images and provide production-ready, copy-paste solutions using static binaries and multi-stage builds to create truly tiny, efficient healthchecks.
Table of Contents
The Core Problem: curl vs. Distroless Images
The HEALTHCHECK Dockerfile instruction is a non-negotiable part of production-grade containers. It tells the Docker daemon (and orchestrators like Swarm or Kubernetes) if your application is actually ready and able to serve traffic. A common implementation for a web service looks like this:
# The "bloated" way
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl --fail http://localhost:8080/healthz || exit 1
This looks harmless, but it has a fatal flaw: it requires curl to be present in the final image. If you’re using a minimal base image like gcr.io/distroless/static or scratch, curl is not available. Your only option is to install it.
Analyzing the “Bloat” of Standard Tools
Why is installing curl so bad? Dependencies. curl is dynamically linked against a host of libraries, most notably libc. On an Alpine image, apk add curl pulls in libcurl, ca-certificates, and several other packages, adding 5MB+. On a Debian-based slim image, it’s even worse, potentially adding 50-100MB of dependencies you’ve tried so hard to avoid.
If you’re building from scratch, you simply *can’t* add curl without building a root filesystem, defeating the entire purpose.
Pro-Tip: The problem isn’t just size, it’s attack surface. Every extra library (like
libssl,zlib, etc.) is another potential vector for a CVE. A minimal healthcheck tool has minimal dependencies and thus a minimal attack surface.
Why Shell-Based Healthchecks Are a Trap
Some guides suggest using shell built-ins to avoid curl. For example, checking for a file:
# A weak healthcheck
HEALTHCHECK --interval=10s --timeout=1s --retries=3 \
CMD [ -f /tmp/healthy ] || exit 1
This is a trap for several reasons:
- It requires a shell: Your
scratchordistrolessimage doesn’t have/bin/sh. - It’s not a real check: This only proves a file exists. It doesn’t prove your web server is listening, responding to HTTP requests, or connected to the database.
- It requires a sidecar: Your application now has the extra job of touching this file, which complicates its logic.
Solution 1: The “Good Enough” Check (If You Have BusyBox)
If you’re using a base image that includes BusyBox (like alpine or busybox:glibc), you don’t need curl. BusyBox provides a lightweight version of wget and nc that is more than sufficient.
# Alpine-based image with BusyBox
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --spider --fail http://localhost:8080/healthz || exit 1
This is a huge improvement. wget --spider sends a HEAD request and checks the response code without downloading the body. --fail causes it to exit with a non-zero status on 4xx/5xx errors. This is a robust and tiny solution *if* BusyBox is already in your image.
But what if you’re on distroless? You have no BusyBox. You have… nothing.
Solution 2: Tiny, Static Docker Healthcheck Tools via Multi-Stage Builds
This is the definitive, production-grade solution. We will use a multi-stage Docker build to compile a tiny, statically-linked healthcheck tool and copy *only that single binary* into our final scratch image.
The best tool for the job is one you write yourself in Go, because Go excels at creating small, static, dependency-free binaries.
The Ultimate Go Healthchecker
Create a file named healthcheck.go. This simple program makes an HTTP GET request to a URL provided as an argument and exits 0 on a 2xx response or 1 on any error or non-2xx response.
// healthcheck.go
package main
import (
"fmt"
"net/http"
"os"
"time"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: healthcheck <url>")
os.Exit(1)
}
url := os.Args[1]
client := http.Client{
Timeout: 2 * time.Second, // Hard-coded 2s timeout
}
resp, err := client.Get(url)
if err != nil {
fmt.Fprintln(os.Stderr, "Error making request:", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
fmt.Println("Healthcheck passed with status:", resp.Status)
os.Exit(0)
}
fmt.Fprintln(os.Stderr, "Healthcheck failed with status:", resp.Status)
os.Exit(1)
}
The Multi-Stage Dockerfile
Now, we use a multi-stage build. The builder stage compiles our Go program. The final stage copies *only* the compiled binary.
# === Build Stage ===
FROM golang:1.21-alpine AS builder
# Set build flags to create a static, minimal binary
# -ldflags "-w -s" strips debug info
# -tags netgo -installsuffix cgo builds against Go's net library, not libc
# CGO_ENABLED=0 disables CGO, ensuring a static binary
ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64
WORKDIR /src
# Copy and build the healthcheck tool
COPY healthcheck.go .
RUN go build -ldflags="-w -s" -tags netgo -installsuffix cgo -o /healthcheck .
# === Final Stage ===
# Start from scratch for a *truly* minimal image
FROM scratch
# Copy *only* the static healthcheck binary
COPY --from=builder /healthcheck /healthcheck
# Copy your main application binary (assuming it's also static)
COPY --from=builder /path/to/your/main-app /app
# Add the HEALTHCHECK instruction
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD ["/healthcheck", "http://localhost:8080/healthz"]
# Set the main application as the entrypoint
ENTRYPOINT ["/app"]
The result? Our /healthcheck binary is likely < 5MB. Our final image contains only this binary and our main application binary. No shell, no libc, no curl, no package manager. This is the peak of container optimization and security.
Advanced Concept: The Go
net/httppackage automatically includes root CAs for TLS/SSL verification, which is why the binary isn’t just a few KBs. If you are *only* checkinghttp://localhost, you can use a more minimal TCP-only check to get an even smaller binary, but the HTTP client is safer as it validates the full application stack.
Other Tiny Tool Options
If you don’t want to write your own, you can use the same multi-stage build pattern to copy other pre-built static tools.
httping: A small tool designed to ‘ping’ an HTTP server. You can often find static builds or compile it from source in your builder stage.- BusyBox: You can copy just the
busyboxstatic binary from thebusybox:staticimage and use itswgetorncapplets.
# Example: Copying BusyBox static binary
FROM busybox:static AS tools
FROM scratch
# Copy busybox and create symlinks for its tools
COPY --from=tools /bin/busybox /bin/busybox
RUN /bin/busybox --install -s /bin
# Now you can use wget or nc!
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD ["/bin/wget", "--quiet", "--spider", "--fail", "http://localhost:8080/healthz"]
# ... your app ...
ENTRYPOINT ["/app"]
Frequently Asked Questions (FAQ)
What is the best tiny alternative to curl for Docker healthchecks?
The best alternative is a custom, statically-linked Go binary (like the example in this article) copied into a scratch or distroless image using a multi-stage build. It provides the smallest possible size and attack surface while giving you full control over the check’s logic (e.g., timeouts, accepted status codes).
Can I run a Docker healthcheck without any tools at all?
Not for checking an HTTP endpoint. The HEALTHCHECK instruction runs a command *inside* the container. If you have no shell and no binaries (like in scratch), you cannot run CMD or CMD-SHELL. The only exception is HEALTHCHECK NONE, which disables the check entirely. You *must* add a binary to perform the check.
How does Docker’s `HEALTHCHECK` relate to Kubernetes liveness/readiness probes?
They solve the same problem but at different levels.
HEALTHCHECK: This is a Docker-native feature. The Docker daemon runs this check and reports the status (healthy,unhealthy,starting). This is used by Docker Swarm anddocker-compose.- Kubernetes Probes: Kubernetes has its own probe system (
livenessProbe,readinessProbe,startupProbe). Thekubeleton the node runs these probes.
Crucially: Kubernetes does not use the Docker HEALTHCHECK status. It runs its own probes. However, the *pattern* is the same. You can configure a K8s exec probe to run the exact same /healthcheck binary you just added to your image, giving you a single, reusable healthcheck mechanism.

Conclusion
Rethinking how you implement HEALTHCHECK is a master-class in Docker optimization. While curl is a fantastic and familiar tool, it has no place in a minimal, secure, production-ready container image. By embracing multi-stage builds and tiny, static Docker healthcheck tools, you can cut megabytes of bloat, drastically reduce your attack surface, and build more robust, efficient, and secure applications. Stop installing; start compiling. Thank you for reading the DevopsRoles page!
