Docker Compose Integration Tests on GitHub Actions

Run Docker Compose integration tests on GitHub Actions with a separate image pull step, health-gated startup, and runner sizing from real per-minute rates.

Last verified:

To run Docker Compose integration tests on GitHub Actions, pull the stack images in their own step, bring the stack up with docker compose up -d --wait behind real health checks, then run the suite against the running services. The tests themselves are rarely the slow part: image pulls, database seeding, and serialized startup dominate the job, and every one of them gets worse on a 2 vCPU runner.

This page covers the workflow and compose file that gate on health rather than on sleep, how to size the runner against the memory and disk a multi-container stack actually consumes, when a snapshot runner is the right way to carry prepared images between runs, and what the job costs per month against GitHub-hosted list prices.

Overview

A compose stack is a different workload from a unit test job. A unit test job needs cores and a package cache. A compose stack needs memory for five or six processes at once, disk for the images and the volumes, a Docker daemon with room to work, and enough spare CPU that health checks answer inside their timeouts while the application boots.

Every GitHub Actions job starts on a fresh virtual machine with an empty container store. That means the same image layers come down from the registry on every run, the same migrations run against an empty database, and the same fixtures load again. None of that work changes between runs on a quiet branch, and all of it is charged by the minute.

Compose work belongs on the Linux runners: the cloud runners documentation states that macOS runners do not support nested virtualization and cannot run Docker. The Linux runners carry the same tooling as GitHub-hosted runners and the swap is a label change.

Two parts of the WarpBuild product surface matter for this workload. Snapshot runners capture a runner VM mid-workflow so later jobs boot from that state, and remote Docker builders keep a persistent layer cache for the images your stack builds. The surface also includes CI observability, an MCP server, and the Action Debugger.

One boundary before the configuration. This page is about running a stack that already has images. Building those images faster is a separate problem with a separate answer, covered on Docker builds on GitHub Actions with remote builders.

Configuration

Two files decide how long the job takes: the compose file, because it defines what "ready" means, and the workflow, because it decides what gets timed.

The compose file

docker compose up -d returns when containers start, which for Postgres is several seconds before it accepts connections. A sleep 30 covers that gap on a good day and produces a flaky suite on a bad one. Health checks replace the guess.

services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app_test
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app_test"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 3s
      retries: 30

  api:
    image: ghcr.io/acme/api:${API_TAG:-main}
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "8080:8080"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
      interval: 2s
      timeout: 3s
      retries: 60
      start_period: 10s

The interval value is the resolution of your readiness signal. At the common default of 30 seconds, a service that became ready one second after a poll waits another 29 seconds before compose notices. Two seconds costs a handful of extra probe executions and removes that dead time. start_period gives a slow starter room to fail its early probes without counting them as failures.

The workflow

name: integration-tests
on:
  pull_request:

jobs:
  compose:
    runs-on: warp-ubuntu-latest-x64-8x
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v5

      - name: Log in to the registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Pull stack images
        run: docker compose -f compose.ci.yaml pull --quiet

      - name: Start the stack
        run: docker compose -f compose.ci.yaml up -d --wait --wait-timeout 300

      - name: Seed the test database
        run: ./scripts/seed.sh

      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgres://app:app@localhost:5432/app_test
          REDIS_URL: redis://localhost:6379

      - name: Dump service state on failure
        if: failure()
        run: |
          docker compose -f compose.ci.yaml ps
          docker compose -f compose.ci.yaml logs --no-color --tail=200

      - name: Tear down
        if: always()
        run: docker compose -f compose.ci.yaml down -v

docker compose pull sits in its own step so GitHub Actions times it separately in the job log. That single number tells you whether image transfer or application startup is the thing to attack, and it is the number to watch after any change on this page.

--wait blocks until every service with a healthcheck reports healthy, and --wait-timeout is in seconds. Services without a healthcheck are treated as ready when they start, so an undeclared health check turns --wait back into a guess.

The failure step runs before teardown so the logs still exist. down -v removes the volumes, which matters on a snapshot runner where the disk survives the job.

Carrying images between runs with a snapshot runner

A snapshot runner captures the VM, including the container store under /var/lib/docker, and later jobs boot from that capture. The images your stack pulls are already on disk, so docker compose pull checks digests instead of transferring layers.

The label syntax lives in runs-on. Use snapshot.enabled=true to boot from the base image and create a snapshot, and snapshot.key=<alias> to boot from an existing snapshot for that alias.

