All articles
Infrastructure

Docker Compose Port, Healthcheck, and Dependency Failures: The DevOps Guide

Fix Docker Compose startup races, unhealthy dependencies, host-port collisions, localhost mistakes, and accidentally exposed databases.

Share:

Answer in brief

Debug Docker Compose in layers: render the merged configuration, separate host ports from container ports, test service-name networking, run healthchecks inside their images, and combine service_healthy startup gates with retry-tolerant applications.

Use this guide as a diagnostic workflow, then verify the result against your production environment and the primary documentation below.

You clone a repository, run docker compose up, and watch PostgreSQL, Redis, and the web container appear in the expected order. Then the web process throws ECONNREFUSED, exits, and leaves the database running. The containers started in the right order, but the applications were not ready in the right order.

The next developer gets a different failure: Docker cannot bind port 5432 because a native PostgreSQL installation already owns it. A third developer can start the stack but accidentally published the database on every host interface. These look like unrelated Compose bugs, yet they come from mixing three separate layers: process lifecycle, service readiness, and network publishing.

TL;DR

  • Short-form depends_on waits for a dependency to be started, not ready to accept real work.
  • Add a cheap, accurate healthcheck and use long-form condition: service_healthy for readiness-gated startup.
  • Inside the Compose network, connect to the service name and container port—for example db:5432, not localhost:5433.
  • Publish only ports the host genuinely needs. Bind development-only ports to 127.0.0.1 and make the host port configurable.
  • expose does not create isolation. Shared-network membership determines container-to-container reachability.
  • Keep application retries and reconnection logic: startup ordering cannot guarantee long-term dependency availability.

Validate the Compose model before starting containers.

The CodeAva Docker Compose Validator & Service Graph catches host- port collisions, circular dependencies, readiness gaps, outdated syntax, undefined resources, and unresolved environment variables. It also separates startup dependencies from network reachability in an exportable graph.

Open the Docker Compose Validator

Use the correct mental model: host, container, and readiness

Before changing YAML, classify which boundary is failing. A published host port, an internal container port, a running process, and a healthy service are different facts.

LayerWhat it provesTypical failure
Container runningThe container's main process has startedDatabase is still initializing and refuses connections
Healthcheck healthyThe configured probe currently exits with status zeroProbe checks only a process, not required migrations
Container portA service can listen inside its network namespaceProcess listens on 127.0.0.1 inside its own container
Published host portHost clients can reach a mapped container portHost port is occupied or bound to an unsafe interface
Application resilientThe client reconnects after later dependency disruptionOne dropped connection permanently crashes the process

The depends_on illusion: started is not ready

A database container can enter the running state while PostgreSQL initializes a new data directory, replays write-ahead logs, runs image entrypoint scripts, or starts listening. Short-form depends_on establishes creation and removal order, but it does not define what “ready” means for the dependency.

Fail-prone: startup order only

services:
  web:
    build: ./web
    depends_on:
      - db

  db:
    image: postgres:18-alpine

Compose starts db before web, but web can still attempt its first connection before PostgreSQL accepts it.

Resilient: define health and wait for it

services:
  web:
    build: ./web
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD: development-only
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 20s

The doubled dollars are deliberate. Compose normally interpolates a single $ using the host environment while rendering the model. $$ escapes it so the healthcheck's shell reads POSTGRES_USER and POSTGRES_DB from inside the database container.

Use service_completed_successfully for one-shot jobs

Readiness and schema migration are different states. A database can accept connections before the application schema exists. Model a migrator as a one-shot service when the application must not start until migrations finish.

services:
  migrate:
    build: ./web
    command: ["npm", "run", "migrate"]
    restart: "no"
    depends_on:
      db:
        condition: service_healthy

  web:
    build: ./web
    depends_on:
      db:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully

The migration itself must be safe to retry and coordinate across concurrent environments. Compose sequencing does not make a non- idempotent migration safe.

Healthchecks that are cheap, accurate, and bounded

A useful healthcheck asks the smallest question that represents the state Compose should gate. It runs inside the target container, so the executable must exist in that image. A curl command that works on your laptop is useless when the production image contains no curl binary.

Avoid expensive database queries, full search requests, cache writes, or a heavyweight application route every two seconds. Healthchecks are repeated operational workload. They should finish quickly, avoid mutation, use a timeout, and return a meaningful exit code.

PostgreSQL: use pg_isready for connection readiness

db:
  image: postgres:18-alpine
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
    start_period: 20s
    start_interval: 2s
    interval: 10s
    timeout: 3s
    retries: 5

pg_isready reports whether the server accepts connections. It does not prove that migrations ran, a particular table exists, or the application role has every required privilege. Test those states in the migration job or application startup path rather than turning a frequent healthcheck into a schema audit.

Redis: PING is constant-time and purpose-built

cache:
  image: redis:7-alpine
  healthcheck:
    test: ["CMD", "redis-cli", "ping"]
    interval: 10s
    timeout: 3s
    retries: 5
    start_period: 5s

