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_onwaits for a dependency to be started, not ready to accept real work. - Add a cheap, accurate healthcheck and use long-form
condition: service_healthyfor readiness-gated startup. - Inside the Compose network, connect to the service name and container port—for example
db:5432, notlocalhost:5433. - Publish only ports the host genuinely needs. Bind development-only ports to
127.0.0.1and make the host port configurable. exposedoes 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 ValidatorUse 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.
| Layer | What it proves | Typical failure |
|---|---|---|
| Container running | The container's main process has started | Database is still initializing and refuses connections |
| Healthcheck healthy | The configured probe currently exits with status zero | Probe checks only a process, not required migrations |
| Container port | A service can listen inside its network namespace | Process listens on 127.0.0.1 inside its own container |
| Published host port | Host clients can reach a mapped container port | Host port is occupied or bound to an unsafe interface |
| Application resilient | The client reconnects after later dependency disruption | One 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-alpineCompose 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: 20sThe 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_successfullyThe 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: 5pg_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: 5sRedis 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: 15sConfirm 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
| Field | Purpose | Common mistake |
|---|---|---|
start_period | Grace period for expected initialization failures | Omitting it for a database with a slow first boot |
start_interval | Faster checks during the start period | Using it without confirming the team's Compose version |
interval | Time between regular checks | Polling an expensive endpoint every second |
timeout | Maximum duration of one check | Letting blocked probes accumulate indefinitely |
retries | Consecutive failures before unhealthy status | Setting one retry and reacting to transient latency |
Unhealthy does not mean restarted
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 ListenDo 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/appEach 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.
| Configuration | Host access | Same-network access |
|---|---|---|
No ports or expose | Not published | Reachable if the process listens and networks overlap |
expose: ["5432"] | Not published | Reachable and explicitly documented |
ports: ["5432:5432"] | Published on all host interfaces by default | Reachable via service name and container port |
127.0.0.1:5432:5432 | Published only to host loopback on current engines | Reachable 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: trueThe 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 --environmentdocker 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 webLook 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 120Current 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 --quietin 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
exposeas 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_healthyand one-shot setup jobs withservice_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
versionfield and document the minimum Compose plugin version. - Prefer
docker compose; Docker supports the standalonedocker-composepath 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.



