Docker Healthcheck Always Showing Unhealthy
A container marked "unhealthy" by Docker despite the application clearly working when tested manually almost always means the healthcheck command itself is failing for reasons unrelated to whether the app is actually functioning — often a tooling or timing issue inside the healthcheck definition itself.
The Problem
docker ps shows the container's status as unhealthy, even though manually curling the application from inside or outside the container returns a normal response:
CONTAINER ID STATUS
a1b2c3d4e5f6 Up 5 minutes (unhealthy)
The application logs show no errors, and requests work fine — only Docker's own healthcheck reports a failure.
Why It Happens
The healthcheck runs as a completely separate command inside the container, evaluated on its own schedule, independent of whether the application is genuinely responding to real traffic. Common causes of a false "unhealthy" status:
- The healthcheck command relies on a tool (like
curlorwget) that isn't actually installed inside the container's base image, especially common with minimal images like Alpine, where these tools aren't included by default - The healthcheck checks a URL or port using
localhost, but the application only binds to a specific interface or takes time to start listening, causing early checks to fail before the app is actually ready - The
start_periodisn't set or is too short for an application with a genuinely slow startup, so Docker marks it unhealthy during normal initialization before the app has had a chance to become ready - The healthcheck command exits with a non-zero status for a reason unrelated to the app's actual health — for example, checking an endpoint that requires authentication the healthcheck doesn't provide, always returning a 401 that Docker interprets as failure
The Fix
First, confirm the tool used in your healthcheck command actually exists inside the image. Exec into the running container and try running the exact command manually:
docker exec -it your-container sh
curl -f http://localhost:3000/health
If this fails with "command not found," the healthcheck was doomed regardless of the application's actual health — install the tool, or switch to one that's already present. Alpine-based images often lack curl by default; wget is sometimes available where curl isn't, or you can install curl explicitly:
RUN apk add --no-cache curl
Confirm your healthcheck's timing settings account for how long the application genuinely takes to start. Add or increase start_period, which gives the container a grace period during which failures don't count against the health status:
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
Make sure the healthcheck endpoint itself doesn't require authentication or return anything other than a success status code under normal conditions — a dedicated, unauthenticated /health endpoint that simply confirms the app process is alive and can reach its own dependencies (like the database) is the standard pattern, rather than reusing a real application endpoint that has its own unrelated requirements.
If using docker-compose.yml instead of a Dockerfile-level healthcheck, the same options apply through the compose syntax:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
start_period: 40s
retries: 3
Still Not Working?
If the exact healthcheck command succeeds when run manually inside the container but Docker still reports unhealthy, check whether the command's exit code is genuinely 0 on success — some tools print success-looking output but still exit with a non-zero status under specific conditions (like curl succeeding at connecting but the endpoint itself returning a 4xx/5xx status, which curl alone doesn't treat as failure unless the -f flag is explicitly included to make it fail on HTTP error responses).
It's also worth checking the healthcheck's history directly rather than only the current status, since a container can flip between healthy and unhealthy over time in a way that a single snapshot doesn't reveal:
docker inspect --format='{{json .State.Health}}' your-container | jq
This shows the recent history of healthcheck results, including the actual output and exit code from each attempt — often revealing an intermittent failure pattern (like occasional timeouts under load) rather than a constant one, which points toward adjusting the timeout value rather than the command itself.
Finally, if the application depends on another service (a database, a cache) being ready before it can respond successfully to its own healthcheck, confirm that dependency's own startup time isn't the actual bottleneck. A healthcheck failing because the app itself is waiting on a slow-starting database looks identical to a broken healthcheck command, but the real fix in that case is adjusting startup ordering and dependency healthchecks, not the application's own health endpoint.