Redis PING is a fast connection check and normally returns PONG. If authentication or TLS is enabled, make the probe use the same security boundary as a real client without placing a password directly in process arguments or committing it to the Compose file.

HTTP services: use a narrow readiness route

api:
  build: ./api
  healthcheck:
    test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:3000/readyz"]
    interval: 15s
    timeout: 3s
    retries: 5
    start_period: 15s

Confirm wget is present in the final image; otherwise add a tiny purpose-built probe or use a runtime already included. The endpoint should avoid authentication, large response bodies, third-party calls, and deep queries. Decide explicitly whether readiness should fail when a dependency is temporarily unavailable.

Understand every timing field

FieldPurposeCommon mistake
start_periodGrace period for expected initialization failuresOmitting it for a database with a slow first boot
start_intervalFaster checks during the start periodUsing it without confirming the team's Compose version
intervalTime between regular checksPolling an expensive endpoint every second
timeoutMaximum duration of one checkLetting blocked probes accumulate indefinitely
retriesConsecutive failures before unhealthy statusSetting one retry and reacting to transient latency

Unhealthy does not mean restarted

A Docker healthcheck changes health status. It can gate a service_healthy dependency, but it does not automatically restart a running unhealthy container. Container restart policies act when the main process exits. Choose recovery behavior deliberately instead of assuming the probe is a self-healing controller.

Remote teams and host-port collisions

A team in New York, Toronto, and London rarely has identical laptop state. One machine runs native PostgreSQL on 5432. Another has a proxy on 8080. A third already runs a different Compose project using 6379. Hardcoding every familiar host port makes onboarding depend on an empty workstation.

A collision usually produces an error such as Bind for 0.0.0.0:5432 failed: port is already allocated. Identify the current owner before stopping anything:

# Compose and Docker-owned bindings
docker compose ps
docker ps --format 'table {{.Names}}	{{.Ports}}'
docker compose port db 5432

# Linux
ss -ltnp | grep ':5432'

# macOS or Linux with lsof
lsof -nP -iTCP:5432 -sTCP:LISTEN

# Windows PowerShell
Get-NetTCPConnection -LocalPort 5432 -State Listen

Do not terminate an unknown process just to make Compose start. It may belong to another project or developer tool. Choose an unused host port or intentionally stop the documented owner.

Make only the host side configurable

services:
  db:
    image: postgres:18-alpine
    ports:
      - "127.0.0.1:${POSTGRES_HOST_PORT:-5432}:5432"

Developers can set POSTGRES_HOST_PORT=55432 in a local .env without changing the container port or the connection string used by other containers. Commit a .env.example with safe defaults and keep secret-bearing local files out of version control.

If nobody on the host needs direct database access, remove the ports entry entirely. Compose networking still allows the application to reach db:5432.

Host ports and container ports are different address spaces

Given 127.0.0.1:55432:5432, host tools connect to 127.0.0.1:55432. Containers on the same Compose network connect to db:5432. They do not use 55432, and they should not use localhost.

services:
  db:
    image: postgres:18-alpine
    ports:
      - "127.0.0.1:55432:5432"

  web:
    build: ./web
    environment:
      # Service name + container port, not localhost:55432
      DATABASE_URL: postgres://app:development-only@db:5432/app

Each container has its own loopback interface. From inside web, localhost means web. Use a service name because Compose DNS keeps that name stable when a recreated container receives a different IP address.

ports vs. expose: the real network boundary

ports creates a mapping between a host address and a container port. When the host IP is omitted, Docker binds to all host interfaces by default. On a machine with a routable interface, a mapping such as 5432:5432 can make the database reachable beyond localhost. Bind local-only development ports explicitly to 127.0.0.1 or ::1.

expose declares container ports that should be available to linked or networked services without publishing them to the host. It is useful documentation, but it is not a firewall rule. Containers sharing a network can normally reach any port on which the peer process listens, even if expose is absent.

ConfigurationHost accessSame-network access
No ports or exposeNot publishedReachable if the process listens and networks overlap
expose: ["5432"]Not publishedReachable and explicitly documented
ports: ["5432:5432"]Published on all host interfaces by defaultReachable via service name and container port
127.0.0.1:5432:5432Published only to host loopback on current enginesReachable via service name and container port

Use network membership for isolation

In a shared or production-like environment, do not publish database and cache ports unless a documented host-side consumer requires them. Put data services on a backend network and attach only the application services that need access.

services:
  proxy:
    image: nginx:alpine
    ports:
      - "127.0.0.1:8080:80"
    networks: [frontend]

  web:
    build: ./web
    networks: [frontend, backend]

  db:
    image: postgres:18-alpine
    networks: [backend]
    expose: ["5432"]

  cache:
    image: redis:7-alpine
    networks: [backend]
    expose: ["6379"]

networks:
  frontend: {}
  backend:
    internal: true

The proxy and database share no network, so the proxy cannot resolve or reach db. Web can reach both. Marking backend as internal removes its default external connectivity; web can still reach the internet through frontend because it belongs to both networks.

