Skip to main content
← All articles
engineering

Your healthcheck is not monitoring

Your healthcheck is not monitoring

There's a comforting green "healthy" next to a lot of people's containers that means far less than they think. On my own Coolify box I had to sit with an uncomfortable question: if Drupal's database fell over right now, would my container's healthcheck even notice? For a naive check, the answer is no — it'd stay green while the site served white screens. Here's what a Docker healthcheck actually does, what it very much does not, and a Drupal-specific one that doesn't lie.

What a healthcheck is

A HEALTHCHECK runs a command inside the container on a schedule and sets a status label: starting, then healthy or unhealthy. The knobs (with defaults): --interval=30s, --timeout=30s, --retries=3, --start-period=0s, and --start-interval=5s (needs Engine 25+). Exit 0 is healthy, exit 1 is unhealthy. That's the whole mechanism.

One nuance worth knowing: the start period isn't a fixed timer. The docs are explicit — if a check succeeds during the start period, the container is considered started and subsequent failures start counting against your retries. A single early success ends the grace window.

depends_on waits for "running," not "ready"

The most common Compose mistake: assuming depends_on: [db] waits for the database to be usable. It doesn't. Straight from the docs: "Compose does not wait until a container is 'ready', only until it's running." The container exists; MariaDB inside it may still be mid-boot. To actually wait for readiness you need a healthcheck on the dependency plus condition: service_healthy:

services:
  drupal:
    depends_on:
      db:
        condition: service_healthy   # waits for DB health, not just "started"
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "drush sql:query 'SELECT 1' >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s

  db:
    image: mariadb:11
    healthcheck:
      test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

The part that surprises people: unhealthy ≠ restarted

Here's the load-bearing misconception. Docker does not restart an unhealthy container. Restart policies (on-failure, always, unless-stopped) key off the container process exiting with a bad code. A container that goes unhealthy but keeps running — PID 1 alive, serving errors — has not exited, so no restart policy fires. The healthcheck flags it, emits a health_status event, and then… nothing. It sits there, red and running, forever.

To actually act on unhealthy you need something more: Docker Swarm (which reschedules unhealthy tasks), a sidecar like willfarrell/docker-autoheal that watches the socket and restarts anything unhealthy, or a healthcheck hacked to make PID 1 exit so a restart policy catches it. This gap is deliberate — auto-restart-on-unhealthy was proposed alongside the feature and left out. Most people never learn this until a wedged container quietly serves 500s all weekend.

A restart policy handles a container that crashed. It does nothing for a container that's alive and broken. Those are different failures, and only one of them fixes itself.

Why curl localhost lies for Drupal

The default healthcheck everyone copies is curl -f http://localhost/ || exit 1. For Drupal it's actively misleading. curl -f only fails on HTTP 400+, and Drupal's internal page cache (with Coolify's Traefik in front) will happily serve a cached anonymous front page without touching the database. So the DB can be down, the front page returns a cached 200, the healthcheck goes green — and every logged-in user or uncached route hits a white screen. The check is confirming "the web server answered," not "Drupal works."

The fix is a check that actually exercises the database. The cleanest, if drush is in your image, is the one in the compose above: drush sql:query 'SELECT 1' connects using Drupal's own settings and fails when the DB is unreachable. If you'd rather stay in HTTP, hit the container directly with a cache-busting query (curl -fsS 'http://localhost/?_hc=1') to force a real bootstrap. Don't build a custom /health route or install a health module for a single blog — that's the over-engineering this post is arguing against.

Four different things, and the trap

The real lesson is vocabulary:

  • Healthcheck — an in-container probe that sets a label. On its own it does nothing.
  • Readiness — "is it ready for dependents/traffic?" This is what service_healthy gates.
  • Monitoring — continuous, external collection of state over time (uptime, latency, errors, disk). Outside the container, with history.
  • Alerting — turning a bad observation into a human being's phone buzzing.

A green healthcheck means "this container answered its own small question a moment ago." It won't restart a wedged process, won't notice a dead DB behind a cached page, keeps no history, and will never wake you at 3am. Healthcheck ≠ readiness ≠ monitoring ≠ alerting — and the failure mode is quietly trusting the first to do the job of the other three. That's why the healthcheck lives in Compose and something like Beszel (and an uptime pinger) lives outside the box.

Links

BM
Blue Moose
The moose behind Blue Moose. Full-stack PHP developer — Drupal by day, Symfony by night, tests always.