← All blogs
  • Docker Compose
  • Control Startup

Your Docker Container Is Running. So Why Can’t Your App Connect to the Database?

Understand service readiness, healthchecks, and depends_on in Docker Compose when an app starts before its database is ready.

Originally published on Medium ↗. Read the full article below.

Understanding service readiness, healthchecks, and depends_on in Docker Compose

Your Docker Container Is Running. So Why Can’t Your App Connect to the Database?

One of the most common problems when developing applications with Docker Compose looks deceptively simple:

Connection refused

You check your containers:

docker compose ps

Your database container is running.

Your application container is running.

Everything appears to be fine.

Yet your application still can’t connect to the database.

What’s going on?

The answer is an important distinction that every developer working with containers should understand:

A container being started does not necessarily mean the service inside it is ready.

This article explains why this happens and how Docker Compose healthchecks can solve it cleanly.

The Problem

Imagine a simple application architecture:

Docker Compose
│
├── application
│
└── PostgreSQL

The application needs PostgreSQL during startup, perhaps to:

  • Run database migrations
  • Initialize tables
  • Load initial data
  • Establish a connection pool

A typical Compose configuration might look like:

services:
  postgres:
    image: postgres:18-alpine
  api:
    build: .
    depends_on:
      - postgres

At first glance, this seems reasonable.

You might expect Docker Compose to do this:

Start PostgreSQL
      ↓
Wait until PostgreSQL is ready
      ↓
Start API

But that isn’t what the basic dependency declaration guarantees.

The API may start while PostgreSQL is still initializing.

The actual sequence can look like this:

Start PostgreSQL
      ↓
Start API
      ↓
API tries to connect
      ↓
❌ Connection refused
      ↓
PostgreSQL finishes initialization
      ↓
PostgreSQL is ready

By then, your API may already have crashed.

depends_on Is Not the Same as "Ready"

This is the key concept.

Consider:

depends_on:
  - postgres

This expresses a dependency:

Start the postgres service before the api service.

But there is a difference between:

Container lifecycle

and

Application readiness

A PostgreSQL container can exist and be running while PostgreSQL is still:

  • Creating its database cluster
  • Applying initialization scripts
  • Starting the database server
  • Opening its network socket

So these two states are not equivalent:

Container is running

and:

PostgreSQL is ready to accept connections

That distinction is the source of many startup race conditions.

What Is a Healthcheck?

Docker provides a mechanism specifically designed to answer a simple question:

Is this service actually ready?

That’s a healthcheck.

For PostgreSQL, the standard tool is:

pg_isready

It checks whether PostgreSQL is accepting connections.

We can add a healthcheck to our Compose configuration:

services:
  postgres:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U root -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5

Now Docker can track the database’s health.

Conceptually:

PostgreSQL container
       │
       ▼
   Healthcheck
       │
       ├── unhealthy
       │
       └── healthy

That’s much more useful than simply knowing whether the container process exists.

Combining Healthchecks With depends_on

Now comes the important part.

Instead of:

depends_on:
  - postgres

we can use:

depends_on:
  postgres:
    condition: service_healthy

Our application service becomes:

api:
  build: .
  depends_on:
    postgres:
      condition: service_healthy

Now the dependency has more meaning:

Start PostgreSQL
      ↓
Run healthcheck
      ↓
Is PostgreSQL healthy?
      │
      ├── No → Keep waiting
      │
      └── Yes
           ↓
       Start API

This eliminates the startup race condition in a much cleaner way.

A Complete Example

Here’s a generic Docker Compose configuration:

services:
  postgres:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U root -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      DB_SOURCE: postgres://root:secret@postgres:5432/myapp?sslmode=disable
    depends_on:
      postgres:
        condition: service_healthy

Notice another important detail:

postgres

is used as the hostname.

Inside a Docker Compose network, services can communicate using their service names.

So the application connects to:

postgres:5432

rather than:

localhost:5432

That’s because localhost inside the API container refers to the API container itself, not the PostgreSQL container.

What About Database Migrations?

This pattern becomes particularly useful when your application runs migrations during startup.

For example, you might have a startup script:

#!/bin/sh
set -e
echo "Running database migrations..."
migrate \
  -path /app/migrations \
  -database "$DB_SOURCE" \
  -verbose up
echo "Starting application..."
exec "$@"

The startup sequence becomes:

PostgreSQL starts
       ↓
PostgreSQL initializes
       ↓
Healthcheck passes
       ↓
Application starts
       ↓
Migration runs
       ↓
Migration succeeds
       ↓
Application starts serving requests

This is significantly more reliable than having the application immediately attempt a database connection while the database is still starting.

