Service Dependencies: depends_on, Healthcheck Conditions, a Migration Service and Restart
Short depends_on only sets the start order; waiting for a service to be ready needs a health check and condition: service_healthy, and waiting for a one-off job needs service_completed_successfully. We write a health check for SQL Server, move the schema scripts into a migrate service and into Compose 5.3's pre_start init container, and test restart policies and SqlClient retry logic with real outage measurements.
Version note: The examples in this post were run with Docker Compose 5.7.1, Docker Engine 29.9.3, SQL Server 2025 (RTM-CU11), .NET 10 and Microsoft.Data.SqlClient 7.1.2.
Guess First
You add depends_on: [db] to the API in the previous post's compose.yaml, delete the volume and run docker compose up -d. Compose starts the database first, then the API. Does the API's first request succeed? Second question: while everything is running, the SQL Server process crashes and the container comes back within a few seconds thanks to restart: unless-stopped. What does a request arriving in those few seconds get: a 500, or a wait followed by a 200?
What Does Short depends_on Guarantee?
We tried it. We added a short-form dependency to the api service, deleted the volume and started everything; a loop called /api/books every second:
api:
...
depends_on:
- db
docker inspect composeseries-db-1 composeseries-api-1 --format "{{.Name}} {{.State.StartedAt}}"
docker compose logs db | grep "ready for client"
/composeseries-db-1 2026-11-14T13:23:45.040984055Z
/composeseries-api-1 2026-11-14T13:23:45.091115846Z
db-1 | 2026-11-14 13:23:51.29 spid43s SQL Server is now ready for client connections. This is an informational message; no user action is required.
The API started 50 milliseconds after the database container; SQL Server was ready to accept connections 6 seconds later. The first request got a 502 (the API was not listening yet), the following ones a 500: first they waited for SQL Server, then Login failed for user 'bookstore_app', because the schema and the login still do not exist. The short form only gives a start order: Compose starts the db container and moves straight on to the next one. The way to tell Compose the difference between "the container started" and "the application inside is ready to serve" is a health check.
A Health Check for SQL Server
db:
image: mcr.microsoft.com/mssql/server:2025-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD}
volumes:
- db-data:/var/opt/mssql
ports:
- "127.0.0.1:1433:1433"
healthcheck:
# $$ reaches the container as $: the shell inside the container expands the variable, not Compose
test: SQLCMDPASSWORD="$$MSSQL_SA_PASSWORD" /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -b -Q "SELECT 1" -o /dev/null
interval: 10s
timeout: 5s
retries: 5
start_period: 60s
start_interval: 2s
restart: unless-stopped
The SQL Server image has no HEALTHCHECK in its Dockerfile, so we write the test. sqlcmd lives under /opt/mssql-tools18/bin in the image (older images had /opt/mssql-tools). -C tells it to trust the server's self-signed certificate; sqlcmd in mssql-tools18 encrypts the connection by default, so without this flag the test fails at certificate validation. -b returns a non-zero exit code on an SQL error, -Q "SELECT 1" runs the query and exits. When the test is written as a string, Compose runs it in CMD-SHELL form, with the shell inside the container.
The detail most easily missed is $$. While reading the file, Compose tries to expand $MSSQL_SA_PASSWORD itself; $$ means "this dollar sign is not for me" and reaches the container as a single $. That way the password is not written into the health check definition shown by docker inspect; every time the test runs, it is read from the container's own environment variable. Passing the password with SQLCMDPASSWORD instead of -P also keeps it out of the process list.
The timing settings are read together. During start_period (60 s), failed tests do not count towards retries: SQL Server takes time to prepare the system databases on first start, and we do not want the container to be considered unhealthy in the meantime. start_interval (2 s) is how often the test runs during that start period; it exists since Docker Engine 25 and notices that the container is ready without waiting for the 10-second interval. After the start period the test runs every 10 seconds, counts as failed if it does not finish within 5 seconds, and after 5 failures in a row the state becomes unhealthy.
condition: service_healthy and a migrate Service
In the previous post we ran the schema scripts by hand with docker compose exec. We turned that step into a one-off service:
# One-off: creates BookstoreDb and the app login, then exits with 0
migrate:
image: mcr.microsoft.com/mssql/server:2025-latest
entrypoint: ["/opt/mssql-tools18/bin/sqlcmd"]
command: ["-C", "-S", "db", "-U", "sa", "-b", "-i", "/scripts/01-bookstore.sql", "-i", "/scripts/02-app-login.sql"]
environment:
SQLCMDPASSWORD: ${MSSQL_SA_PASSWORD}
BOOKSTORE_DB_PASSWORD: ${BOOKSTORE_DB_PASSWORD}
volumes:
- ./db:/scripts:ro
depends_on:
db:
condition: service_healthy
api:
...
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped
web:
...
depends_on:
api:
condition: service_healthy
restart: unless-stopped
migrate uses the SQL Server image only for the sqlcmd inside it: entrypoint runs the client instead of starting the server, and -S db connects to the db service on the network. The image is already downloaded, so there is no extra pull. The three conditions in the long form: service_started is what the short form means, service_healthy waits for the dependency's health check to be healthy, service_completed_successfully waits for the dependency to finish with exit code 0. We did not write anything extra for web to wait for the API: Bookstore.Api's Dockerfile has a HEALTHCHECK left over from the Docker series, and Compose uses it.
In this series Bookstore's schema is not EF Core migrations but the idempotent SQL scripts from the Docker series. In a project that uses EF Core, the same migrate service runs the migration bundle (efbundle); the bundle itself is covered in the EF Core series post "EF Core Migrations: migrations add, database update, Scripts and Bundles". The pattern is the same: a one-off container waits for a healthy database, does its job, exits with 0; the API waits for it. Looking at the startup script of the SQL Server 2025 image, we saw one more option: scripts placed in the /mssql-server-setup-scripts.d folder are run only while the data folder is empty, that is, only on the first start. That is handy for the initial setup, but it does not apply schema changes that come with later releases, so it does not replace a migration.
docker compose down -v
docker compose up -d
docker compose ps -a --format "table {{.Service}}\t{{.Status}}"
Container composeseries-db-1 Starting
Container composeseries-db-1 Started
Container composeseries-db-1 Waiting
Container composeseries-db-1 Healthy
Container composeseries-migrate-1 Starting
Container composeseries-migrate-1 Started
Container composeseries-migrate-1 Waiting
Container composeseries-migrate-1 Exited
Container composeseries-api-1 Starting
Container composeseries-api-1 Started
Container composeseries-api-1 Waiting
Container composeseries-api-1 Healthy
Container composeseries-web-1 Starting
Container composeseries-web-1 Started
SERVICE STATUS
api Up 5 seconds (healthy)
db Up 15 seconds (healthy)
migrate Exited (0) 5 seconds ago
web Up Less than a second
The chain now runs in order and up -d returned in 16 seconds; the first request came back with 6 books. The Exited (0) state of migrate is not an error, it is a one-off container that finished its job. You need -a to see it in ps.
What happens when a script fails? We passed an application password that does not meet the policy (a variable in the shell overrides the one in .env):
BOOKSTORE_DB_PASSWORD=123 docker compose up -d; echo "exit code: $?"
docker compose logs migrate | tail -2
Container composeseries-migrate-1 Error service "migrate" didn't complete successfully: exit 1
Container composeseries-api-1 not started: condition "service_completed_successfully" of service "migrate" not met
service "migrate" didn't complete successfully: exit 1
exit code: 1
migrate-1 | Msg 33062, Level 16, State 2, Server sql1, Line 5
migrate-1 | Password validation failed. The password does not meet SQL Server password policy requirements because it is too short. The password must be at least 8 characters.
Compose stopped the chain and returned a non-zero exit code; a CI step can catch that. But looking at ps shows something more important: the state of api is Created. Because the password in the connection string changed, Compose recreated the API container; the old container is gone and the new one was never started because the condition was not met. web is up, but there is no API behind it. A failed migration can take down a running system too; we come back to this in the production deploy flow in post 6.
The Compose 5.3 Way: pre_start
A separate migrate service works, but it means an extra service in the model, an Exited (0) line in ps -a forever and a container that runs again on every up. Compose 5.3 (July 2026) implemented the pre_start hook from the Compose Specification: one-off containers that run in order before the service's container starts and must finish with 0, in other words the counterpart of init containers in Kubernetes. We wrote the same step as an init container of the API (labs/compose/02-pre-start.yaml):
services:
api:
depends_on: !override
db:
condition: service_healthy
pre_start:
- image: mcr.microsoft.com/mssql/server:2025-latest
entrypoint: ["/opt/mssql-tools18/bin/sqlcmd"]
command: ["-C", "-S", "db", "-U", "sa", "-b", "-i", "/scripts/01-bookstore.sql", "-i", "/scripts/02-app-login.sql"]
environment:
SQLCMDPASSWORD: ${MSSQL_SA_PASSWORD}
BOOKSTORE_DB_PASSWORD: ${BOOKSTORE_DB_PASSWORD}
volumes:
- ./db:/scripts:ro
docker compose down -v
docker compose -f compose.yaml -f labs/compose/02-pre-start.yaml up -d db api web
curl -s localhost:8080/api/books | jq length
6
If no image is given for a hook, the service's own image is used; the API's chiseled image has no sqlcmd, so we gave the SQL Server image. The hook is attached to the service's networks and inherits the service's container definition: in docker compose config, the hook's environment also shows the API's connection string. We tested the behavior: as long as the definition does not change, it does not run again on later ups; with the password set to 123 again, the error service "api" pre_start[0] exited with code 1 and the hook's output came straight to the terminal, the failed hook container (composeseries-api-pre_start-0) was kept for inspection and cleaned up on the next successful up. The API was again recreated but not started.
Which one to choose? pre_start simplifies the model but needs Compose 5.3; Docker Desktop 4.99 ships Compose 5.7.0, so Windows is fine, but the Compose on a server or a CI runner may be older. The snapshot keeps the migrate service, which works with every Compose 2.x release, and the pre_start version is under labs/. If you move to Kubernetes, both patterns will look familiar: the Kubernetes series covers migrations as an init container and as a Job.
Retries in the Application Anyway
depends_on only applies at startup, during docker compose up. If the database restarts while running, nobody tells the API. We measured the second question: labs/compose/02-probe.sh calls /api/books every half second and runs the given command meanwhile. First a crash: we killed the SQL Server process inside the container with kill -9.
labs/compose/02-probe.sh 70 'docker compose exec db sh -c "kill -9 \$(ps -o pid= -C sqlservr)"'
docker inspect composeseries-db-1 --format "restarts={{.RestartCount}} health={{.State.Health.Status}}"
1s 200 6703ms
restarts=1 health=healthy
The container exited, restart: unless-stopped restarted it (RestartCount=1) and all 70 requests returned 200; only one took 6.7 seconds. While opening a connection, SqlClient keeps trying until the Connect Timeout (15 seconds by default) runs out, and the server came back within that time, so the request succeeded. When we ran the same experiment with docker compose kill db, the container did not restart: Docker does not apply the restart policy to a container that was stopped or killed by hand. A restart policy protects against crashes and Docker service restarts, not against your own commands.
Now an outage longer than 15 seconds: we stopped the database and started it again 20 seconds later.
labs/compose/02-probe.sh 50 "docker compose stop db; sleep 20; docker compose start db"
11s 500 14682ms
26s 200 10959ms
api-1 | Microsoft.Data.SqlClient.SqlException (0x80131904): A network-related or instance-specific error occurred while establishing a connection to SQL Server. ...
api-1 | Error Number:11001,State:0,Class:20
One request waited 14.7 seconds and got a 500. The error number is interesting: 11001, "host not found". The stopped container left the network, so Docker's DNS no longer resolves the name db. We added SqlClient's configurable retry logic (available since Microsoft.Data.SqlClient 3.0, off by default) to the connection open step:
// Reads the catalog from BookstoreDb (SQL Server)
public sealed class SqlBookSource : IBookSource
{
private readonly string connectionString;
private readonly SqlRetryLogicBaseProvider retryProvider;
public SqlBookSource(string connectionString, ILogger<SqlBookSource> logger)
{
this.connectionString = connectionString;
// Transient errors while opening the connection are retried with exponential backoff.
// 11001: the db service name does not resolve while its container is stopped or recreated
retryProvider = SqlConfigurableRetryFactory.CreateExponentialRetryProvider(new SqlRetryLogicOption
{
NumberOfTries = 5,
DeltaTime = TimeSpan.FromSeconds(1),
MaxTimeInterval = TimeSpan.FromSeconds(10),
TransientErrors = [.. SqlConfigurableRetryFactory.BaselineTransientErrors, 11001],
});
retryProvider.Retrying += (_, e) => logger.LogWarning(
"SQL connection attempt {Attempt} failed with error {Error}, retrying in {Delay}",
e.RetryCount, (e.Exceptions[^1] as SqlException)?.Number, e.Delay);
}
public async Task<IReadOnlyList<Book>> GetAllAsync(CancellationToken cancellationToken)
{
await using SqlConnection connection = new(connectionString) { RetryLogicProvider = retryProvider };
await connection.OpenAsync(cancellationToken);
...
}
}
builder.Services.AddSingleton<IBookSource>(services =>
new SqlBookSource(connectionString, services.GetRequiredService<ILogger<SqlBookSource>>()));
NumberOfTries is the total number of attempts, including the first one. Giving TransientErrors a list replaces the default list rather than adding to it; BaselineTransientErrors in SqlClient 7.0 gives the 20 default error numbers, so we could add 11001 without copying the list. The same outage with the new image:
11s 200 26441ms
api-1 | warn: Bookstore.Api.SqlBookSource[0]
api-1 | SQL connection attempt 1 failed with error 11001, retrying in 00:00:01.0130000
api-1 | warn: Bookstore.Api.SqlBookSource[0]
api-1 | SQL connection attempt 2 failed with error 11001, retrying in 00:00:03.1710000
api-1 | warn: Bookstore.Api.SqlBookSource[0]
api-1 | SQL connection attempt 3 failed with error 11001, retrying in 00:00:07.3430000
No error, but the request took 26 seconds. Retries turn errors into latency; a user waiting 26 seconds is not always better than a 500. Choose the limits per application and make sure nginx's proxy_read_timeout (60 s by default) is longer than the total retry time. If you use EF Core, EnableRetryOnFailure does the same at the DbContext level; the wider picture of retries, timeouts and circuit breakers is in the upcoming External Service Integration and Resilience series.
In Production
A one-off service runs again on every up: when we rebuilt the API with up -d --build api, Compose also ran migrate again as a dependency. That is why the scripts must be idempotent; the scripts from the Docker series are written with IF ... IS NULL and MERGE, so they are fine. The health check itself has a cost too: every test runs a process in the container, and since the 2-second start_interval only applies at startup, the steady load is one sqlcmd every 10 seconds. Keep the health check cheap and focused on the question "does this service accept requests"; a health check that checks external dependencies marks the whole API unhealthy when the database slows down. The ASP.NET Core Web API series post "Health Checks in ASP.NET Core Web API: Liveness, Readiness and Database Checks" covers the liveness and readiness split.
In CI or in a deploy script, docker compose up -d --wait waits until all services are healthy (those without a health check: running) and returns a non-zero exit code otherwise; we use it as a pipeline step in post 5.
Common Pitfalls
- Believing entrypoint scripts with "wait-for-it" or
sleep 10guarantee the order: an open port does not mean the database is ready, and those scripts only cover the first start; outages at runtime need retries in the application. - Giving
service_healthyto an image without a health check: Compose does not wait, it fails. We tried it with the Redis image:dependency failed to start: container composeseries-nohc-cache-1 has no healthcheck configured. - Writing
$instead of$$in the health check: Compose expands the variable while reading the file, and the password ends up in the container definition and therefore indocker inspectoutput; if the variable is missing on the host, the test fails forever with an empty password. - Keeping
start_periodshort: under emulation or on a slow disk, SQL Server's first start can take a minute and the container is markedunhealthybefore it is even up. - Forgetting that a migration failure can stop the API too: if the dependent service's definition changed, Compose recreates it and does not start the new one when the condition is not met.
- Expecting the restart policy to bring back a container you stopped by hand: after
docker compose stoporkill, the container stays down until you start it.
A Small Experiment
★☆☆ Remove start_period and start_interval from db's health check, measure with docker compose down -v && time docker compose up -d and compare with the 16 seconds in this post. Which setting does the difference come from?
★★☆ Add restart: true to the api's dependency on db and run docker compose restart db. Does the API container's StartedAt change? Compare how many requests are affected meanwhile, using labs/compose/02-probe.sh.
★★★ Set the retry options to NumberOfTries = 3 and MaxTimeInterval = 2s and repeat the 20-second outage experiment. Which requests got a 500 and how long did each take? Which is better for the user: a quick error or a 26-second wait? Think about the answer separately for the catalog page and for the payment step.
In this post we still pass the two passwords from .env as environment variables, and they show up in clear text in the config output. In the next post we look at the difference between .env interpolation and env_file, required variables with ${VAR:?}, secrets:, which hands passwords over as files under /run/secrets, and profiles for optional services.