Docker Compose depends_on Not Waiting for Database Readiness on WSL2 Ubuntu

Resolve Docker Compose services starting before databases are ready on Windows WSL2 Ubuntu. Implement robust startup dependencies.


Resolve Docker Compose services starting before databases are ready on Windows WSL2 Ubuntu. Implement robust startup dependencies.

Docker Compose's depends_on directive is a fundamental tool for orchestrating multi-service applications. However, a common pitfall encountered by developers, particularly in Windows Subsystem for Linux 2 (WSL2) environments, is when an application service attempts to connect to a database before the database container is fully initialized and ready to accept connections. This guide provides a highly technical, step-by-step resolution to this classic race condition.

Symptom & Error Signature

When deploying an application stack with Docker Compose on WSL2, the application container often starts and exits quickly with database connection errors, even though depends_on specifies the database service. This happens because depends_on only guarantees the start order of containers, not the readiness of the services running inside them.

You might observe logs similar to these from your application container (e.g., a Python, Node.js, or PHP application attempting to connect to PostgreSQL or MySQL):

# docker-compose up output snippet
db_1    | 2023-10-26 10:30:05.123 UTC [1] LOG:  starting PostgreSQL 15.4 (Debian 15.4-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
db_1    | 2023-10-26 10:30:05.125 UTC [1] LOG:  listening on IPv4 address "0.0.0.0", port 5432
db_1    | 2023-10-26 10:30:05.125 UTC [1] LOG:  listening on IPv6 address "::", port 5432
db_1    | 2023-10-26 10:30:05.128 UTC [1] LOG:  database system was shut down at 2023-10-26 10:30:04 UTC
db_1    | 2023-10-26 10:30:05.456 UTC [1] LOG:  database system is ready to accept connections
app_1   | Waiting for database connection...
app_1   | Traceback (most recent call last):
app_1   |   File "/app/main.py", line 15, in <module>
app_1   |     conn = psycopg2.connect(DATABASE_URL)
app_1   | psycopg2.OperationalError: connection to server at "db" (172.X.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.
my_app-app-1 exited with code 1

Notice that the application attempts to connect before the database logs database system is ready to accept connections.

Root Cause Analysis

The core of this issue lies in the fundamental difference between "container started" and "service ready".

  1. depends_on Limitations: The depends_on directive in docker-compose.yml (e.g., app: depends_on: - db) only ensures that the db container is started before the app container. It does not wait for the database process inside the db container to be fully initialized, listening on its port, and capable of processing queries.
  2. Database Initialization Time: Databases like PostgreSQL or MySQL require a period to initialize their data directories, start their server processes, apply migrations, and become fully operational. This can take anywhere from a few milliseconds to several seconds, depending on the database and its configuration.
  3. Race Condition: The application container starts, resolves the db hostname, and immediately attempts to establish a connection. If the database server isn't yet listening or ready, the connection attempt fails, leading to the "Connection refused" or similar errors.
  4. WSL2 Context (Contributing Factor): While not the root cause, WSL2's virtualization layer can sometimes introduce subtle timing variations or initial resource allocation delays compared to a native Linux environment. This can make the inherent race condition more pronounced or frequent for some users.

To resolve this, we need to implement mechanisms that explicitly wait for service readiness before allowing dependent services to proceed.

Step-by-Step Resolution

The solution involves a combination of Docker Compose's advanced dependency options and custom container entrypoint scripts to ensure robust service readiness checks.

1. Define Database Health Checks (healthcheck)

Add a healthcheck block to your database service in docker-compose.yml. This tells Docker how to determine if the service inside the container is truly healthy, not just running.

For PostgreSQL (e.g., service named db):

# docker-compose.yml
version: '3.8'
services:
  db:
    image: postgres:15
    restart: always
    environment:
      POSTGRES_DB: mydatabase
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    ports:
      - "5432:5432"
    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 initialize before starting health checks

  # ... other services
volumes:
  db_data:

For MySQL (e.g., service named db):

# docker-compose.yml
version: '3.8'
services:
  db:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_DATABASE: mydatabase
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      MYSQL_ROOT_PASSWORD: rootpassword
    ports:
      - "3306:3306"
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${MYSQL_USER}", "-p${MYSQL_PASSWORD}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s # Give the DB time to initialize before starting health checks

  # ... other services
volumes:
  db_data:

Ensure the test command within your healthcheck uses appropriate credentials and commands for your specific database. For PostgreSQL, pg_isready is excellent. For MySQL, mysqladmin ping is a standard choice. The start_period is crucial as it prevents health checks from failing while the database is still performing its initial setup.

2. Configure Application to Wait for Database Readiness (depends_on with condition: service_healthy)

Now, update your application service's depends_on entry to leverage the newly defined healthcheck. This is a powerful feature of Docker Compose V2 (CLI plugin) and newer V1 versions.

# docker-compose.yml
version: '3.8'
services:
  db:
    # ... healthcheck defined above ...

  app:
    build: . # Or image: myapp:latest
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://user:password@db:5432/mydatabase
    depends_on:
      db:
        condition: service_healthy # <-- This is the key

The condition: service_healthy syntax requires Docker Compose V2 (e.g., docker compose command) or Docker Compose V1.27+ with version: '3.8' in your docker-compose.yml. If you're using an older Docker Compose version, this condition will be ignored, and you'll rely solely on the next step.

3. Implement an Entrypoint Script for Application (Robust Waiting)

Even with condition: service_healthy, it's a best practice, especially in production or complex deployments, to have the application container itself perform a final check and retry mechanism. This catches any minor race conditions or temporary network glitches that might occur after the database is marked "healthy" but before the application truly connects.

Create an entrypoint.sh file in your application's root directory:

#!/bin/bash
# entrypoint.sh

set -e # Exit immediately if a command exits with a non-zero status

HOST="db" # The name of your database service in docker-compose
PORT="5432" # The port your database listens on

echo "Waiting for database '$HOST:$PORT' to be ready..."

# Loop until netcat (nc) can connect to the database host and port
# We use nc -z for zero-I/O scan, checking if the port is open
# This will retry every 1 second
while ! nc -z $HOST $PORT; do
  sleep 1
done

echo "Database '$HOST:$PORT' is available. Starting application..."

# Execute the main command (CMD) specified in the Dockerfile
exec "$@"

Then, modify your application's Dockerfile to include this entrypoint script:

# Dockerfile for your application
FROM python:3.9-slim-buster # Or your base image
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

# Copy and make the entrypoint script executable
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

# Install netcat if your base image doesn't have it (common for slim/alpine images)
# Uncomment the appropriate line for your base image:
# For Debian/Ubuntu-based images (like python:*-slim-buster):
RUN apt-get update && apt-get install -y netcat-traditional && rm -rf /var/lib/apt/lists/*
# For Alpine-based images:
# RUN apk add --no-cache netcat-openbsd

# Set the entrypoint to our script, which will then execute the CMD
ENTRYPOINT ["entrypoint.sh"]
CMD ["python", "main.py"] # Replace with your actual application start command

Ensure you install netcat (or netcat-traditional / netcat-openbsd) in your Dockerfile as it's often not present in minimal base images. The set -e in the entrypoint script is crucial for ensuring that if any command within the entrypoint script fails, the container exits rather than proceeding incorrectly.

4. (Optional) Use a Helper Script (e.g., wait-for-it.sh or dockerize)

For more advanced waiting logic (e.g., waiting for specific HTTP responses, longer timeouts, or multiple services), consider using battle-tested helper scripts like wait-for-it.sh or dockerize.

Using wait-for-it.sh:

  1. Add wait-for-it.sh to your Dockerfile:

    # Dockerfile for your application
    # ... (rest of your Dockerfile) ...
    
    # Add wait-for-it.sh
    ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /usr/local/bin/wait-for-it.sh
    RUN chmod +x /usr/local/bin/wait-for-it.sh
    
    # Use wait-for-it.sh as the ENTRYPOINT
    ENTRYPOINT ["/usr/local/bin/wait-for-it.sh", "db:5432", "--timeout=30", "--strict", "--"]
    CMD ["python", "main.py"] # Your actual application start command
    

    Here, db:5432 is the host and port to wait for, --timeout=30 sets a 30-second timeout, and --strict will exit if a connection can't be made. The -- separates wait-for-it.sh arguments from your application's CMD.

5. Verify and Test

After implementing these changes, rebuild your application container and bring up your services:

docker-compose down # Stop and remove old containers
docker-compose build app # Rebuild only the app service (if using `build: .`)
docker-compose up # Start all services

Observe the logs. You should now see the application container waiting patiently for the database to become ready, indicated by messages from your entrypoint.sh script or wait-for-it.sh, before proceeding to start your main application process. The database logs should show it becoming ready before the application attempts its first connection.

By combining Docker Compose's healthcheck with depends_on condition: service_healthy and a robust entrypoint script, you create a resilient startup sequence that gracefully handles database readiness, ensuring your applications start reliably in any Docker environment, including WSL2.