What About wait-for.sh?

You may have seen another popular solution:

wait-for.sh
wait-for-it.sh
dockerize
custom retry loops

These approaches are often used to solve service startup ordering.

For example:

Application starts
       ↓
wait-for.sh
       ↓
Wait for PostgreSQL
       ↓
Run migration
       ↓
Start application

This can work.

But there is an important question to ask:

Should the application be responsible for understanding Docker service readiness?

When you’re using Docker Compose, Compose already has mechanisms for expressing service dependencies and health.

For a straightforward Compose setup, using:

healthcheck:
  ...

and:

depends_on:
  postgres:
    condition: service_healthy

is generally cleaner and more declarative.

Instead of writing additional shell logic, you’re describing the relationship directly in your infrastructure configuration.

Declarative vs. Imperative

This is a useful way to think about the difference.

A shell script approach says:

“Run this command repeatedly until the database works.”

That’s imperative.

A Compose healthcheck approach says:

“PostgreSQL is healthy when this check succeeds, and the API depends on PostgreSQL being healthy.”

That’s declarative.

The second approach describes what the system requires, rather than embedding the orchestration logic inside the application startup process.

But Does service_healthy Solve Everything?

No.

This is an important distinction.

A healthcheck solves service readiness at startup.

It doesn’t automatically solve every possible database connectivity problem.

For example, after the application has started, PostgreSQL could still:

  • Restart
  • Become temporarily unavailable
  • Lose its network connection
  • Experience resource exhaustion

Your application should still have appropriate database error handling and, depending on the architecture, retry logic.

So don’t think of:

condition: service_healthy

as a replacement for robust application behavior.

Think of it as solving a specific problem:

Don’t start this dependent service until its dependency is ready.

Healthchecks Are Useful Beyond Databases

The same concept applies to many services.

Redis

A healthcheck can verify that Redis is responding.

HTTP services

You can check an endpoint such as:

/health

Message queues

You can verify that the broker is accepting connections.

Custom applications

You can expose a health endpoint:

GET /health

and let Docker use it to determine whether the service is healthy.

The general architecture becomes:

Service starts
      ↓
Healthcheck
      ↓
Service ready?
      │
      ├── No
      │
      └── Yes
           ↓
    Dependent services

A Small Detail That Matters: Healthcheck Design

A healthcheck should test something meaningful.

For PostgreSQL:

test: ["CMD-SHELL", "pg_isready -U root -d myapp"]

is better than simply checking whether a process exists.

Why?

Because we’re interested in:

“Can PostgreSQL accept connections?”

not:

“Does a PostgreSQL process exist?”

The closer your healthcheck is to the actual dependency requirement, the more useful it becomes.

Don’t Ignore Application-Level Readiness

There’s another subtle distinction worth mentioning.

A service can be:

Process running

but not necessarily:

Application ready

For example, a web application might start its process before:

  • Loading configuration
  • Connecting to dependencies
  • Loading models
  • Warming caches
  • Initializing background workers

That’s why production systems often expose explicit readiness endpoints.

For example:

GET /health

might answer:

{
  "status": "ok"
}

The healthcheck then represents the actual state that other services care about.

The Mental Model to Remember

Whenever you build a multi-container system, think about three different concepts:

1. Created

The container exists.

CREATED

2. Running

The container’s main process is running.

RUNNING

3. Ready

The service is actually capable of handling requests.

HEALTHY

These aren’t the same thing.

A common mistake is assuming:

RUNNING = READY

But in real systems:

RUNNING ≠ READY

That’s the lesson behind many “connection refused” errors during container startup.

Key Takeaways

If your application depends on another container:

  1. Don’t assume that a running container means the service is ready.
  2. Use a meaningful healthcheck for the dependency.
  3. Use:
depends_on:
  service:
    condition: service_healthy

when you need Compose to wait for that dependency.

  1. Use service names for container-to-container communication:
postgres:5432

rather than:

localhost:5432
  1. Keep application-level retry and error handling where appropriate.

  2. Treat startup ordering and runtime resilience as two separate problems.

Final Thoughts

Containerization doesn’t eliminate distributed-system problems.

It often makes them more visible.

A database and an API may run on the same machine, but once they’re placed in separate containers, they have separate lifecycles. The API cannot simply assume that the database is ready because its container has started.

That’s why healthchecks are so useful.

Instead of:

"PostgreSQL container started, so let's connect."

we can express the actual requirement:

"Start the API when PostgreSQL is healthy."

That small change can turn a fragile startup sequence into a predictable one.

And sometimes, the best solution to a startup race condition isn’t another script.

It’s describing the dependency correctly.