A common Docker Compose bug: the app container starts, tries to connect to the database, and crashes because Postgres is still initializing. Plain depends_on only waits for the database container to start, not for the database to accept connections.

The fix is a healthcheck on the database plus condition: service_healthy on the app. Here is a complete setup.

App + Postgres

# app + PostgreSQL. `cp .env.example .env && docker compose up -d` # The app waits until Postgres passes its healthcheck before starting. name: app-postgres services: app: build: . ports: - "3000:3000" environment: DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} depends_on: db: condition: service_healthy restart: unless-stopped db: image: postgres:16-alpine environment: POSTGRES_USER: ${POSTGRES_USER:?set in .env} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env} POSTGRES_DB: ${POSTGRES_DB:-app} volumes: - pgdata:/var/lib/postgresql/data # persists across `down`; `down -v` wipes it healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 5s timeout: 3s retries: 10 # No host port by default; uncomment to connect from local tools. # ports: ["5432:5432"] restart: unless-stopped volumes: pgdata: 

With a .env next to it:

POSTGRES_USER=app POSTGRES_PASSWORD=change-me POSTGRES_DB=app 

What each piece does

  • healthcheck with pg_isready. pg_isready ships in the Postgres image and returns success only when the server accepts connections. Compose runs it every 5 seconds, allows 3 seconds per attempt, and tries 10 times before marking the container unhealthy.
  • $${POSTGRES_USER} (double dollar). A single $ would make Compose substitute the value from your host shell when it parses the file. $$ escapes it, so the variable is expanded inside the container, where it is set from the environment block.
  • condition: service_healthy. The app is not created until the healthcheck passes. No sleep loops or wait-for-it scripts in your entrypoint.
  • ${POSTGRES_USER:?set in .env}. If the variable is missing, docker compose up stops with that error instead of booting a database with an empty user.
  • ${POSTGRES_DB:-app}. Falls back to app when unset.
  • No published DB port. The app reaches the database over the Compose network as db:5432. Nothing on your host or network can reach Postgres unless you uncomment the ports line.
  • Named volume. docker compose down keeps the data; docker compose down -v wipes it. Worth knowing before you run the second one.

Same pattern for Redis

 redis: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"] volumes: - redisdata:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 10 restart: unless-stopped 

redis-cli ping returns PONG once Redis is serving. The app then uses depends_on: { redis: { condition: service_healthy } } the same way. --appendonly yes turns on AOF persistence, and the memory flags cap Redis at 256 MB and evict least-recently-used keys when it is full, which suits a cache.

Checking it

docker compose up -d docker compose ps # the db shows "(healthy)" before app starts 

If the app never starts, docker inspect --format '{{json .State.Health}}' <db-container> shows the healthcheck output.

These files come from ShipKit's Docker & Dev Container Starter Pack, which also has multi-stage Dockerfiles for Node, Python, Go and static sites, a Compose Watch hot-reload setup, and dev container configs: https://8686702537545.gumroad.com/l/bafiks

Want to try one first? The free Node Dockerfile from the pack (multi-stage, non-root, healthcheck, hadolint-clean) is here: https://8686702537545.gumroad.com/l/csiqjo

Disclosure: I make ShipKit, which sells this pack. Everything above is self-contained and passes docker compose config without buying anything.