How Do I Run Database Services in GitHub Actions?

Declare the database as a service container, gate the first step on its health check, and size the runner for the database and test workers together.

Last verified:

Run a database in GitHub Actions by declaring it as a service container under the job's services key, gating the first step on the container health check, and sizing the runner so the database engine and the test workers both fit in memory. GitHub creates a fresh Docker container for each service and destroys it when the job completes, so the database starts empty on every run and the migration and seed time is charged on every run.

Answer

A service container is the supported way to attach Postgres, MySQL, Redis, or a message broker to a job. GitHub's documentation sets three constraints that decide most of the configuration.

The first is the platform. Jobs that use service containers, job containers, or Docker container actions must run on a Linux runner. This workload belongs on the Linux labels in the cloud runners documentation.

The second is networking. A job that runs directly on the runner machine reaches a service at localhost:<port> and has to publish the container port with the ports keyword. A job that sets the container key joins the same user-defined bridge network, reaches the service at its service label as a hostname, and skips port mapping entirely. Both forms are documented in the workflow syntax reference.

The third is readiness. The container image starts before the database accepts connections, so the options block carries the health check that makes the runner wait.

Here is the shape that covers most test suites, running directly on the runner machine with two services and the connection variables in job-level env.

name: integration-tests
on:
  pull_request:
    branches: [main]

jobs:
  tests:
    runs-on: warp-ubuntu-latest-x64-4x
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: app_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      DATABASE_URL: postgres://app:app@localhost:5432/app_test
      REDIS_URL: redis://localhost:6379
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run db:migrate
      - run: npm run db:seed
      - run: npm test

Move that job to a container by adding container: node:22-bookworm-slim, deleting both ports blocks, and changing the host in DATABASE_URL from localhost to postgres.

Detail

Gate on the health check instead of a sleep

A sleep 15 before the migration step fails in two directions. It adds fifteen billed seconds to every green run, and it still loses the race on the run where the image pull ran long. The health options solve both cases: the runner holds the first step until the container reports healthy, then starts immediately.

Match the probe to the engine. pg_isready for Postgres, mysqladmin ping for MySQL, redis-cli ping for Redis. A probe that only checks the TCP port answers before the engine has finished recovery on startup, which produces the same connection errors a sleep produces.

Retries multiply against the interval. The block above allows 5 retries at a 10 second interval, so a service that takes longer than about 50 seconds to come up fails the job rather than hanging it. Raise --health-retries for engines that replay a write-ahead log at boot.

Size the runner for the database and the test workers together

The common sizing mistake is budgeting for the test process alone. The database engine, its page cache, any broker or search node, and every parallel test worker share one machine. Plan one vCPU per parallel worker plus one for each service, then add the memory ceilings of the containers to the peak of the test process.

Services on the jobParallel test workersMemory to plan forRunner labelShapeRate
Postgres onlySerial suite2 GBwarp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.004/min
Postgres and Redis46 GBwarp-ubuntu-latest-x64-4x4 vCPU, 16 GB$0.008/min
Postgres, Redis, and a message broker812 GBwarp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.016/min
Postgres and a JVM search node814 GBwarp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.016/min
Two database engines, a broker, and a search node1628 GBwarp-ubuntu-latest-x64-16x16 vCPU, 64 GB$0.032/min

Labels, shapes, and rates come from the cloud runners documentation and the pricing page. The memory column is a planning budget rather than a measurement: set a container memory limit per service, run the suite once, and replace these figures with what the job reports.

Every Linux size carries a 150GB SSD, which matters because the database volume, the pulled images, and any dump file all land on the same disk. A 20GB fixture dump plus a few database images stays well inside that, and a job that restores several large dumps in a matrix does not.

At the size where most multi-service suites land, warp-ubuntu-latest-x64-8x costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner at the same 8 vCPU, 32 GB shape: 27 percent lower list price. GitHub list price checked on 2026-08-13 against the GitHub Actions billing reference.

What seeding costs, and when a prepared machine is cheaper

Migrations and fixtures run against an empty database on every job, so seed time is a recurring line on the invoice. Take a suite that runs 500 times a month on warp-ubuntu-latest-x64-8x at $0.016 per minute.

Seed duration per runBilled minutes per monthMonthly cost of seeding
1 minute500$8.00
5 minutes2,500$40.00
12 minutes6,000$96.00

The alternative is a prepared machine. A snapshot captures the runner VM, so it carries the pulled service images and any host directory the job wrote. Point the service container's data directory at a host path with the volumes keyword, seed it once on main, save the snapshot, and later jobs boot with that data already on disk. Add ;snapshot.key=integration-db to the runs-on label to boot from it, as described in the snapshot runners documentation.

The snapshot path has its own bill, priced on the pricing page. Snapshot restore is $0.04 per job, which is $20.00 across 500 runs. Snapshot storage is $0.025 per snapshot-hour, so one alias held through a 30 day month is 720 snapshot-hours, or $18.00. Assume the restored machine spends 30 seconds getting the seeded volume ready: 250 minutes at $0.016 adds $4.00. That is $42.00 a month whatever the original seed took, which puts the crossover near a 5 minute seed at this run volume, with the wall-clock saving on top.

Two limits shape the decision, both covered in the snapshot runners documentation. Snapshots are deleted after 15 days, so the alias needs rewriting on a schedule. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only, and the labels are silently ignored on BYOC, Windows, and macOS runners.

When the stack grows past three or four services, the services key stops being the right tool and a compose file takes over. That path, including health-gated startup and a separate image pull step, is covered on Docker Compose integration tests on GitHub Actions.

Do I have to map ports for a database service container?

Only when the job runs directly on the runner machine. GitHub documents that a job on the runner reaches a service at localhost:<port> or 127.0.0.1:<port> and needs the ports keyword to publish the container port to the host. A job that sets the container key joins the same Docker network, reaches the service at its service label as a hostname, and needs no ports mapping at all. The service container documentation covers both forms, and service containers in GitHub Actions walks through the configuration end to end. Once the port list runs past three or four services, move the stack into a compose file and follow Docker Compose integration tests on GitHub Actions.

Why does my test suite fail with connection refused on the first step?

The service container started before it was ready to accept connections. Give the service an options block with --health-cmd, --health-interval, --health-timeout, and --health-retries so the runner waits for a healthy container before the first step runs. For Postgres that is --health-cmd pg_isready, and for Redis it is --health-cmd "redis-cli ping". A migration step that fails intermittently after the health check passes usually points at the migration itself, which running database migrations on GitHub Actions covers.

Should I seed the database on every GitHub Actions run?

Seed on every run while the seed is short. At 500 runs a month on warp-ubuntu-latest-x64-8x at $0.016 per minute, a 1 minute seed costs $8.00 a month and a 12 minute seed costs $96.00. A snapshot runner that boots with the seeded data directory already on disk costs about $42.00 a month at that run volume, counting $0.04 per restore, $0.025 per snapshot-hour of storage, and the restored boot time. The crossover sits near a 5 minute seed, and keeping a database warm between GitHub Actions jobs covers the reuse patterns in more detail.

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.