You write a shiny compose.yml, run docker compose up -d, and everything starts at once. Postgres is still initializing when your web app tries to connect. The migration container runs before the database accepts queries. Redis is listed as a dependency, but your cache-dependent service crashes three times before it finds the port open.
This is the startup order problem — and it is one of the most common pain points in homelab Docker deployments.
Docker Compose gives you the tools to solve it, but the defaults (depends_on without conditions) only guarantee container start order, not service readiness. This guide covers every option from basic to advanced: depends_on conditions, healthcheck-based gating, init container patterns, and the classic wait-for-it script approach.
depends_on — What It Actually Does and Does Not Do
The simplest form of depends_on tells Compose to start one container after another:
|
|
Without any condition, depends_on only guarantees that the db container is started before app. It does not wait for Postgres to be ready to accept connections. On a fast machine with local volumes, this usually works. On a homelab with spinning disks or during first boot when databases initialize, it fails reliably.
Compose v2.20+ (included in Docker Desktop 4.25+ and Docker Engine 25+) supports conditional depends_on:
|
|
Three conditions are available:
service_started— the default; starts after the dependency container is runningservice_healthy— waits until the dependency passes its healthcheckservice_completed_successfully— waits until the dependency runs and exits with code 0 (init container pattern)
The service_healthy and service_completed_successfully conditions are what turn depends_on from a simple ordering hint into a real readiness gate.
Healthcheck-Based Startup Ordering
The most reliable pattern for production is pairing depends_on with healthcheck on the dependency service. Docker runs the healthcheck command periodically and marks the container as healthy or unhealthy. The dependent service waits until healthy before starting.
Here is a complete example with Postgres, Redis, and a web application:
|
|
Key decisions in this config:
start_period: 30sgives Postgres time to initialize on first boot before healthcheck failures count towardretries. Without this, a cold Postgres init could exhaust retries before the server is ready.interval: 5sbalances quick detection with reasonable overhead — every 5 seconds is fine for homelab, even with many containers.restart: unless-stoppedensures the application retries if it fails while waiting for dependencies. Compose starts the app, the healthcheck is still pending, the app crashes, Docker restarts it, and eventually the healthcheck passes.
Starting Additional Dependencies by Service Type
Homelab stacks often need MariaDB or MySQL instead of Postgres:
|
|
MariaDB’s official image bundles healthcheck.sh with --innodb_initialized — safer than a raw MySQL ping because it also verifies InnoDB recovery completed.
Init Container Pattern in Docker Compose
Kubernetes has native init containers: they run to completion before the main pod starts. Docker Compose has no built-in init container type, but you can simulate the pattern cleanly with depends_on and condition: service_completed_successfully.
Use this for:
- Database schema migrations (Alembic, Flyway, Prisma)
- Seed data loading
- Terraform / Pulumi provisioning
- File permission setup on shared volumes
Here is a real example with Alembic migrations for a FastAPI app:
|
|
The chain is: postgres becomes healthy → migrations runs alembic upgrade head and exits 0 → app starts knowing the schema is current.
For multi-step init, chain multiple init services:
|
|
The wait-for-it and wait-for Scripts
Not every image ships with a healthcheck. For third-party images you cannot modify, the wait-for-it.sh script (or the simpler wait-for) is a practical fallback.
wait-for-it
Available at vishnubob/wait-for-it, this bash script blocks until a TCP port is open:
|
|
The script polls db:5432 until it accepts a TCP connection, then executes the command.
wait-for (Efficient Python Alternative)
The wait-for script is a shorter, POSIX-sh alternative that works on Alpine:
|
|
Important: Port-open checks have a race condition. The TCP port can be open before the service is ready (Postgres immediately opens the port, but may reject queries for several seconds during crash recovery). Healthchecks are strictly better. Use port-waiting only when you cannot add a healthcheck.
Advanced Patterns for Complex Stacks
Chained Dependencies
|
|
Conditional Startup with Profiles
Use Compose profiles to control which services start normally vs. on-demand:
|
|
Run migrations explicitly: docker compose --profile setup run migrations. Without the profile flag, migrations is skipped, and app starts immediately. This pattern is useful when you manage migrations manually.
Handling Stale Health States
Occasionally Compose caches a stale health check status. When this happens, a container shows as healthy when it is not:
|
|
If the issue persists, check the health status explicitly:
|
|
For a full reset of all health state, restart the Docker daemon — though this is rarely needed.
Summary and Best Practices
Here is a decision tree for Docker Compose startup ordering in your homelab:
| Situation | Pattern | Condition |
|---|---|---|
| Dependencies start fast (local Alpine images, no init) | depends_on: (bare) |
service_started |
| Database needs to accept queries | healthcheck + depends_on condition |
service_healthy |
| Migration must run before app starts | Init container | service_completed_successfully |
| Third-party image, no healthcheck | wait-for-it.sh |
Port check |
| Complex multi-service dependency chain | Chained healthchecks | service_healthy each |
Start with healthchecks and depends_on condition: service_healthy. This covers 80% of homelab scenarios. Add the init container pattern for database migrations. Use wait-for-it only as a last resort for images you cannot modify.
Set reasonable start_period values — 30-60 seconds for databases, 10 seconds for caches. Use restart: unless-stopped on all services that have dependencies so they retry automatically after a crash.
Your Compose stack will boot reliably every time, whether it is the first deploy on bare metal or a restart after a power outage.