Docker Compose `depends_on` Failure: Resolving Database Readiness Issues on macOS
Fix Docker Compose services failing to wait for database readiness on macOS. Implement robust health checks and entrypoint scripts for dependable local development.
Fix Docker Compose services failing to wait for database readiness on macOS. Implement robust health checks and entrypoint scripts for dependable local development.
When developing containerized applications locally on macOS using Docker Compose, it's a common scenario for services to depend on a database. While depends_on is designed to orchestrate service startup order, it often falls short of ensuring that a dependent application service waits until the database service is fully "ready" to accept connections, leading to connection errors and application failures during startup. This guide will walk you through the technical reasons behind this behavior and provide robust, production-grade solutions.
Symptom & Error Signature
You're running docker compose up for your multi-service application, and while your database container starts, your primary application container (e.g., a Python web app, a Node.js API, or a Java Spring Boot app) fails to initialize, reporting connection errors to the database. This typically manifests as messages similar to the following in your application's logs:
# Example: Python application connecting to PostgreSQL
app_1 | Traceback (most recent call last):
app_1 | File "/usr/local/lib/python3.9/site-packages/psycopg2/__init__.py", line 122, in connect
app_1 | conn = _connect(dsn, connection_factory=connection_factory, **kwasync)
app_1 | psycopg2.OperationalError: connection to server at "db" (172.xx.x.x), port 5432 failed: Connection refused
app_1 | Is the server running on that host and accepting TCP/IP connections?
app_1 |
app_1 | The above exception was the direct cause of the following exception:
app_1 |
app_1 | Traceback (most recent call last):
app_1 | File "app.py", line 10, in <module>
app_1 | db_connection = psycopg2.connect(database="mydatabase", user="user", password="password", host="db")
app_1 | File "/usr/local/lib/python3.9/site-packages/psycopg2/__init__.py", line 127, in connect
app_1 | raise OperationalError(e.pgerror, e)
app_1 | psycopg2.OperationalError: connection to server at "db" (172.xx.x.x), port 5432 failed: Connection refused
app_1 | Is the server running on that host and accepting TCP/IP connections?
app_1 |
app_1 | Exiting due to database connection error.
The database container itself, when inspected, might show it's "Healthy" (if a healthcheck is configured) or simply "Up" (if not), but the application still fails to connect.
Root Cause Analysis
The core issue stems from a misunderstanding of how depends_on functions within Docker Compose, particularly in conjunction with the nuances of container startup on macOS:
depends_ononly checks container status, not application readiness: By default,depends_onsimply ensures that a dependent service's container has started (e.g.,runningorhealthyif usingcondition: service_healthy) before initiating the startup of the dependent service. It does not check if the application inside that container (e.g., PostgreSQL, MySQL, Redis) has fully initialized, opened its listening port, and is ready to accept connections. A database container can be "running" for several seconds (or even minutes, depending on initialization tasks like data migration or recovery) before its application is truly ready.Race Conditions: When both containers are launched relatively quickly by Docker Desktop on macOS, the application service often attempts to establish a database connection immediately upon its own startup. If the database server isn't ready at that exact moment, the connection attempt fails, leading to the application crashing or entering an unhealthy state. This is a classic race condition.
macOS-Specific Overheads: While not unique to macOS, Docker Desktop runs containers within a lightweight Linux VM (using HyperKit or Virtualization Framework). This adds a small layer of overhead compared to native Linux Docker environments. Although generally performant, the startup sequencing and network readiness can sometimes be marginally less predictable, exacerbating race conditions if not properly handled.
Step-by-Step Resolution
The robust solution involves implementing application-level readiness checks for your database service, ensuring the dependent service genuinely waits until the database is fully operational.
1. Configure a healthcheck for the Database Service
While depends_on: { service: db, condition: service_healthy } can be used, it relies on the healthcheck of the database. This is a good first step, but not a complete solution. A healthcheck tells Docker Compose about the internal state of a service.
Add a healthcheck block to your database service definition in docker-compose.yml.
# docker-compose.yml
version: '3.8' # Use a modern version that supports healthcheck
services:
db:
image: postgres:15-alpine # Or mysql, mariadb, etc.
environment:
POSTGRES_DB: mydatabase
POSTGRES_USER: user
POSTGRES_PASSWORD: password
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s # Give the DB time to start up before checking
# ports: # Uncomment if you need to expose the port directly to host
# - "5432:5432"
app:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://user:password@db:5432/mydatabase
depends_on:
db:
condition: service_healthy # This is crucial for depends_on to use the healthcheck
# entrypoint and command will be updated in step 2
# command: ["python", "app.py"]
volumes:
db_data:
The
depends_onconditionservice_healthyis critical here. Without it,depends_ononly waits for thedbcontainer to be started, not healthy. However, even withservice_healthy, the dependent application might still experience issues if its startup logic runs before the very first successful health check, or if the health check interval is too long. This is why Step 2 is typically also required.
2. Implement a wait-for-it Entrypoint Script in the Application Service
This is the most robust and widely recommended solution. You need a script that runs before your actual application command, specifically to poll the database connection until it's ready.
a. Create a wait-for-it Script:
Create a file named wait-for-it.sh in the root of your application's Docker context (where your Dockerfile is).
#!/usr/bin/env bash
# wait-for-it.sh
# Based on https://github.com/vishnubob/wait-for-it/blob/master/wait-for-it.sh
# Simplified for common database use case
set -e
host="$1"
shift
cmd="$@"
until nc -z "$host" 5432; do # Replace 5432 with your database port (e.g., 3306 for MySQL)
>&2 echo "Database is unavailable - sleeping"
sleep 1
done
>&2 echo "Database is up - executing command"
exec $cmd
Ensure the
nc(netcat) utility is available in your application's container image. For Alpine-based images, you might need to install it:apk add --no-cache netcat-openbsd. For Debian/Ubuntu-based images:apt-get update && apt-get install -y netcat-traditional.
b. Update your Dockerfile:
Add the wait-for-it.sh script to your application's Docker image and make it executable.
# Dockerfile for your app service
FROM python:3.9-slim-buster # Or your preferred base image
WORKDIR /app
# Install netcat (if not present) for wait-for-it.sh
RUN apt-get update && apt-get install -y --no-install-recommends netcat-traditional && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Make the wait-for-it script executable
RUN chmod +x /app/wait-for-it.sh
EXPOSE 8000
# Original CMD would be here, but we'll use entrypoint in docker-compose.yml
# CMD ["python", "app.py"]
c. Modify docker-compose.yml for the app service:
Update the app service definition to use the wait-for-it.sh script as part of its entrypoint or command. The entrypoint is generally preferred for wrapper scripts.
# docker-compose.yml (updated app service)
version: '3.8'
services:
db:
# ... (as defined in step 1)
app:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://user:password@db:5432/mydatabase
depends_on:
db:
condition: service_healthy # Still beneficial, but the script is primary
# Use entrypoint to run the wait-for-it script before the main command
entrypoint: ["./wait-for-it.sh", "db:5432", "--", "python", "app.py"]
# If your app needs more arguments, add them after "python app.py"
# Example: ["./wait-for-it.sh", "db:5432", "--", "gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Ensure the database hostname (
db) and port (5432) in thewait-for-it.shinvocation (db:5432) precisely match your database service name indocker-compose.ymland its exposed port.
3. Rebuild and Restart
After making these changes, rebuild your application service image and restart your Docker Compose environment:
docker compose up --build -d
The application service will now start, execute wait-for-it.sh, which will continuously try to connect to the database service on port 5432 (or your configured port). Once nc successfully connects, the script will exit, and your application's command (python app.py in this example) will execute, finding the database ready for connections.
This combination of healthcheck on the database service and a wait-for-it script in the dependent application provides a robust and reliable startup sequence for your Docker Compose applications on macOS.
Our Production Verification Guarantee
Encountering a bug not covered here or running a non-standard kernel configuration? Our solutions are continually refined against real production incidents. Submit an environment trace for our editorial team to replicate.