DEV Community

Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

The Complete Guide to Docker HEALTHCHECK: Dockerfile vs Compose vs Orchestrator

How many times have you seen a HEALTHCHECK CMD curl -f http://localhost/health || exit 1 pasted into a Dockerfile without anyone asking what happens when that endpoint returns 200 while the database behind it is dead?

That's the scene that triggered this post. Not a production incident story — I don't have that kind of public evidence to show here — but a pattern that repeats every time someone searches "docker healthcheck", "dockerfile healthcheck" or "docker container health check" on Google expecting a quick recipe. They get one. And with that recipe, in a real deployment, the orchestrator ends up restarting healthy containers or leaving broken ones running, depending on which side the error is on.

My thesis is simple and I'll stand by it: a healthcheck with no criteria behind it — the classic copy-pasted curl to /health — is infrastructure folklore, not real observability. It's good for checking a box on a best-practices checklist. It's useless for knowing whether the container can actually serve traffic.

The real pain before you write a single line of HEALTHCHECK

The problem isn't syntax — the docs solve that in two minutes. The problem is deciding what to check, how often, and what to do when the check fails. That's where most guides stop short: they hand you the command and leave you alone with the decision that actually matters.

And that decision has concrete consequences on a stack running Next.js or Node behind PostgreSQL: a badly placed healthcheck isn't neutral. It generates false positives that restart containers mid-workload, or false negatives that keep sending traffic to a process that can no longer respond with anything useful.

