Containers Advanced

Resolving Docker Compose Service Startup Race: Database Readiness on Alpine Linux

Learn why Docker Compose's depends_on isn't enough for database readiness on Alpine Linux and implement robust startup dependency checks.

👨‍💻
Senior Systems Architect • Verified in Staging Labs

Learn why Docker Compose's depends_on isn't enough for database readiness on Alpine Linux and implement robust startup dependency checks.

When deploying multi-service applications with Docker Compose, especially those involving a database, it's a common misconception that depends_on guarantees service readiness. Many developers encounter a race condition where their application container starts, attempts to connect to the database, but fails because the database container, while started, hasn't fully initialized and isn't ready to accept connections. This issue is particularly prevalent and sometimes more challenging to debug on minimal Linux distributions like Alpine, due to their stripped-down nature. This guide will meticulously detail the root cause and provide robust, production-ready solutions.

Symptom & Error Signature

The primary symptom is that your application container fails to start correctly, crashes, or enters a restart loop shortly after your docker-compose up command, while the database container appears to be running. You might observe output similar to the following in your application's logs:

my-app_1  | Traceback (most recent call last):
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 1900, in _execute_context
my-app_1  |     self.dialect.do_connect(self.__connection)
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/default.py", line 737, in do_connect
my-app_1  |     dbapi_connection = self.dbapi.connect(*cargs, **cparams)
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/psycopg2/__init__.py", line 122, in connect
my-app_1  |     conn = _connect(dsn, connection_factory=connection_factory, **kwasync)
my-app_1  | psycopg2.OperationalError: could not connect to server: Connection refused
my-app_1  | 	Is the server running on host "db" (172.18.0.2) and accepting
my-app_1  | 	TCP/IP connections on port 5432?
my-app_1  |
my-app_1  | The above exception was the direct cause of the following exception:
my-app_1  |
my-app_1  | Traceback (most recent call last):
my-app_1  |   File "/app/app.py", line 10, in <module>
my-app_1  |     db.create_all()
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/flask_sqlalchemy/__init__.py", line 1030, in create_all
my-app_1  |     self.db.create_all()
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/sqlalchemy/schema.py", line 4768, in create_all
my-app_1  |     _create_all(self, tables, checkfirst=checkfirst)
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/sqlalchemy/schema.py", line 4741, in _create_all
my-app_1  |     conn.execute(
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 1966, in execute
my-app_1  |     return self._exec_single_context(
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 2102, in _exec_single_context
my-app_1  |     self._handle_dbapi_exception(
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 2283, in _handle_dbapi_exception
my-app_1  |     util.raise_(
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/sqlalchemy/util/compat.py", line 207, in raise_
my-app_1  |     raise exception
my-app_1  |   File "/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py", line 1900, in _execute_context
my-app_1  |     self.dialect.do_connect(self.__connection)
my-app_1  | sqlalchemy.exc.OperationalError: (psycopg2.OperationalError) could not connect to server: Connection refused
my-app_1  | 	Is the server running on host "db" (172.18.0.2) and accepting
my-app_1  | 	TCP/IP connections on port 5432?

This log snippet from a Python application using SQLAlchemy and Psycopg2 clearly indicates a Connection refused error when trying to connect to the db service on port 5432 (PostgreSQL).

Root Cause Analysis

The core of this problem lies in a common misunderstanding of how Docker Compose's depends_on functionality works:

  1. depends_on only ensures container start order: When you specify depends_on in your docker-compose.yml, Docker Compose ensures that the dependent service's container is started before the service that depends on it. It does not wait for the service inside that container to be fully initialized and ready to accept connections.
  2. Database initialization takes time: Database management systems like PostgreSQL or MySQL need time to perform various tasks upon container startup:
    • Initializing data directories (if not persistent).
    • Applying migrations or schema changes.
    • Starting the database daemon.
    • Listening on the specified port.
    • Authenticating users. These processes can take anywhere from a few seconds to several minutes, depending on the database size and server load.
  3. Race condition: Your application container starts, sees that the db container is running (as depends_on ensured), and immediately tries to establish a connection. If the database daemon hasn't finished its startup routine and isn't listening on the port, the connection attempt fails, leading to the Connection refused error.
  4. Alpine Linux considerations: Alpine is a minimal distribution. While this is great for small image sizes, it means many common utilities (like bash, curl, netcat, iputils) are not pre-installed. If your readiness script relies on bash-specific syntax or tools not available by default, you'll encounter additional failures. Ensuring sh compatibility or explicitly installing dependencies (like bash or busybox-extras for nc) is crucial.

Step-by-Step Resolution

The most robust solution involves implementing a custom "wait-for-it" mechanism directly within your application's startup sequence. This ensures that the application only proceeds once the database port is actually open and responsive.

1. Implement a wait-for-it Script

A widely adopted and effective solution is to use a small shell script that repeatedly tries to connect to the database host and port until successful. We will use a POSIX-compliant script that works well on Alpine's default ash shell.

a. Add wait-for-it.sh to your project

Create a file named wait-for-it.sh in the root of your application's directory (or a scripts subdirectory):

#!/usr/bin/env sh
# wait-for-it.sh

set -e

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

until nc -z "$host" "$port"; do
  >&2 echo "Database is unavailable - sleeping"
  sleep 1
done

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

The nc (netcat) utility is crucial for this script. Alpine Linux's default busybox distribution often provides nc, but if you encounter "nc: not found", you may need to explicitly install busybox-extras in your Dockerfile, which often includes a more feature-rich nc. apk add --no-cache busybox-extras

b. Update your application's Dockerfile

Modify your application's Dockerfile to copy this script into the container and make it executable.

# Use an Alpine-based image for your application
FROM python:3.9-alpine

# Install any dependencies your application needs.
# For Python, this usually involves pip, virtualenv, etc.
# Also, install busybox-extras for 'nc' if not already present,
# and potentially 'postgresql-dev' or 'mysql-client-dev' for database drivers.
RUN apk add --no-cache python3-dev 
    gcc 
    musl-dev 
    postgresql-dev 
    # Ensure nc is available for the wait-for-it script
    busybox-extras

WORKDIR /app

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

# Copy your application requirements and install them
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy your application code
COPY . .

# Expose ports, define entrypoint, etc.
EXPOSE 8000

# The CMD will be overridden by docker-compose.yml
# CMD ["python", "app.py"]
c. Modify your docker-compose.yml

Now, update the command or entrypoint for your application service in docker-compose.yml to execute wait-for-it.sh before starting your main application.

version: '3.8'

services:
  db:
    image: postgres:13-alpine # Or mysql:8-alpine
    restart: always
    environment:
      POSTGRES_DB: mydatabase
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
    volumes:
      - db-data:/var/lib/postgresql/data # Or /var/lib/mysql for MySQL
    ports:
      - "5432:5432" # Expose for local access if needed

  my-app:
    build: . # Build from the Dockerfile in the current directory
    restart: always
    environment:
      DATABASE_URL: postgresql://myuser:mypassword@db:5432/mydatabase
    # Use depends_on for orchestration, but rely on wait-for-it for readiness
    depends_on:
      - db
    # The crucial part: run wait-for-it.sh before your app command
    command: ["/usr/local/bin/wait-for-it.sh", "db", "5432", "--", "python", "app.py"]
    ports:
      - "8000:8000"

volumes:
  db-data:

In the command for my-app, db is the service name (which resolves to the container's IP address) and 5432 is the database port. The -- argument separates the wait-for-it.sh arguments from the command that should be executed after the database is ready (python app.py in this case).

2. Consider Application-Level Retry Logic (Most Robust)

While wait-for-it scripts are excellent for orchestrating startup, the most robust solution for production environments is to implement connection retry logic directly within your application code. This handles not only initial startup but also transient network issues or database restarts during runtime.

Here's a conceptual example in Python using a common retry library:

# app.py
import os
import time
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import OperationalError
from tenacity import retry, stop_after_attempt, wait_fixed, before_log, after_log

import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@localhost:5432/mydb")
engine = create_engine(DATABASE_URL)
Session = sessionmaker(bind=engine)

@retry(stop=stop_after_attempt(10), # Try 10 times
       wait=wait_fixed(3),           # Wait 3 seconds between attempts
       before=before_log(logger, logging.INFO),
       after=after_log(logger, logging.INFO),
       retry_error_cls_types=(OperationalError,)) # Only retry on OperationalError
def initialize_database():
    logger.info("Attempting to connect to database...")
    try:
        # This will raise OperationalError if connection fails
        with engine.connect() as connection:
            connection.execute("SELECT 1")
        logger.info("Database connection successful!")
        # Example: create tables
        # Base.metadata.create_all(engine)
    except OperationalError as e:
        logger.warning(f"Database connection failed: {e}. Retrying...")
        raise # Re-raise to trigger tenacity retry

if __name__ == "__main__":
    initialize_database()
    logger.info("Application starting...")
    # Your main application logic here
    while True:
        time.sleep(10) # Keep the app running for demonstration

Implementing application-level retry logic requires modifying your application's source code. This is often preferred in production as it provides the most granular control and resilience, but it's more involved than shell scripts for initial setup. Ensure your application's base image includes any necessary development headers (e.g., postgresql-dev for psycopg2) for database driver compilation if you're building a Python or Ruby application from source on Alpine.

3. Using dockerize (Alternative to wait-for-it.sh)

dockerize is another popular utility specifically designed for this problem. It's a Go binary that you can add to your Docker image.

a. Add dockerize to your Dockerfile
FROM python:3.9-alpine

# Install any dependencies your application needs.
RUN apk add --no-cache python3-dev gcc musl-dev postgresql-dev

WORKDIR /app

# Download and install dockerize
ENV DOCKERIZE_VERSION v0.6.1
RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz 
    && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz 
    && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz

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

COPY . .

EXPOSE 8000
b. Modify your docker-compose.yml
version: '3.8'

services:
  db:
    image: postgres:13-alpine
    restart: always
    environment:
      POSTGRES_DB: mydatabase
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
    volumes:
      - db-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  my-app:
    build: .
    restart: always
    environment:
      DATABASE_URL: postgresql://myuser:mypassword@db:5432/mydatabase
    depends_on:
      - db
    # Use dockerize to wait for the database before starting your app
    command: ["dockerize", "-wait", "tcp://db:5432", "-timeout", "60s", "python", "app.py"]
    ports:
      - "8000:8000"

volumes:
  db-data:

dockerize provides more features like waiting for HTTP endpoints or files, making it a versatile tool. Ensure you use a version compatible with Alpine Linux (dockerize-alpine-linux-amd64). The -timeout flag is crucial to prevent your service from waiting indefinitely if the database truly fails to start.

By implementing one of these solutions, you effectively decouple the container startup order from the service readiness, thus resolving the common race condition and ensuring your application starts reliably with a ready database.

👨‍💻

Johnathon Wheeler

Senior Systems Architect & DevOps Engineer • Austin, TX

Connect on LinkedIn

Johnathon has over 16 years of hands-on experience designing, debugging, and scaling Linux web hosting stacks, container clusters, and high-availability database architectures. Every guide on ButItWorkedLocal is independently tested against Debian 12, Ubuntu 24.04/22.04 LTS, Rocky Linux, and Docker environments to guarantee reproducibility in production.

🛡️

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.