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
-
healthcheckwithpg_isready.pg_isreadyships 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 theenvironmentblock. -
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 upstops with that error instead of booting a database with an empty user. -
${POSTGRES_DB:-app}. Falls back toappwhen 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 theportsline. - Named volume.
docker compose downkeeps the data;docker compose down -vwipes 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.
