DEV Community

Cover image for The Most Overlooked Part in Docker Compose
Shahid
Shahid

Posted on

The Most Overlooked Part in Docker Compose

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Always test the command manually before relying on it:

docker exec <container-name> \
  wget --quiet --tries=1 --spider http://127.0.0.1:3000/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The health check should use:

http://127.0.0.1:3000/
Enter fullscreen mode Exit fullscreen mode

not:

http://127.0.0.1/
Enter fullscreen mode Exit fullscreen mode

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)})"
Enter fullscreen mode Exit fullscreen mode

A successful result might be:

HTTP 200
Enter fullscreen mode Exit fullscreen mode

Then inspect the health status:

docker compose ps
Enter fullscreen mode Exit fullscreen mode

For detailed results:

docker inspect <container-name> \
  | jq '.[0].State.Health'
Enter fullscreen mode Exit fullscreen mode

The health state normally moves through:

starting → healthy
Enter fullscreen mode Exit fullscreen mode

If checks continue to fail, Docker eventually reports:

unhealthy
Enter fullscreen mode Exit fullscreen mode

The inspection output includes the failing command and its output, which often reveals simple problems such as:

curl: executable file not found
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But treat health status and restart behavior as separate concerns:

  • healthcheck answers: “Is the application responding correctly?”
  • restart answers: “What should Docker do if the container process exits?”
  • depends_on answers: “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:
Enter fullscreen mode Exit fullscreen mode

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)