Wait for container healthchecks instead of sleeping
Give every service container a healthcheck that proves it can answer a real request, then start the stack with `docker compose up -d --wait` so the job blocks exactly until each service is ready.
AI agents open the PR
StarSling agents inspect your workflow, apply this optimization, and open a reviewable PR automatically.
Do this: healthcheck the service, then wait on it
Every service in docker-compose.yml declares a healthcheck whose test runs a real client command against the service, not a port probe: pg_isready for Postgres, mysqladmin ping for MySQL, redis-cli ping for Redis, mongosh --eval for MongoDB, clickhouse-client --query 'SELECT 1' for ClickHouse, sqlcmd -Q 'SELECT 1' for SQL Server. Each has a short interval, a small timeout, enough retries to survive a slow runner, and a start_period for anything that bootstraps a data directory on first boot. The workflow then starts the stack with docker compose up -d --wait --wait-timeout 60, which returns as soon as every service reports healthy and fails the step when one never does. There is no sleep in the workflow and no hand-rolled docker inspect loop in a package script. GitHub Actions services: containers take the same probes through their options: flags, and the runner will not start a step until they report healthy.123
# docker-compose.yml
services:
postgres:
image: postgres:16.4 # pinned, not :latest
healthcheck:
# A client command, so passing means "will answer a query".
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
interval: 1s
timeout: 5s
retries: 10
mysql:
image: mysql:8.4
healthcheck:
# 127.0.0.1, not localhost: on Linux, localhost resolves to a Unix socket,
# which can succeed against the temporary init server MySQL runs with
# --skip-networking before the real server is listening.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uuser", "-ppassword"]
interval: 1s
timeout: 5s
retries: 10
# First boot initialises the data directory and system tables. Probe
# failures inside start_period do not count toward retries, so without it
# the service is marked unhealthy long before it finishes bootstrapping.
start_period: 30s
# .github/workflows/ci.yml
steps:
- name: Start services
# Returns the moment every service reports healthy; fails the step if one
# never does, instead of handing a broken stack to the test command.
run: docker compose up -d --wait --wait-timeout 60
- name: Integration tests
run: pnpm test:integrationAvoid this: sleep and hope
Every fast run pays the full sleep, and any run where the database needs longer starts the tests against a service that is not accepting connections yet.
- name: Start Docker Containers
run: |
docker compose up -d
# Wait for services to be ready (optional)
sleep 10
- name: Test
run: pnpm test:integrationShipped by StarSling
Across four merged public PRs, StarSling agents replaced sleeps, TCP-only probes, and hand-rolled poll loops with healthchecks that check the query path, then made the start step wait on them.
- Read the Better Auth customer storyMerged 2026-02-16
Customer story
chore(ci): add Docker Compose healthchecks for faster CI service readiness
StarSling gave every Compose service a real healthcheck and replaced the four fixed sleeps that stood in for one, so each job blocks exactly until Postgres, MySQL, MongoDB, Redis, and SQL Server report ready.
Read the Better Auth customer storyView PR #8010 in better-auth/better-auth (opens in new tab) - Read the Mastra customer storyMerged 2026-04-28
Customer story
test(mssql): replace TCP healthcheck with sqlcmd, simplify pretest, pin Docker image
StarSling swapped SQL Server's port-only probe for a sqlcmd query probe, so a healthy container means the engine will actually answer, and deleted the two-stage shell poll that pretest used to compensate.
The change note in that PR records the TCP-only check passing roughly 20 seconds before the engine could accept connections, which is what the tighter interval was previously unsafe against.
Read the Mastra customer storyView PR #14520 in mastra-ai/mastra (opens in new tab) - Read the Mastra customer storyMerged 2026-04-29
Customer story
test(clickhouse): stop merges before TRUNCATE, use tmpfs and Docker healthcheck
StarSling replaced a 30-iteration shell loop that polled ClickHouse by hand with a SELECT 1 healthcheck the container owns, and let the pretest script wait on it.
The same PR also changed table truncation to stop background merges first, which is a test-isolation fix rather than a readiness one.
Read the Mastra customer storyView PR #14044 in mastra-ai/mastra (opens in new tab) - Read the Mastra customer storyMerged 2026-03-07
Customer story
test(chroma): reduce fixed 2000ms waitForIndexing sleep to 200ms and add Docker healthcheck
StarSling gave the Chroma service a healthcheck and started it with docker compose up -d --wait, so the suite stops beginning against a container that has only just been created.
Chroma's probe here checks the port rather than a query, which is weaker than the SQL Server probe above; the fixed indexing waits in that suite were shortened rather than replaced with condition polling.
Read the Mastra customer storyView PR #13866 in mastra-ai/mastra (opens in new tab)
Probe the query path, not the port
An open TCP port only proves a process bound a socket. Database engines bind early and then spend seconds recovering, initialising, or loading system tables, so a probe like echo > /dev/tcp/localhost/1433 goes green well before the first query will succeed. That is a slower, subtler version of the same bug the sleep had. Run the service's own client instead, from inside the container, and make it execute the cheapest real operation: a SELECT 1, a ping, a readiness subcommand. If the probe would pass while the next step fails, it is the wrong probe.
Cover slow starts with start_period, not more retries
retries is the budget for a service that is up and answering slowly. start_period is what covers a service that is not answering yet: probe failures inside it do not count toward retries, and Docker polls at start_interval while it lasts. Anything that formats a data directory on first boot, such as MySQL or SQL Server, needs one. Raising --wait-timeout is not a substitute; it caps how long the whole project may take to come up, it does not extend a single service's retry budget, so a service that burns its retries is reported unhealthy no matter how generous the timeout was.
Delete the hand-rolled poll loop
Repos that already know sleeping is wrong often grow a shell loop instead: a for loop around docker inspect --format='{{.State.Health.Status}}', or one that greps docker compose ps. It duplicates, badly, what Compose already does, and it usually hides its own failure mode by falling through the loop and running the tests anyway. Once the service has a healthcheck, the loop collapses to docker compose up -d --wait in the same script.
The same probe for GitHub Actions service containers
If the job uses a services: block instead of Compose, the probe goes in options: as --health-cmd, --health-interval, --health-timeout, --health-retries, and --health-start-period. The runner waits for the container to report healthy before it runs the job's first step, so a service container with a real health command needs no wait step at all. A service container without one is exactly where the sleep reappears.
Why it matters
A fixed sleep is a guess about the slowest machine the job will ever run on, and it is charged to every run that did not need it. A healthcheck turns the same wait into the shortest correct one: fast when the service starts fast, patient when it does not, and a clear failure with the probe's own output when the service never comes up. It also moves the readiness rule next to the service definition, where the person changing the image will see it.
When to add a healthcheck
Use it when
Any job that starts a database, cache, queue, search engine, or other service container before integration or end-to-end tests.
Be careful when
A healthcheck proves the service accepts requests. It does not prove that data you just wrote is queryable or that an index has caught up, so do not stretch it to cover eventual consistency: poll the condition the assertion needs instead, as described in the bounded readiness polling guide. Skip a custom probe when the image already ships a HEALTHCHECK you trust.
Verify on your repo
Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to audit and fix this practice in your own repo.
Audit this repository for sleep-based container readiness in CI. Search .github/workflows, docker-compose files, and any package scripts that start containers for fixed waits (sleep N), for hand-rolled poll loops around docker inspect or docker compose ps, and for services started with a bare docker compose up -d before a test command. For every service involved, add a healthcheck to its compose service definition whose test runs the service's own client and executes a real operation (pg_isready, mysqladmin ping against 127.0.0.1, redis-cli ping, mongosh --eval, clickhouse-client --query 'SELECT 1', sqlcmd -Q 'SELECT 1'), never a TCP port probe, with a short interval, a small timeout, enough retries for a slow CI runner, and a start_period for any service that initialises a data directory on first boot. Then replace the sleep or the poll loop with docker compose up -d --wait (add --wait-timeout where a hard cap is wanted). For jobs that use a services: block instead of Compose, put the same probe in options: as --health-cmd, --health-interval, --health-retries and --health-start-period, and remove the wait step. Do not put secrets or connection strings into log output, and do not weaken an existing probe that already checks the query path. Verify by starting the stack from cold and confirming every service reports healthy, then show me the diff and open a PR rather than applying it blindly.
Ground these changes in the upstream docs before you edit: https://docs.docker.com/reference/compose-file/services/#healthcheck, https://docs.docker.com/reference/cli/docker/compose/up/. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.Prefer to check by hand?
Find the guesses:
grep -rnE 'sleep [0-9]' .github/workflows/ docker-compose*.ymlplus any package scripts that start containers.Confirm every service in
docker-compose.ymlhas ahealthcheck, and that itstestruns a client command rather than a port or process check.Confirm the start command is
docker compose up -d --wait(optionally with--wait-timeout), not a baredocker compose up -dfollowed by a wait of any kind.Run it locally from cold:
docker compose up -d --waitshould return whendocker compose psshows every service healthy, anddocker inspect --format='{{json .State.Health}}' <container>shows the failing probe output when it does not.For a
services:block, check each container sets--health-cmdinoptions:, since the runner only waits for containers that have one.
Go further
One fix, all of them, or forever.
You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.
Fix this one thing
Copy the prompt above
Hand OPT17 to your coding agent and fix it in your repo today.
Fix everything, once
Install the ci-speedup skill
One prompt audits your whole repo against all 73 ci-speedup patterns (this one plus 72 more) and hands your agent every fix at once. Open source, MIT, runs locally.
Keep it fixed, forever
Install the StarSling GitHub App
Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.
More best practices for GitHub Actions
Where to go next in the CI best-practices catalog.
- Runner & Queue
Replace fixed CI sleeps with bounded readiness polling
Poll the exact condition a test needs, stop as soon as it is true, and cap the wait with a diagnostic failure so fast runs do not pay a worst-case sleep and slow runs do not become flaky.
- Runner & Queue
Bound GitHub Actions runner jobs with timeout-minutes
Set
timeout-minuteson every GitHub Actions job that executes on a runner, plus hang-prone steps, so a stuck run is cancelled in minutes instead of occupying a runner for up to the 360-minute default. - Runner & Queue
Cut CI queue time in GitHub Actions
GitHub-hosted runners cap how many jobs your account can run at once (20 on Free, more on paid plans), so past that ceiling jobs queue for a free slot no matter how your workflows are written.
Trying to make GitHub Actions faster?
If you do not know why a run is slow yet, start with the diagnostic guide. Let an agent find which of these applies: /ci-speedup. If your workflows are already optimized but still slow, see our guide to fast GitHub Actions and GitHub Actions runner alternatives. Building containers in CI? The Docker workflow guide covers layer caching end to end. Want to see how your configuration measures up before changing anything? CI Score grades workflow config against a pass/fail rubric of these practices. It is not a speed measurement.
FAQ
How do I wait for a Docker container to be ready in GitHub Actions?
Give the service a healthcheck in docker-compose.yml whose test runs a real client command, then start the stack with docker compose up -d --wait. That returns as soon as every service reports healthy and fails the step if one never does. If the job uses a services: block instead, put the probe in options: as --health-cmd; the runner waits for it before the first step runs.
Is sleep enough to wait for Postgres or MySQL in CI?
No. A sleep long enough to be safe on a slow cold start is wasted on every run that did not need it, and it is still a guess: a slower run than the one you tuned against starts the tests against a database that is not accepting connections. pg_isready and mysqladmin ping as healthchecks answer the actual question instead.
What does docker compose up --wait do?
It waits for the project's services to be running or healthy before returning, and it implies detached mode. Services with a healthcheck must report healthy; --wait-timeout caps how long the whole project may take. It replaces both the fixed sleep and the hand-rolled loop around docker inspect.
Why does my healthcheck pass before the database is ready?
Because it is probing the port rather than the service. Engines bind their port early and then keep initialising, so a TCP check can go green seconds before the first query succeeds. Run the service's own client and make it execute something real, such as SELECT 1, so healthy means answerable.
Sources
1Docker Compose · the healthcheck element (opens in new tab)
Last updated 2026-07-31