service_healthy is a startup gate, not permanent availability

Once web starts, the database can restart, the network can be recreated, or an existing connection can close. Docker's networking guidance explicitly places reconnection responsibility on the application when a service is recreated under a new IP.

Production-grade clients should use bounded exponential backoff with jitter, re-resolve the service name, rebuild dead pools, and distinguish transient errors from permanent authentication or schema failures. Make startup and migration work idempotent so retries do not duplicate data.

Long-form depends_on also supports restart: true, but do not confuse it with a container restart policy. It restarts the dependent service after an explicit Compose operation updates or restarts the dependency. It does not mean every runtime crash or unhealthy status automatically cascades.

A repeatable Docker Compose debugging workflow

1. Render the model Compose will actually apply

docker compose config --quiet
docker compose config
docker compose config --environment

docker compose config merges files, applies profiles, resolves interpolation, and expands short syntax. This catches a wrong override, missing variable, unexpected host port, or service disabled by a profile. Be careful in CI logs: rendered configuration and interpolation output can contain sensitive values.

2. Inspect state and logs by service

docker compose ps
docker compose logs --tail=100 db cache web
docker compose logs --follow --timestamps db web

Look for the first meaningful error rather than the last restart-loop message. Check whether web failed before db became healthy, whether db rejected authentication, or whether the healthcheck command itself was missing.

3. Inspect the recorded healthcheck output

docker inspect --format '{{json .State.Health}}' your-project-db-1

# Run the exact probe inside the target service and inspect its exit status.
docker compose exec db sh -lc 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"; echo "exit=$?"'

A container marked unhealthy may have a healthy application and a broken probe: wrong hostname, absent shell, missing binary, bad credentials, or incorrect quoting. Run exactly what Docker runs in the same image.

4. Test from the failing network namespace

A successful host connection proves the host mapping, not service-to- service networking. Execute a DNS lookup or connection test from web, using tools the image actually contains. Confirm that web and db share a network and that the database listens on the container interface and port.

5. Make CI wait for health explicitly

docker compose up --detach --wait --wait-timeout 120

Current Compose supports --wait to wait until services are running or healthy, with a bounded timeout. This is useful in CI, but it is only as accurate as the healthchecks you define.

Environment files and Compose version traps

The project-level .envused for Compose interpolation is not automatically the same as a service's env_file. A value can successfully change ports while remaining absent inside the container. Render the model and declare service environment explicitly.

Modern Compose follows the Compose Specification. The top-level version field is obsolete and informative only; current Compose validates against its latest supported schema regardless of that value. Remove stale version: "3" declarations instead of assuming they enable or disable dependency conditions.

Pin and document a minimum Compose plugin version when using newer fields such as start_interval. Run docker compose version in bug reports so cross-team behavior is reproducible.

Audit the Compose architecture with CodeAva

A YAML parser can confirm indentation and scalar types but miss a valid file in which two services publish 8080, a dependency cycle crosses legacy constructs, or every database dependency waits only for startup. The CodeAva Docker Compose Validator & Service Graph performs Compose-aware static checks for those relationships, produces an environment-variable checklist, and keeps pasted Compose content in the browser.

For scripts, Dockerfiles, entrypoints, or a focused configuration block surrounding the Compose model, use the CodeAva Code Audit to review risky patterns and maintainability issues. Neither static tool replaces runtime checks: use docker compose logs, ps, and docker inspect for the actual deployed state.

Production-ready Compose checklist

  • Render and validate the merged model with docker compose config --quiet in CI.
  • Use service names and container ports for internal connections; never use a sibling's published host port.
  • Publish only ports required by host-side clients and bind local-only development services to loopback.
  • Make host ports configurable without changing stable container ports.
  • Treat expose as documentation and design isolation with network membership.
  • Add cheap, bounded healthchecks using binaries present in the final image.
  • Use start_period, reasonable intervals, timeouts, and retry counts for real startup behavior.
  • Gate critical dependencies with service_healthy and one-shot setup jobs with service_completed_successfully.
  • Keep connection retries, backoff, DNS re-resolution, and idempotency in the application.
  • Do not expect an unhealthy status to restart a container automatically.
  • Keep credentials out of committed Compose files and avoid printing resolved secrets in CI logs.
  • Remove the obsolete top-level version field and document the minimum Compose plugin version.
  • Prefer docker compose; Docker supports the standalone docker-compose path only for backward compatibility.

A dependable local environment is engineered, not wished into existence. When the Compose file expresses real readiness, keeps private services off host interfaces, and leaves recovery logic in the application where it belongs, docker compose up becomes a repeatable workflow instead of a race every teammate learns to rerun.

Sources and further reading

#Docker Compose port collision#Docker Compose healthcheck#Docker Compose depends_on#service_healthy#container startup order#ports vs expose#Docker networking#pg_isready#Redis healthcheck#DevOps troubleshooting

Frequently asked questions

More from Jerome James

Found this useful?

Share:

Want to audit your own project?

These articles are written by the same engineers who built CodeAva\u2019s audit engine.