Docker Compose: Resolving 'depends_on' Not Waiting for Database Readiness

Is your Docker Compose app failing to connect to the database on startup? Learn why 'depends_on' isn't enough and how to properly ensure service readiness.


Introduction

You’ve meticulously crafted your Docker Compose file, defined your application and database services, and used depends_on to specify their startup order. Yet, your application container consistently crashes or fails to launch, reporting “Connection refused” or similar database connectivity errors, even though docker-compose ps shows your database container as Up. This frustrating scenario is a common pitfall for many developers and system administrators orchestrating multi-service applications with Docker Compose. This guide will demystify why depends_on alone isn’t sufficient for true service readiness and provide robust, production-grade solutions to ensure your application waits patiently for its database to be fully operational.

Symptom & Error Signature

The most common symptom is your application service failing repeatedly during startup. You might see it restarting in a loop or exiting with a non-zero status code shortly after the database service appears to be “up”.

Typical error messages found in the application service’s logs (e.g., docker-compose logs <app_service_name>):

web_1    | Traceback (most recent call last):
web_1    |   File "/usr/local/lib/python3.9/site-packages/psycopg2/__init__.py", line 122, in connect
web_1    |     conn = _connect(dsn, connection_factory=connection_factory, **kwasync)
web_1    | psycopg2.OperationalError: connection to server at "db" (172.18.0.2), port 5432 failed: Connection refused
web_1    | 	Is the server running on that host and accepting TCP/IP connections?
web_1    |
web_1    | During handling of the above exception, another exception occurred:
web_1    |
web_1    | Traceback (most recent call last):
web_1    |   File "./manage.py", line 10, in <module>
web_1    |     execute_from_command_line(sys.argv)
web_1    |   File "/usr/local/lib/python3.9/site-packages/django/core/management/__init__.py", line 401, in execute_from_command_line
web_1    |     utility.execute()
# ... further traceback ...
web_1    | django.db.utils.OperationalError: connection to server at "db" (172.18.0.2), port 5432 failed: Connection refused
web_1    | 	Is the server running on that host and accepting TCP/IP connections?

(or similar for MySQL/MariaDB with mysqlclient._exceptions.OperationalError: (2002, "Can't connect to MySQL server on 'db'"))

When checking container status:

docker-compose ps
   Name                 Command               State           Ports
----------------------------------------------------------------------------------
myapp-db-1         docker-entrypoint.sh postgres   Up           5432/tcp
myapp-web-1        /entrypoint.sh python manage.py Exit 1

Notice the web service is Exit 1 or Restarting.

Root Cause Analysis

The core of this issue lies in a common misunderstanding of how depends_on operates within Docker Compose.

  • depends_on only manages container start order: When you define depends_on: - db for your web service, Docker Compose ensures that the db container is started before the web container. It does not guarantee that the service inside the db container (e.g., PostgreSQL or MySQL server) has finished its initialization process, opened its listening port, or is ready to accept connections.
  • Database initialization takes time: Database servers, especially on their first run, need time to create data directories, initialize schemas, and start their daemon processes. During this period, the container might be “Up”, but the database service itself is not yet listening on its designated port or is rejecting connections.
  • Application attempts premature connection: Your application starts, immediately attempts to connect to the database, finds no active listener or gets a connection refused error, and subsequently crashes or enters a retry loop that may eventually time out.

In essence, depends_on solves a container-level dependency, but the problem is a service-level dependency within those containers.

Step-by-Step Resolution

To truly ensure service readiness, we need to implement mechanisms that probe the database service’s availability before the application attempts to connect. The most robust and recommended approach for modern Docker Compose (v3.4+) is to use healthcheck declarations combined with depends_on: service_healthy. For older versions or more complex scenarios, a custom entrypoint script is an excellent alternative.

1. Implement Database healthcheck in docker-compose.yml

This is the preferred method for Docker Compose files using version 3.4 or higher. We define a healthcheck for the database service, which Docker will periodically run to determine if the service inside the container is truly ready.

Let’s assume a PostgreSQL database. The healthcheck command typically uses pg_isready (for PostgreSQL) or mysqladmin ping (for MySQL).

Modify your docker-compose.yml:

# docker-compose.yml
version: '3.8' # Use a recent version supporting healthchecks

services:
  db:
    image: postgres:14-alpine
    restart: always
    environment:
      POSTGRES_DB: mydatabase
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myuser -d mydatabase"] # Command to check DB readiness
      interval: 5s                                             # How often to run the check
      timeout: 5s                                              # How long to wait for the check to pass
      retries: 5                                               # How many times to retry before marking as 'unhealthy'
      start_period: 10s                                        # Grace period for the container to start up
                                                               # before health checks begin. Important for DBs!

  web:
    build: . # Or image: myapp:latest
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://myuser:mypassword@db:5432/mydatabase
    depends_on:
      db:
        condition: service_healthy # Crucial: Wait until 'db' service reports 'healthy'
    # command: python manage.py runserver 0.0.0.0:8000 # Example application command
    # Or for Gunicorn/production:
    command: gunicorn myapp.wsgi:application --bind 0.0.0.0:8000

