Docker healthcheck marks container unhealthy because curl is missing from the slim base image
Docker healthcheck marks container unhealthy because curl is missing from the slim base image
Your container starts, serves traffic, and then flips to unhealthy after 30 seconds. Nothing in the app logs explains it. In the health log you find a one-liner: sh: 1: curl: not found, exit code 127. The app was fine the whole time — the probe was broken. Worse, if you run on Swarm the task gets killed and rescheduled on a loop, and if you use Compose depends_on: condition: service_healthy, every dependent service stays blocked.
The Docker healthcheck curl missing problem is almost always a base-image change: someone moved to debian:*-slim, alpine, or a distroless image for size and CVE count, and the HEALTHCHECK line still calls curl.
Why the probe fails in slim and distroless images
HEALTHCHECK runs inside the container's own filesystem namespace. It does not use your host's tooling. A shell-form healthcheck like HEALTHCHECK CMD curl -f http://localhost:8080/health || exit 1 is executed as /bin/sh -c "...", and sh resolves curl via PATH. If the binary isn't in the image, you get 127, which Docker records as a failed probe and, after --retries, marks the container unhealthy.
Full debian, ubuntu, and node images ship curl. Their slim variants usually don't; distroless images have no shell at all. Note that a plain docker run only *reports* unhealthy — it doesn't restart. Swarm replaces unhealthy tasks, Compose uses the status for dependency ordering [link needed].
Diagnose a Docker healthcheck curl missing failure
1. Read the health state, don't guess:
docker inspect --format '{{json .State.Health}}' <container> | jq
Look for "ExitCode": 127 and the Output field containing not found.
2. Confirm the binary is absent, using the same entrypoint context:
docker run --rm --entrypoint sh <image> -c 'command -v curl; echo $?'
Empty output plus a nonzero status confirms it.
3. List what *is* available: docker run --rm --entrypoint sh <image> -c 'command -v wget busybox nc python3'.
4. Reproduce by hand before editing anything: docker exec <container> curl -f http://localhost:8080/health. A "not found" here matches the probe failure and rules out an app problem.
Fix it: three options, ordered by preference
1. Use a tool the image already has. Alpine ships BusyBox wget; many runtimes ship a native HTTP client. For a Python image: HEALTHCHECK CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/health').status==200 else 1)". For BusyBox: HEALTHCHECK CMD wget -q --spider http://localhost:8080/health || exit 1. No image growth.
2. Install curl in a build stage or as a single layer. Only worth it if other tooling needs it:
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Pin the version and check the resulting size delta — the curl package pulls CA certificates and libcurl [link needed].
3. Ship nothing and probe from outside. Keep the container scratch-thin and let the orchestrator or a sidecar do the HTTP check. Kubernetes httpGet probes need no in-image binary; ECS and Compose support external checks too.
Whatever you pick, verify the transition: rebuild, start, and confirm docker inspect shows "Status": "healthy", then stop the app process and confirm the probe actually flips to unhealthy. A probe that never fails is not a probe.
When NOT to do this
- Don't add curl to a distroless image if you chose distroless partly to exclude a package manager and shell — install it in a builder stage and copy the binary plus its shared libraries, or use an external probe instead.
- Don't healthcheck with a heavy client on a 5-second interval across hundreds of replicas; each probe is a real process spawn and a real request. Use
--interval=30sand a cheap endpoint. - Don't treat the healthcheck as dependency ordering.
HEALTHCHECKsays "my process answers"; it doesn't guarantee migrations finished or a downstream service exists.
Checklist
- [ ] Health log read; exit code 127 confirmed, not assumed
- [ ] Missing binary verified with
command -vagainst the same image - [ ] Probe rewritten to use something already in the image, or moved outside it
- [ ] Unhealthy transition tested, not just the healthy one
- [ ]
--interval,--timeout, and--retriesreviewed for cost - [ ] Base-image tag pinned so the next slim-image bump doesn't remove your probe's tool again