Docker Won't Restart an "Unhealthy" Container — Here's What Actually Will
"My container says it's unhealthy, why didn't Docker restart it?" is one of the most common points of confusion in a self-hosted Docker setup, and it comes from a reasonable assumption that turns out to be wrong. Docker's HEALTHCHECK and Compose's restart policy are two separate systems that don't automatically talk to each other. Get that wrong, and a container can sit there marked "unhealthy" for hours while your app is actually down and nothing brings it back.
Here's how the two pieces actually fit together, and the handful of lines that make a self-hosted app genuinely self-healing instead of just self-diagnosing.
The misconception, stated plainly
Per Docker's own Dockerfile reference, a HEALTHCHECK instruction runs a command inside the container on an interval and reports a status: starting, healthy, or unhealthy. That status is purely informational to the Docker daemon itself — it's visible in docker ps, and Compose's depends_on: condition: service_healthy can use it to delay starting a dependent service. But Docker does not restart a container just because its health status flips to unhealthy. The container keeps running, still reachable, still "up" in every sense Docker tracks — it just also happens to be broken.
Restart policies are a completely separate mechanism, and per Docker's restart policy documentation, they trigger only when a container's main process actually exits — not when a healthcheck fails while the process keeps running. A web server stuck serving 500 errors, or a Node process wedged in an infinite loop, never exits, so restart: unless-stopped alone never fires for it. This is exactly the gap that catches people running a single VPS with no orchestrator watching over things.
Setting up the healthcheck
In a Compose file, add a healthcheck block to the service you want monitored:
services:
app:
image: my-app:latest
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
start_period matters more than it looks: it gives the container time to boot before failed checks start counting toward "unhealthy," which avoids a slow-starting app (a Laravel app warming its cache, a database running migrations) getting flagged as broken during normal startup.
Check the result with:
docker compose ps
docker inspect --format='{{json .State.Health}}' app_container_name
Closing the actual gap: making unhealthy trigger a real restart
Since Docker Compose (outside of Swarm mode) won't restart on an unhealthy status by itself, you need something watching for it. For a single VPS, the simplest reliable option is a small companion container built for exactly this:
services:
app:
image: my-app:latest
restart: unless-stopped
labels:
- "autoheal=true"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
autoheal:
image: willfarrell/autoheal
restart: unless-stopped
environment:
AUTOHEAL_CONTAINER_LABEL: autoheal
volumes:
- /var/run/docker.sock:/var/run/docker.sock
The autoheal container watches for any container labeled autoheal=true that reports unhealthy, and restarts it directly through the Docker socket. This is the piece most self-hosted single-VPS setups are missing: the healthcheck alone only tells you something is wrong, it doesn't fix it.
If you'd rather not mount the Docker socket into another container (a reasonable security concern on a shared VPS), a simpler alternative is a cron job or systemd timer running the one-liner Docker's own documentation implies is possible with the health filter:
# crontab -e, runs every 2 minutes
*/2 * * * * docker ps --filter health=unhealthy --format "{{.Names}}" | xargs -r docker restart
The catch: a healthcheck that restarts blindly can mask a real outage
Auto-restarting on unhealthy is a mitigation, not a fix. If your database connection is actually down, or a dependency your app needs is genuinely unreachable, an autoheal loop will dutifully restart the app every few minutes forever, and you may never get a clear signal that something needs real attention. Two things are worth adding alongside auto-restart, not instead of it:
- Alerting on repeated restarts, not just on unhealthy status. A container that restarts once and recovers is fine; one that's restarted eight times in the last hour needs a human, not another restart.
- A healthcheck that tests the right thing. A check that only confirms the web server process answers on its port will report "healthy" even if the app behind it can't reach its database — write the check against an endpoint that actually exercises the dependency you care about, not just "is something listening."
Who this matters most for
This is squarely for anyone running a self-hosted app, side project, or small SaaS on a single VPS without Kubernetes or Swarm doing this automatically. If you're already on an orchestrator with built-in health-based rescheduling, you don't need the autoheal container — the orchestrator already closes this gap. For a plain docker compose up -d deployment, though, healthcheck plus restart policy plus something watching the health status is the actual minimum for "restarts itself when it breaks," not healthcheck alone.
FAQ
Does Docker Swarm handle this automatically?
Yes — in Swarm mode, an unhealthy task is replaced automatically as part of the service's reconciliation loop. Plain docker compose (not run as a Swarm stack) does not have this behavior, which is the gap this guide addresses.
Why not just set restart: always instead of dealing with healthchecks?
restart: always only helps when the container's process actually crashes and exits. It does nothing for a process that's still running but stuck or broken — the exact case a healthcheck is designed to catch.
Is mounting the Docker socket into the autoheal container a security risk?
Yes, meaningfully — anything with access to /var/run/docker.sock can control any container on the host, not just the one it's meant to watch. On a VPS running only your own trusted services this is a reasonable tradeoff; on a shared or multi-tenant host, prefer the cron-based approach instead.
How do I stop a healthcheck from flagging a slow-starting app as unhealthy?
Increase start_period to comfortably exceed your app's real startup time. Failed checks during the start period don't count toward the unhealthy threshold, but checks after it do.
Can I add a healthcheck to a container I don't control the image for?
Yes — the healthcheck block in Compose overrides any HEALTHCHECK baked into the image, so you can add or replace a healthcheck for third-party images (a database, a queue, a proxy) without rebuilding them.
Bottom line
A healthcheck without something acting on it is a monitor, not a fix. For a solo-run VPS, the combination that actually delivers "restarts itself when broken" is a healthcheck that tests something real, a restart policy for actual crashes, and either an autoheal container or a cron job watching for the unhealthy status in between — plus alerting on repeated restarts, so a real outage doesn't just quietly loop forever.
Sources: Docker Dockerfile reference — HEALTHCHECK; Docker documentation — Start containers automatically; Docker Compose file reference — services. Verified against Docker's official documentation on August 26, 2026.
Comments 0
Be the first to comment.
Leave a comment