jobs:
  compose:
    runs-on: >-
      ${{ github.ref == 'refs/heads/main'
        && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
        || 'warp-ubuntu-latest-x64-8x;snapshot.key=integration-stack' }}
    steps:
      - uses: actions/checkout@v5

      - name: Pull stack images
        run: docker compose -f compose.ci.yaml pull --quiet

      - name: Start the stack
        run: docker compose -f compose.ci.yaml up -d --wait --wait-timeout 300

      - name: Run integration tests
        run: npm run test:integration

      - name: Stop containers before snapshotting
        if: github.ref == 'refs/heads/main'
        run: docker compose -f compose.ci.yaml down

      - name: Clean credentials
        if: github.ref == 'refs/heads/main'
        run: |
          rm -rf $HOME/.docker/config.json $HOME/.ssh $HOME/.aws
          git clean -ffdx

      - name: Save snapshot
        if: github.ref == 'refs/heads/main'
        uses: WarpBuilds/snapshot-save@v1
        with:
          alias: "integration-stack"
          fail-on-error: true
          wait-timeout-minutes: 60

down without -v keeps the images and named volumes on the disk that the snapshot captures, so a seeded database volume rides along with the images. Pull requests read that alias and never write it, which keeps one branch from poisoning everyone else's starting state.

The boundaries are worth reading before you adopt this. Snapshot boot takes 45 to 60 seconds, which is the honest trade against a cold start, so a stack whose pull and seed cost less than a minute should stay on a plain runner. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners; BYOC runs on AWS, GCP, and Azure, and the snapshot labels are silently ignored there, as they are on Windows and macOS runners. Snapshots are deleted after 15 days. /tmp does not persist across the boot. Anyone who can read the alias can boot from the snapshot, which is why the credential cleanup step exists and why public repositories need extra care. The full rules are in the snapshot runners documentation, and snapshot runners for GitHub Actions covers the wider set of use cases.

Runner and snapshot behavior in this example is driven from the workflow file.

Sizing

Two resources decide the size: memory, because containers hold it all at once, and disk, because images and volumes accumulate inside a fixed budget.

Memory

Sum the peak resident memory of every container, add the test process, and add headroom for the Docker daemon and the page cache the database depends on. An example five service stack:

Component in the example stackAssumed peak memory
Application under test2.0GB
PostgreSQL1.0GB
Redis0.5GB
Message broker2.5GB
Search node, 1GB JVM heap2.0GB
Test runner process1.5GB
Total9.5GB

Those are assumptions for illustration; read your own numbers with docker stats --no-stream at the end of a local run. The shape of the answer holds either way. A stack near 10GB does not fit the 8GB on warp-ubuntu-latest-x64-2x, and the failure mode is an exit code 137 in whichever container the kernel picked, usually the database, usually two thirds of the way through the suite.

For five or more services, warp-ubuntu-latest-x64-8x with 32GB is the practical floor. warp-ubuntu-latest-x64-16x with 64GB becomes the floor once the stack adds a browser for end to end tests, a second JVM service, or several replicas of the application. Three service stacks fit warp-ubuntu-latest-x64-4x with 16GB.

Disk

Every Linux runner size carries 150GB SSD, and the runner image already uses part of it for preinstalled tooling. Run df -h / in a job to read the free space you actually start with, then budget against these consumers: pulled image layers, container writable layers, named volumes holding seeded data, build contexts if the job also builds images, and test artifacts such as recordings and coverage output.

Two habits keep a job inside the budget. Run docker compose down -v in an always() step so volumes from a failed run do not survive into a snapshot. Prune deliberately rather than by reflex on snapshot runners, since docker image prune -a throws away the images the snapshot exists to carry.

Rates and the cost model

Runner labelvCPURAMStoragePer minuteGitHub-hosted comparableGitHub listLower by
warp-ubuntu-latest-x64-2x28GB150GB SSD$0.004ubuntu-latest, 2 vCPU 8GB on private repositories$0.00633 percent
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.0084-core Linux larger runner$0.01233 percent
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.0168-core Linux larger runner$0.02227 percent
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.03216-core Linux larger runner$0.04224 percent
warp-ubuntu-latest-x64-32x32128GB150GB SSD$0.06432-core Linux larger runner$0.08222 percent

WarpBuild rates from the pricing page and the cloud runners documentation. GitHub list prices from the GitHub Actions billing reference, checked on 2026-08-13.