What the official source says (and what it doesn't)

Docker's documentation on HEALTHCHECK is clear on the syntax side. It defines the instruction, its flags (--interval, --timeout, --start-period, --retries), and the exit codes Docker interprets: 0 healthy, 1 unhealthy, 2 reserved.

# Official Dockerfile syntax
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1
Enter fullscreen mode Exit fullscreen mode

That's what the official source gives you: https://docs.docker.com/reference/dockerfile/#healthcheck

What it doesn't give you — and that's the whole point of this post — is judgment about:

  • What that /health endpoint should actually return to be honest ("the process is alive" vs "I can talk to the database")
  • What interval makes sense for your real load
  • What happens when the orchestrator (Swarm, Kubernetes, Railway) decides what to do with an "unhealthy" container

The docs give you the tool. They don't give you the signal design.

Dockerfile vs Compose vs orchestrator: not the same question

Here's where the confusion from the three searches in the title collides. These are three distinct layers, and each answers a different question:

flowchart TD
  A[Dockerfile HEALTHCHECK] -->|defines the test| B[Docker Engine]
  B -->|marks status| C{Compose depends_on: condition}
  C -->|healthy| D[Starts the next service]
  C -->|unhealthy| E[Blocks or retries]
  B --> F{Orchestrator: Swarm/K8s}
  F -->|repeated unhealthy| G[Replaces the container]
Enter fullscreen mode Exit fullscreen mode
  • Dockerfile defines the test itself: the command, the interval, the retries. It's the lowest layer, it lives with the image.
  • Compose consumes that status to sequence startup with depends_on: condition: service_healthy, or to override the HEALTHCHECK parameters without touching the image:
# docker-compose.yml
services:
  api:
    build: .
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 20s
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
Enter fullscreen mode Exit fullscreen mode
  • The orchestrator (Swarm, Kubernetes with its own liveness/readiness probes, or a platform like Railway) decides what to do with that signal: retry, replace the container, pull it out of the load balancer. At that point Docker's HEALTHCHECK stops being the only source of truth — Kubernetes, for instance, has its own probes that don't depend on the Dockerfile's HEALTHCHECK at all.

Mixing up these three layers is exactly why someone searches "docker healthcheck" thinking there's one answer, when really they're asking three different things depending on which layer they're standing in.

Where people get it wrong: the common recipe and its hidden cost

The common recipe is this: expose a /health endpoint that returns 200 OK with a hardcoded {"status": "ok"}, and don't touch anything else. It compiles, it works in the demo, it checks the "I have a healthcheck" box.

The hidden cost shows up when that Node process is still alive — the runtime responds, the port is listening — but the PostgreSQL connection dropped, the connection pool is exhausted, or a critical external dependency isn't responding. The healthcheck says "healthy." The container can't serve a single real request.

It's the same design mistake I ran into when I talked about what to expose and what to hide in Actuator: the surface you decide to show as "status" has to reflect what actually matters, not what's easy to check. A /health that only confirms the process started is equivalent to an Actuator endpoint that returns UP without checking any real dependency.

The honest counterexample, and the one I actually worry about more: an overly strict healthcheck can do just as much damage. Picture an endpoint that checks the database, the cache, and three external services on every 10-second ping. As a rule of thumb — not something I've measured in production, but a pattern that shows up constantly in infra discussions — any transient latency spike in one of those external dependencies is enough to drag the whole container down to "unhealthy." The orchestrator restarts it, killing active connections, over a problem that most likely would've resolved itself on the next retry.

Decision matrix: what to look at before writing the CMD

Scenario What to check Suggested interval Risk if you get it wrong
Simple stateless API Process responds on the port 30s, timeout 5s Low — not much to break
API with PostgreSQL connection Port + lightweight query like SELECT 1 15-30s, high retries (3-5) High if the check is heavy: overloads the database with pings
Worker with no HTTP port Lock file, processed queue, own heartbeat Depends on the job cycle False "unhealthy" if the cycle runs longer than the interval
Service behind Compose with depends_on Make sure service_healthy doesn't block the whole stack's startup indefinitely Generous start_period Whole stack fails to start over a too-short start_period
Container in an orchestrator (Swarm/K8s) Separate liveness (is it alive?) from readiness (can it take traffic?) Relaxed liveness, strict readiness Cascading restarts if liveness and readiness share the same check

This matrix isn't a closed formula. It's a starting point to ask "is what I'm checking actually what fails when the service fails?" before copying the first example you find in a tutorial.

I use a similar filter for npm libraries: before putting a dependency into production, it's worth evaluating it with actual criteria instead of adding it because "everyone uses it." Same logic applies to a HEALTHCHECK — the fact that a command shows up in a hundred GitHub Dockerfiles says nothing about whether it fits your case. Popularity isn't evidence.

Common mistakes / gotchas

  • Using curl without having it in the final image. If the Dockerfile uses a slim or alpine base, curl might not be installed, and the healthcheck fails every single time with a "command not found" error, not because of an actual service problem.
  • start_period too short for apps with slow startup. If the app takes 15 seconds to come up (migrations, pool connection, warm-up) and start_period is set to 5 seconds, the container gets marked unhealthy before it's even finished booting.
  • Confusing liveness with readiness. A check that only confirms "the process hasn't crashed" doesn't tell you if it can serve traffic. That distinction, which Kubernetes makes explicit with two separate probes, gets lost easily when plain Docker only gives you one HEALTHCHECK.
  • Healthchecks that write to the database just to verify. A check that does a test INSERT every 10 seconds generates noise in the PostgreSQL logs — something you notice fast if you've ever turned on Prisma's query logging and watched that background traffic compete with the real queries.
  • Not logging the healthcheck result. docker inspect --format='{{json .State.Health}}' <container> gives you the history of the last checks. If you've never looked at it, it's hard to know whether the healthcheck is actually doing anything or just sitting there for decoration.
# Check the health check history of a running container
docker inspect --format='{{json .State.Health}}' mi_contenedor | jq
Enter fullscreen mode Exit fullscreen mode

Limits of this guide

This is design judgment based on the official docs and known failure patterns, not an experiment with my own metrics. I don't have a reproducible benchmark comparing intervals, or a documented public production case to cite here. If you're deciding on the healthcheck for a system with a real SLA, the next step isn't reading a blog post — it's instrumenting your own system, running a controlled-load experiment, and watching the docker inspect logs over a representative period. This guide gives you the framework to design that test, not the result of having run it.

There's also no evidence here about specific Kubernetes probe behavior or Railway — every orchestrator has its own semantics, and it's worth reading its specific docs before assuming it behaves the same as Docker Compose.

FAQ

What's the difference between HEALTHCHECK in Dockerfile vs Compose?
The Dockerfile defines the image's default healthcheck. Compose can inherit it or override it with its own healthcheck section, without rebuilding the image. Useful for tuning intervals per environment (dev vs staging) without touching the Dockerfile.

What happens if I don't set any HEALTHCHECK?
Docker assumes the container is healthy as long as the main process keeps running. No active check. That's not necessarily worse than a badly designed healthcheck — sometimes "no check" is more honest than a check that lies.

What's a good interval for HEALTHCHECK?
There's no universal number. It depends on how expensive the check is and how fast you need to detect a problem. A typical reference range is 10-30 seconds with a short timeout (3-5s) and retries of 3 to 5 to avoid false positives from a transient spike.

Does HEALTHCHECK replace Kubernetes probes?
No. Kubernetes has its own livenessProbe and readinessProbe, independent of Docker's HEALTHCHECK. If you deploy on K8s, the Dockerfile's HEALTHCHECK might end up unused — the real config lives in the pod manifest.

Can I use a script instead of curl?
Yes. HEALTHCHECK's CMD accepts any command that returns an exit code of 0 or non-zero. A custom script gives you more control to check, for example, whether the PostgreSQL connection pool has available connections, instead of just hitting an HTTP port.

Can a too-strict healthcheck backfire?
Yes, and it's one of the central points of this guide. If the check depends on external services with variable latency, a transient spike can drag the container into "unhealthy" and trigger a restart that fixes nothing — because the real problem was outside the container, not inside it.

Closing: the position

A HEALTHCHECK isn't a best-practices checkbox. It's a signal that some other system — Compose, Swarm, Kubernetes, whatever platform you're using — is going to use to make an automatic decision about your container. Designing it without thinking about what decision you're going to trigger is folklore, not observability.

My concrete recommendation: before writing the CMD, write down the question that check has to answer first — "can this process serve traffic right now?" — and only then write the command. If the answer needs a SELECT 1 against PostgreSQL, let it have one. If it needs to separate liveness from readiness because the process can be alive but not ready, let it separate them. The command is the easy part. Deciding what to ask is what makes the healthcheck useful on the day something actually breaks. The uncomfortable question worth sitting with: if your healthcheck failed right now, would it be telling you the truth, or just following a script nobody re-checked?

Original source: Docker Docs - HEALTHCHECK — https://docs.docker.com/reference/dockerfile/#healthcheck


This article was originally published on juanchi.dev

Top comments (0)