volumes:
  db_data:

[!IMPORTANT] The healthcheck command in the db service is critical. For PostgreSQL, pg_isready -U myuser -d mydatabase checks if the database is accepting connections for myuser to mydatabase. For MySQL, mysqladmin ping -h localhost -u myuser --password=mypassword or simply mysqladmin ping -h 127.0.0.1 -u <user> --password=<pass> (using localhost or 127.0.0.1 inside the DB container) is typical.

[!WARNING] Ensure the healthcheck command’s user and database match your environment variables (e.g., POSTGRES_USER, POSTGRES_DB). Also, for MySQL, if the root user has no password for ping, you can omit --password.

2. Alternative: Custom Entrypoint Script (for older Compose or fine-grained control)

If you’re using an older Docker Compose version (before 3.4) or prefer to have more control within your application’s container, a custom entrypoint script is a robust solution. This script will repeatedly try to connect to the database until successful, then hand over execution to your main application command.

a. Create a wait-for-db.sh script: Create a file named wait-for-db.sh in the root of your application’s directory (next to your Dockerfile).

#!/bin/sh
# wait-for-db.sh

set -e

host="$1"
shift
cmd="$@"

until PGPASSWORD="$POSTGRES_PASSWORD" psql -h "$host" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c '\q'; do
  >&2 echo "Postgres is unavailable - sleeping"
  sleep 1
done

>&2 echo "Postgres is up - executing command"
exec $cmd

For MySQL, replace the psql line with:

until mysql -h "$host" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1;" > /dev/null 2>&1; do
  >&2 echo "MySQL is unavailable - sleeping"
  sleep 1
done

[!IMPORTANT] Make sure psql (for PostgreSQL) or mysql client (for MySQL) is installed in your application’s Docker image. You’ll likely need to add it to your Dockerfile. For example, in a Debian/Ubuntu-based image: apt-get update && apt-get install -y postgresql-client.

b. Update your Dockerfile: Add the script to your application’s image and make it executable.

# Dockerfile
FROM python:3.9-slim-buster

WORKDIR /app

# Install PostgreSQL client for wait-for-db.sh (example for Debian/Ubuntu base)
RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Copy and make the wait-for-db.sh script executable
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
RUN chmod +x /usr/local/bin/wait-for-db.sh

# This example assumes you have an ENTRYPOINT defined for your application already.
# If not, you can define it here.
# ENTRYPOINT ["./entrypoint.sh"] # if you have another entrypoint script

EXPOSE 8000
# CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] # Your original CMD

c. Update your docker-compose.yml: Modify the web service to use the new wait-for-db.sh script as its entrypoint.

# docker-compose.yml
version: '3.8'

services:
  db:
    image: postgres:14-alpine
    restart: always
    environment:
      POSTGRES_DB: mydatabase
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
    volumes:
      - db_data:/var/lib/postgresql/data
    # No healthcheck needed here if the web service handles the waiting

  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://myuser:mypassword@db:5432/mydatabase
      # Pass DB credentials to the script if they're not in DATABASE_URL or a config file
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
      POSTGRES_DB: mydatabase
    depends_on:
      - db # Here, depends_on just ensures the container starts first.
    entrypoint: ["/usr/local/bin/wait-for-db.sh", "db", "python", "manage.py", "runserver", "0.0.0.0:8000"]
    # For Gunicorn/production:
    # entrypoint: ["/usr/local/bin/wait-for-db.sh", "db", "gunicorn", "myapp.wsgi:application", "--bind", "0.0.0.0:8000"]

volumes:
  db_data:

[!NOTE] When using an entrypoint in docker-compose.yml, it overrides any ENTRYPOINT defined in the Dockerfile. The command in docker-compose.yml (or CMD in Dockerfile) then becomes arguments to this entrypoint. If you already have an ENTRYPOINT in your Dockerfile, you might need to adapt wait-for-db.sh to call your original entrypoint after the DB is ready. A simpler approach is to use command to call the wait-for-db.sh and pass your original command as arguments.

3. Test and Verify the Solution

After implementing one of the above solutions, rebuild your services and observe the logs:

docker-compose down # Stop and remove old containers/networks
docker-compose build --no-cache # Rebuild images to ensure new scripts/configs are included
docker-compose up --build # Start fresh
  • For healthcheck + service_healthy:

    • You should see the db container briefly show (health: starting) in docker-compose ps.
    • Eventually, it will switch to (health: healthy).
    • Only then will the web service start, and its logs should show successful database connections.
    • Check docker-compose logs db and docker-compose logs web.
  • For custom entrypoint script:

    • docker-compose logs web will show messages like “Postgres is unavailable - sleeping…” for a few seconds.
    • Once the database is ready, you’ll see “Postgres is up - executing command” followed by your application’s normal startup logs.

Both methods effectively defer your application’s startup until its critical dependencies are truly ready, resolving the “Docker Compose depends_on service not waiting for database ready” issue. Choose the method that best fits your Docker Compose version and architectural preferences. For new projects, the healthcheck and service_healthy approach is generally cleaner and more declarative.