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.

PhaseWhat the runner doesWhere it shows up
Initialize containersCreates a user-defined bridge network, pulls each service image, creates each container with the options you suppliedThe "Initialize containers" log group, timed
Health waitPolls the container health status and blocks the first step until every service with a health command reports healthyThe same log group, before step one
StepsYour job runs; services resolve at 127.0.0.1 on published ports, or at the service label from a job containerStep logs
Stop containersStops and removes every service container and the networkThe "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.

SymptomCauseWhere to look
Connection refused on the first queryNo health flags, so the runner never waited"Initialize containers" group
Healthy, then refused during migrationHealth command answered over the Unix socket during initdbThe --health-cmd string
Job exits 137 under loadServices plus workers exceed the runner memoryMemory P90 for the job
Health retries exhausted on a cold imageEngine needs longer to boot than retries allow--health-start-period
Minutes rise, test time flatImage pull repeated on every runTime 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:5432 publishes the container port to the runner machine, which is what makes 127.0.0.1:5432 resolvable from a job that runs directly on the runner. A job that sets the container key would drop the mapping and use postgres as 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 10s plus 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 1g raises the shared memory segment above the 64 MB Docker default, which is the parallel-query and large-sort ceiling for Postgres.
  • TEST_WORKERS is 6 on an 8 vCPU label, leaving headroom for the two service containers and the runner agent.
  • timeout-minutes: 20 bounds 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 labelvCPURAMStorageUSD per minute
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128 GB150GB 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.

ComponentAssumed peakNotes
postgres:17 after migration1.0 GBShared buffers plus the WAL writer
One Postgres backend per worker40 MBOne connection held per test worker
redis:7 with a small keyspace0.2 GBGrows with the fixture set
Test worker process1.0 GBRuntime, module graph, and fixtures
Runner agent and Docker daemon0.5 GBConstant

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.

Linewarp-ubuntu-latest-x64-8x8-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.