Assumptions for the model: 800 pull request runs per month, 9 minutes per run, one 8 vCPU runner per run.

LineArithmeticMonthly
Runner minutes on warp-ubuntu-latest-x64-8x800 x 9 min x $0.016$115.20
Same 7,200 minutes on the GitHub-hosted 8-core larger runner7,200 x $0.022$158.40

Adding a snapshot adds two lines. Snapshot restore lists at $0.04 per job and snapshot storage at $0.025 per snapshot-hour on the pricing page, checked on 2026-08-13. Restores across 800 runs are $32.00, and one live alias held for a full 15 day retention window is 360 hours x $0.025, or $9.00.

That is $41.00 per month, or $0.05125 per run, which buys 3.2 minutes of runner time at $0.016 per minute. The 45 to 60 second snapshot boot comes out of the same budget, so treat roughly 4 minutes of removed pull and seed time per run as the break-even point. Measure your pull step first, then decide.

Bottlenecks

Image pulls on every run

The container store is empty at the start of every job, so every layer travels again. Put docker compose pull in its own step and read the number. Authenticate to the registry even for public images so anonymous pull limits stop applying. Pin tags to digests so the pull is deterministic and a moving latest cannot invalidate a snapshot. Where the pull still dominates, that is the case a snapshot is for. More options are in how to reduce Docker image pull time in GitHub Actions.

Database seeding

Migrations plus fixtures against an empty database repeat identically on every run. Three ways out, in increasing order of effort. Bake the migrated schema into a database image so the container starts with the schema in place. Use a template database and create each test database with CREATE DATABASE app_test TEMPLATE app_seeded, which copies files instead of replaying inserts. Carry the seeded volume in a snapshot, which works when the schema changes on a merge cadence rather than on every pull request.

Sequential service startup

depends_on with condition: service_healthy is correct and it serializes the boot. A chain of four services each waiting on the previous one pays four health check intervals in series before the suite starts. Depend on what a service genuinely needs, keep the intervals short, and let independent services start in parallel rather than chaining them for tidiness.

Watch the interaction with runner size. Health checks are processes competing for the same cores as the starting services. On a saturated 2 vCPU runner probes time out, compose retries, and the stack reports unhealthy for reasons that look like application bugs. How to cut GitHub Actions cold starts covers the queue and boot time that sits in front of all of this.

Stacks that outgrew the runner

The signature of a stack over its memory budget is exit code 137 with no application error, appearing in a different container each time. The signature of a stack over its disk budget is no space left on device during a pull or a volume write. Both look like flaky tests in the report and neither is fixed by retrying. Size the runner from the table above, and read the failure for what it is.

Proof

Public repositories running warp- labels are the most direct evidence available. The warp-e2e workflow in the Warpbuilds/build-push-action repository runs on runs-on: warp-ubuntu-latest-x64-4x on pull requests and pushes to main, so the label shape and the runner behavior are both readable in public logs. The WarpBuild runner agent is open source if you want to see what runs on the runner itself.

Every cost number on this page carries its arithmetic, its source, and a checked-on date, and the same numbers appear on the pricing page. Point one integration workflow at an 8x runner, read the pull step timing before and after, and decide on a snapshot from your own numbers.

FAQ

What runner size does a Docker Compose stack need?

Add the peak memory of every container plus the test process, then pick the first size that clears it with headroom. A five service stack with a database, a broker, and a search node lands near 10GB, which puts it on warp-ubuntu-latest-x64-8x at 32GB. Three service stacks fit the 4x size at 16GB, and 2 vCPU with 8GB is where compose stacks start getting killed.

Do snapshot runners work with Docker Compose?

Yes, on WarpBuild Cloud Ubuntu runners. Add snapshot.key=<alias> to the runs-on label and the job boots a VM that already carries the container images and volumes captured by an earlier run. Snapshot boot takes 45 to 60 seconds, and the labels are silently ignored on BYOC, Windows, and macOS runners.

Why does docker compose up return before my services are ready?

Without healthcheck blocks, compose considers a container ready as soon as it starts, which is well before Postgres accepts connections. Define a healthcheck per service and start the stack with docker compose up -d --wait so the step blocks until every service reports healthy.

How do I stop image pulls from dominating every run?

Put docker compose pull in its own step so the job log times it, authenticate to the registry so anonymous pull limits stop applying, pin digests so the pull is deterministic, and carry the images between runs in a snapshot when the pull time exceeds the snapshot cost.

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.