Service Containers for Integration Tests
GitHub Actions service containers start and end with the job. Gate the first step on a real health check, then size the runner for services and test workers.
Last verified:
A service container is a Docker container that the GitHub Actions runner creates from the services block before your first step and destroys when the job completes, which is why every run starts with an empty database (GitHub service container documentation). Two problems account for most of the time lost around them: steps that begin before the engine accepts connections, and a runner sized for the test process while the database, the broker, and the test workers share one machine.
This guide covers the container lifecycle inside a job, the health-check gate that removes the startup race, a workflow with two services and connection details passed to the test step, and a sizing model that prices services plus workers against the Linux x64 ladder. For neighboring problems see running database services in GitHub Actions, Docker Compose integration tests when the stack outgrows the services block, and the hub on speeding up GitHub Actions.
Diagnosis
The lifecycle explains both failure modes. Every phase below happens inside the billed job, on a fresh virtual machine with an empty container store.
| Phase | What the runner does | Where it shows up |
|---|---|---|
| Initialize containers | Creates a user-defined bridge network, pulls each service image, creates each container with the options you supplied | The "Initialize containers" log group, timed |
| Health wait | Polls the container health status and blocks the first step until every service with a health command reports healthy | The same log group, before step one |
| Steps | Your job runs; services resolve at 127.0.0.1 on published ports, or at the service label from a job container | Step logs |
| Stop containers | Stops and removes every service container and the network | The "Stop containers" post-job group |
Without health flags in options, a container counts as ready the moment it starts, and the first step runs against a port that is open only because Docker published it. That gap is the flake. With health flags, the runner waits, and the gap closes.
The subtler version survives a health check. The official Postgres image runs its initialization scripts against a temporary server that does not accept TCP connections (postgres image documentation, checked on 2026-08-13). A --health-cmd pg_isready with no host argument talks to the Unix socket, sees that temporary server, and reports healthy while the real server is still restarting. The job then gets one connection refused on its first query.
Memory is the second failure. Service containers run on the same virtual machine as the job, so their resident memory competes with the test workers rather than living somewhere else. A suite that passes at four workers and gets killed at eight is usually a memory ceiling, and the exit code is 137.
| Symptom | Cause | Where to look |
|---|---|---|
| Connection refused on the first query | No health flags, so the runner never waited | "Initialize containers" group |
| Healthy, then refused during migration | Health command answered over the Unix socket during initdb | The --health-cmd string |
| Job exits 137 under load | Services plus workers exceed the runner memory | Memory P90 for the job |
| Health retries exhausted on a cold image | Engine needs longer to boot than retries allow | --health-start-period |
| Minutes rise, test time flat | Image pull repeated on every run | Time in "Initialize containers" |
On GitHub-hosted runners, read peak memory inside the job yourself.
Fix
Work these in order.
Put health flags on every service. options is passed through to docker create, so --health-cmd, --health-interval, --health-timeout, --health-retries, and --health-start-period are all available. A service with no health command is a service the runner will not wait for.
Force the health command onto TCP. For Postgres that means pg_isready -h 127.0.0.1 -U app -d app_test rather than bare pg_isready, which is what closes the initdb race described above. For Redis, redis-cli ping already speaks the protocol on the port.
Give slow engines a start period instead of more retries. --health-start-period 10s holds the container out of the unhealthy state while it boots, so a 12-retry budget covers real trouble rather than normal startup.
Delete every sleep that was covering the gap. A fixed sleep pays its full duration on every run and still fails on the slow one.
Pass connection details once, at job level. Put DATABASE_URL and REDIS_URL in job env so the migration step, the test step, and any debug step read the same values.
Size the runner for the sum. The next two sections turn this into arithmetic.
Configuration
Two services, health gates that hold, and connection details handed to the test step. Service containers require a Linux runner, so this workload sits on the labels in the cloud runners documentation.
name: integration-tests
on:
pull_request:
branches: [main]
jobs:
integration:
runs-on: warp-ubuntu-latest-x64-8x
timeout-minutes: 20
env:
DATABASE_URL: postgres://app:[email protected]:5432/app_test
REDIS_URL: redis://127.0.0.1:6379/0
TEST_WORKERS: "6"
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -h 127.0.0.1 -U app -d app_test"
--health-interval 5s
--health-timeout 5s
--health-retries 12
--health-start-period 10s
--shm-size 1g
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- run: npm ci
- name: Migrate
run: npm run db:migrate
- name: Integration tests
run: npm test -- --maxWorkers=${TEST_WORKERS}What each piece does:
ports: - 5432:5432publishes the container port to the runner machine, which is what makes127.0.0.1:5432resolvable from a job that runs directly on the runner. A job that sets thecontainerkey would drop the mapping and usepostgresas the hostname instead (workflow syntax reference).- The Postgres health command carries
-h 127.0.0.1, so it fails while initialization runs and passes only once the server listens on TCP. --health-start-period 10splus 12 retries at 5 second intervals gives 70 seconds of grace before the container is declared unhealthy and the job fails at the container step rather than mid-suite.--shm-size 1graises the shared memory segment above the 64 MB Docker default, which is the parallel-query and large-sort ceiling for Postgres.TEST_WORKERSis 6 on an 8 vCPU label, leaving headroom for the two service containers and the runner agent.timeout-minutes: 20bounds the failure mode where a service never becomes healthy and the job sits waiting.
Labels resolve to these machines, from the cloud runners documentation, checked on 2026-08-13. The full ladder, including the Ubuntu 22.04 and 26.04 labels, sits on the Linux x64 runner page.
runs-on label | vCPU | RAM | Storage | USD per minute |
|---|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 |
warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 |
Cost or Time Model
Size on two axes at once. Memory is services + (workers * per_worker) + 0.5 GB of runner agent and Docker daemon overhead, and vCPU is workers + 1 so the service containers keep a core while the suite runs.
The figures below are stated assumptions for a Node suite with Postgres and Redis, not measurements. Replace them with the peak resident memory from your own job.
| Component | Assumed peak | Notes |
|---|---|---|
postgres:17 after migration | 1.0 GB | Shared buffers plus the WAL writer |
| One Postgres backend per worker | 40 MB | One connection held per test worker |
redis:7 with a small keyspace | 0.2 GB | Grows with the fixture set |
| Test worker process | 1.0 GB | Runtime, module graph, and fixtures |
| Runner agent and Docker daemon | 0.5 GB | Constant |
Six workers come to 1.0 + (6 * 1.0) + (6 * 0.04) + 0.2 + 0.5, or about 7.9 GB, and eight workers come to about 10.0 GB. The 4 vCPU label at 16 GB holds the memory in both cases and starves the CPU axis at eight workers, so a suite that wants eight workers plus two services lands on warp-ubuntu-latest-x64-8x at 8 vCPU and 32 GB.
Price that label against its GitHub-hosted equivalent. An 8 minute integration job at 500 runs a month is 4,000 billed minutes.
| Line | warp-ubuntu-latest-x64-8x | 8-core Linux larger runner |
|---|---|---|
| Per-minute rate | $0.016 | $0.022 |
| One 8 minute job | $0.128 | $0.176 |
| 500 runs a month | $64.00 | $88.00 |
| 12 months | $768.00 | $1,056.00 |
warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13, from the GitHub Actions billing reference.
The health gate pays for itself in the same arithmetic. Replacing a sleep 30 with a gate that clears in 8 seconds returns 22 seconds per run, which is 183 minutes a month at 500 runs, or $2.93 on the 8 vCPU label. The image pull is the larger remaining line: it repeats on every run because the virtual machine is new, and a snapshot runner boots with those images already on disk (snapshot runners documentation). The same mechanism carries a seeded data directory across jobs, which is covered in keeping a database warm between jobs.
Full rates by runner type are on the pricing page.
FAQ
Why do my tests get connection refused even though the service reports healthy?
The health command answered over the Unix socket while the engine was still initializing. The official Postgres image runs its initialization scripts against a temporary server that does not accept TCP connections, so pg_isready over the socket passes before the real server is listening. Force the check onto TCP with --health-cmd "pg_isready -h 127.0.0.1 -U app -d app_test" and add --health-start-period so the retries do not burn out during initdb.
Do service containers need the ports keyword?
Only when the job runs directly on the runner machine, which is the common case. That job reaches a service at 127.0.0.1 on the published port, so each service needs a ports mapping. A job that sets the container key joins the same Docker network and reaches each service at its service label as a hostname with no mapping at all.
What runner size do two services and eight test workers need?
Budget memory as services plus workers plus about 0.5 GB of runner and Docker daemon overhead, then take the first size on the Linux x64 ladder that clears it. Postgres at 1 GB, Redis at 0.2 GB, eight workers at 1 GB each and eight database backends at 40 MB each comes to about 10 GB, and the 8 vCPU size at 32 GB is the first label that holds it while leaving a core for the services.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.