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 1That'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
/healthendpoint 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]- 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- 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
curlwithout having it in the final image. If the Dockerfile uses a slim or alpine base,curlmight not be installed, and the healthcheck fails every single time with a "command not found" error, not because of an actual service problem. start_periodtoo short for apps with slow startup. If the app takes 15 seconds to come up (migrations, pool connection, warm-up) andstart_periodis 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
INSERTevery 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 | jqLimits 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
Related Articles
Prisma Query Logging and PostgreSQL: Where the ORM Ends and the Database Begins
Prisma's query log shows you what SQL the ORM generated, not why that SQL is slow. A practical guide to knowing when logging is enough and when you need to go straight into PostgreSQL.
Jul 29 2026 · 8′ · Tutorials · TypeScript · postgresql
npm Dependencies: How to Evaluate a Library Before It Hits Production
A clean package.json says nothing about who's going to fix that library when it breaks. Practical criteria for evaluating maintenance, surface area, types and transitive deps before adding a dependency to a TypeScript stack.
Jul 28 2026 · 8′ · Tutorials · TypeScript · pnpm
Barman vs pgBackRest: a decision tree for PostgreSQL backup in production
There's no universal winner. Barman wins on simplicity and real-time WAL streaming with low operational overhead. pgBackRest wins on volume and restore speed. The criteria matter more than the tool.
Jul 13 2026 · 9′ · Tutorials · devops · produccion
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.
No comments yet. Be the first — your take matters most when we're few.