Best practice · Runner & Queue

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

A real readiness probe per service, then one waiting start
# 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:integration

Avoid 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.

A fixed sleep standing in for a readiness check
- name: Start Docker Containers
  run: |
    docker compose up -d
    # Wait for services to be ready (optional)
    sleep 10

- name: Test
  run: pnpm test:integration

Shipped 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 story
    Customer story
    Merged 2026-02-16

    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 Mastra customer story
    Customer story
    Merged 2026-04-28

    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 story
    Customer story
    Merged 2026-04-29

    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 story
    Customer story
    Merged 2026-03-07

    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.

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.

Prompt for your coding agent
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?

  1. Find the guesses: grep -rnE 'sleep [0-9]' .github/workflows/ docker-compose*.yml plus any package scripts that start containers.

  2. Confirm every service in docker-compose.yml has a healthcheck, and that its test runs a client command rather than a port or process check.

  3. Confirm the start command is docker compose up -d --wait (optionally with --wait-timeout), not a bare docker compose up -d followed by a wait of any kind.

  4. Run it locally from cold: docker compose up -d --wait should return when docker compose ps shows every service healthy, and docker inspect --format='{{json .State.Health}}' <container> shows the failing probe output when it does not.

  5. For a services: block, check each container sets --health-cmd in options:, 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.

  1. Fix this one thing

    Copy the prompt above

    Hand OPT17 to your coding agent and fix it in your repo today.

  2. 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.

  3. 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.

All CI best practices

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)

2Docker Compose CLI · docker compose up (opens in new tab)

3GitHub Docs · about service containers (opens in new tab)

Last updated 2026-07-31