A Docker container can be running while the application inside it is completely unavailable. This is one of the most common misunderstandings in Docker Compose.
The container process may still exist, but the web server may not have started, the database may still be initializing, or the application may be unable to respond to requests. That is why a Compose file should define a meaningful healthcheck, not just a restart policy.
A health check executes a command inside the container. If that command returns exit code 0, Docker marks the container as healthy; any non-zero exit code indicates failure.
Running Is Not Ready
Consider this simple Compose file:
services:
app:
image: my-app:latest
ports:
- "3000:3000"
restart: unless-stopped
Docker may report the container as running immediately after the process starts. That does not necessarily mean the application is ready to receive traffic.
The application might still be:
- Loading configuration.
- Running database migrations.
- Compiling an extension.
- Waiting for PostgreSQL.
- Starting an HTTP server.
- Connecting to an external service.
Without a health check, Docker has no reliable way to distinguish “the process exists” from “the application works.”
Basic Health Check
A health check can test the application’s HTTP endpoint:
services:
app:
image: my-app:latest
ports:
- "3000:3000"
healthcheck:
test:
[
"CMD",
"curl",
"--fail",
"--silent",
"--show-error",
"http://127.0.0.1:3000/"
]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
The command runs inside the container. Therefore, the check must use the container port, 3000, rather than necessarily using the host port mapping.
The start_period gives the application time to start before failures count against it. The interval controls how often Docker checks the service, timeout limits how long an individual check may run, and retries controls how many consecutive failures are allowed.
Choose the Right Option
The most overlooked detail is that the health-check command must exist inside the image. Minimal images often do not contain curl, wget, Bash, or network tools. A health check can be perfectly written and still fail because its executable is missing.
Option 1: curl
If the image contains curl:
healthcheck:
test:
[
"CMD",
"curl",
"--fail",
"--silent",
"--show-error",
"http://127.0.0.1:3000/"
]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
For a Debian-based image such as node:20-slim, install it in the Dockerfile:
FROM node:20-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Slim images intentionally contain fewer utilities, so installing curl explicitly is expected. github
Option 2: Node.js
For a Node application, Node itself can perform the check without installing another package:
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode>=200&&r.statusCode<400?0:1)).on('error',()=>process.exit(1))"
]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
This is a useful option for node:20-slim, especially when the only purpose of curl would be the health check.
Option 3: wget
If the image already includes wget, use:
healthcheck:
test:
[
"CMD",
"wget",
"--quiet",
"--tries=1",
"--spider",
"http://127.0.0.1:3000/"
]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
Always test the command manually before relying on it:
docker exec <container-name> \
wget --quiet --tries=1 --spider http://127.0.0.1:3000/
Option 4: PostgreSQL
A database should have its own health check:
services:
database:
image: postgres:16
environment:
POSTGRES_DB: my-app-db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: change-this-password
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U postgres -d my-app-db"
]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
pg_isready is included in the PostgreSQL image, so no additional package is required.
Startup Order Matters
A health check becomes more useful when combined with a health-based dependency:
services:
app:
build: .
depends_on:
database:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode>=200&&r.statusCode<400?0:1)).on('error',()=>process.exit(1))"
]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
database:
image: postgres:16
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U postgres -d my-app-db"
]
interval: 10s
timeout: 5s
retries: 5
With condition: service_healthy, Compose waits for the database health check to pass before starting the application. last9
For an my-app-db deployment, the application health check can test port 3000:
environment:
HOST: 0.0.0.0
PORT: 3000
The health check should use:
http://127.0.0.1:3000/
not:
http://127.0.0.1/
unless the application actually listens on port 80.
Test Before You Trust It
A health check should be tested manually inside the container. For the Node-based check:
docker exec <container-name> \
node -e "require('http').get('http://127.0.0.1:3000/',r=>{console.log('HTTP',r.statusCode);process.exit(r.statusCode>=200&&r.statusCode<400?0:1)}).on('error',e=>{console.error(e.message);process.exit(1)})"
A successful result might be:
HTTP 200
Then inspect the health status:
docker compose ps
For detailed results:
docker inspect <container-name> \
| jq '.[0].State.Health'
The health state normally moves through:
starting → healthy
If checks continue to fail, Docker eventually reports:
unhealthy
The inspection output includes the failing command and its output, which often reveals simple problems such as:
curl: executable file not found
Health Checks Do Not Restart Containers
A health check reports the state of a container. It does not automatically restart a container merely because it becomes unhealthy. forums.docker
Use a restart policy for processes that exit:
restart: unless-stopped
But treat health status and restart behavior as separate concerns:
-
healthcheckanswers: “Is the application responding correctly?” -
restartanswers: “What should Docker do if the container process exits?” -
depends_onanswers: “Should another service wait for this dependency?” - Monitoring answers: “Who should be alerted when the service is unhealthy?”
This distinction is why health checks are easy to overlook but important to design properly.
Final Compose Example
services:
app:
build:
context: .
container_name: my-app
restart: unless-stopped
ports:
- "3000:3000"
environment:
HOST: 0.0.0.0
PORT: 3000
DB_HOST: database
DB_PORT: 5432
DB_NAME: myapp
DB_USER: postgres
DB_PASSWORD: change-this-password
depends_on:
database:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode>=200&&r.statusCode<400?0:1)).on('error',()=>process.exit(1))"
]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
database:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: change-this-password
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U postgres -d myapp"
]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
volumes:
postgres-data:
The best health check is not the most complicated one. It is the smallest command that verifies the condition users actually need: that the service is ready to do its job.
Top comments (0)