---
title: "Docker healthchecks in CI, not sleep"
description: "Give every CI service container a healthcheck that proves it answers a real query, then start the stack with docker compose up --wait instead of a fixed sleep."
url: https://starsling.dev/best-practices/github-actions/wait-for-container-healthchecks
canonicalUrl: https://starsling.dev/best-practices/github-actions/wait-for-container-healthchecks
---

# Wait for container healthchecks instead of sleeping

Best practice: Runner & Queue. Last updated: 2026-07-31

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.

## Table of contents

- [Do this: healthcheck the service, then wait on it](#do-this)
- [Avoid this: sleep and hope](#avoid-this)
- [Shipped by StarSling](#shipped-by-starsling)
- [Probe the query path, not the port](#probe-the-query-path-not-the-port)
- [Cover slow starts with start_period, not more retries](#start-period-not-more-retries)
- [Delete the hand-rolled poll loop](#delete-the-hand-rolled-poll-loop)
- [The same probe for GitHub Actions service containers](#github-actions-service-containers)
- [Why it matters](#why-it-matters)
- [When to add a healthcheck](#when-to-use)
- [Verify on your repo](#verify)
- [Trying to make GitHub Actions faster?](#speedup-guides)
- [Sources](#sources)

<a id="do-this"></a>

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

_A real readiness probe per service, then one waiting start_

```yaml
# 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
```

<a id="avoid-this"></a>

## 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_

```yaml
- name: Start Docker Containers
  run: |
    docker compose up -d
    # Wait for services to be ready (optional)
    sleep 10

- name: Test
  run: pnpm test:integration
```

<a id="shipped-by-starsling"></a>

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

### better-auth/better-auth#8010: 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.

Diff: docker-compose.yml adds pg_isready, mysqladmin ping, mongosh, redis-cli, and sqlcmd probes (with a start_period for MySQL's first-boot initialisation); the sleep 10 after docker compose up -d in ci.yml and e2e.yml becomes docker compose up -d --wait --wait-timeout 60.

Source: [better-auth/better-auth#8010](https://github.com/better-auth/better-auth/pull/8010), merged 2026-02-16.
Customer story: [Better Auth](https://starsling.dev/customers/better-auth).

### mastra-ai/mastra#14520: 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.

Diff: stores/mssql/docker-compose.yaml replaces the echo > /dev/tcp/localhost/1433 check with a sqlcmd SELECT 1 healthcheck at a 2s interval, and pins mssql/server:2025-CU3-ubuntu-24.04; the two docker compose ps / docker inspect loops in the pretest script collapse to docker compose up -d --wait.

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.

Source: [mastra-ai/mastra#14520](https://github.com/mastra-ai/mastra/pull/14520), merged 2026-04-28.
Customer story: [Mastra](https://starsling.dev/customers/mastra).

### mastra-ai/mastra#14044: 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.

Diff: stores/clickhouse/docker-compose.yaml adds a clickhouse-client SELECT 1 healthcheck, pins clickhouse-server:26.2.3.2, and mounts /var/lib/clickhouse on tmpfs; the pretest for-loop becomes docker compose up -d --wait.

The same PR also changed table truncation to stop background merges first, which is a test-isolation fix rather than a readiness one.

Source: [mastra-ai/mastra#14044](https://github.com/mastra-ai/mastra/pull/14044), merged 2026-04-29.
Customer story: [Mastra](https://starsling.dev/customers/mastra).

### mastra-ai/mastra#13866: 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.

Diff: stores/chroma/docker-compose.yaml pins chromadb/chroma:1.5.2 and adds a port-level healthcheck; pretest becomes docker compose up -d --wait.

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.

Source: [mastra-ai/mastra#13866](https://github.com/mastra-ai/mastra/pull/13866), merged 2026-03-07.
Customer story: [Mastra](https://starsling.dev/customers/mastra).

<a id="probe-the-query-path-not-the-port"></a>

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

<a id="start-period-not-more-retries"></a>

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

<a id="delete-the-hand-rolled-poll-loop"></a>

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

<a id="github-actions-service-containers"></a>

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

<a id="why-it-matters"></a>

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

<a id="when-to-use"></a>

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

<a id="verify"></a>

## Verify on your repo

Hand this prompt to your coding agent 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*.yml` plus any package scripts that start containers.
- 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.
- 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.
- 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.
- For a `services:` block, check each container sets `--health-cmd` in `options:`, since the runner only waits for containers that have one.

<a id="more-best-practices"></a>

## More best practices for GitHub Actions

- [Replace fixed CI sleeps with bounded readiness polling](https://starsling.dev/best-practices/github-actions/replace-fixed-sleeps-with-polling)
- [Bound GitHub Actions runner jobs with timeout-minutes](https://starsling.dev/best-practices/github-actions/bound-job-timeouts)
- [Cut CI queue time in GitHub Actions](https://starsling.dev/best-practices/github-actions/cut-queue-time)
- [All CI best practices](https://starsling.dev/best-practices/github-actions)

<a id="speedup-guides"></a>

## 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](/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.

- [GitHub Actions too slow](https://starsling.dev/github-actions-too-slow)
- [Fast GitHub Actions](https://starsling.dev/fast-github-actions)
- [GitHub Actions alternatives](https://starsling.dev/github-actions-alternatives)
- [Docker CI on GitHub Actions](https://starsling.dev/ci/docker)
- [Install the free /ci-score skill to improve your GitHub Actions setup](https://starsling.dev/ci-score)

<a id="faq"></a>

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

<a id="sources"></a>

## Sources

1. [Docker Compose - the healthcheck element](https://docs.docker.com/reference/compose-file/services/#healthcheck)
2. [Docker Compose CLI - docker compose up](https://docs.docker.com/reference/cli/docker/compose/up/)
3. [GitHub Docs - about service containers](https://docs.github.com/en/actions/using-containerized-services/about-service-containers)
