Can I Keep a Database Warm Between Jobs?

Yes. Snapshot a WarpBuild Ubuntu runner once the database is seeded, and later jobs boot with the data directory already on disk instead of importing it.

Yes, on WarpBuild Cloud Ubuntu runners. Seed the database once, capture the machine with the WarpBuilds/snapshot-save action after the data is loaded, and later jobs that request the same snapshot alias boot with the data directory already on disk, so they start the database process and run tests instead of importing the dump again (snapshot runners documentation).

Answer

The reason a database goes cold is the job boundary. Every GitHub Actions job runs on a freshly allocated ephemeral virtual machine whose storage is deleted when the runner terminates (cloud runners documentation), and a service container declared under services is created from its image at job start and removed at job end. Both facts point the same way: the database process is new, its data directory is empty, and the schema plus the seed data have to be loaded again.

A snapshot runner changes what is on the disk, not what is running. WarpBuilds/snapshot-save captures the runner VM disk at the point it runs, and a later job whose label carries snapshot.key=<alias> boots from that captured disk (snapshot runners documentation). A database data directory written under $HOME or into a Docker named volume is part of that disk and comes back with the rows in it. Three properties decide how you write the workflow:

  • Nothing is running after a boot from a snapshot. The machine reboots, so no process is up and no port is listening. The database has to be started again, pointed at the data directory that was restored with the disk.
  • /tmp does not persist, because the directory is cleaned on reboot. A data directory staged in /tmp is gone; keep it under $HOME, in the workspace, or in /var/lib/docker.
  • The container record from the earlier job is still on the disk while no process is running, so a docker run --name pg in the warm job collides with the stale name unless the step removes it first.

Platform scope is narrow. Snapshots apply to the Cloud Ubuntu part of that catalog. On macOS, Windows, and BYOC runners the snapshot labels are silently ignored, which means a mislabeled job produces no warning and simply runs cold. The snapshot runners hub lists the Ubuntu labels that accept the feature.

What the two paths cost in job time

The numbers below are stated assumptions for a 4 GB compressed dump, not measurements. Substitute the durations from your own job log; the shape of the comparison is what carries over. The snapshot boot figure is from the snapshot runners documentation, which states that snapshot boots take 45 to 60 seconds.

PhaseCold job that seeds every runJob that boots from the snapshot
Runner boot penaltynone45 to 60 seconds
Pull the database image40 secondsalready in the local image store
initdb and first start20 seconds15 seconds against the restored data directory
Restore the dump6 minutes 20 secondsnot run
VACUUM ANALYZE50 secondsnot run
Test suite4 minutes4 minutes
Totalabout 12 minutesabout 5 minutes 15 seconds

Two line items sit on top of runner minutes, both from the pricing page, checked on 2026-08-13: snapshot restore at $0.04 per job and snapshot storage at $0.025 per hour per snapshot. On warp-ubuntu-latest-x64-8x at $0.016 per minute, the restore fee is worth 2.5 minutes of runner time, and the snapshot boot costs about another minute, so an alias pays for itself only when it removes more than about 3.5 minutes of seeding work.

At 600 pull request runs per month with one alias held live for 720 hours, the assumptions above price out as follows.

LineCold job that seeds every runJob that boots from the snapshot
Minutes per run12.25.25
Runner minutes per month7,3203,150
Runner cost at $0.016 per minute$117.12$50.40
Snapshot restore fees$0$24.00
Snapshot storage$0$18.00
Total per month$117.12$92.40

Storage scales with live aliases rather than with runs, so two aliases held the same way is $36.00 per month instead of $18.00.

Detail

The workflow

The alias has to be decided before the runner is chosen, and it has to depend on the schema and the seed files. hashFiles reads the runner workspace, which does not exist yet when runs-on is evaluated, so a small first job checks out the repository, computes the hash, and publishes the label as a job output that the test job consumes.

name: tests
on: [pull_request, push]

jobs:
  plan:
    runs-on: warp-ubuntu-latest-x64-2x
    outputs:
      label: ${{ steps.key.outputs.label }}
      alias: ${{ steps.key.outputs.alias }}
    steps:
      - uses: actions/checkout@v5
      - id: key
        run: |
          hash=$(cat db/schema.sql db/seed/*.sql | sha256sum | cut -c1-12)
          echo "alias=pgdata-$hash" >> "$GITHUB_OUTPUT"
          echo "label=warp-ubuntu-latest-x64-8x;snapshot.key=pgdata-$hash" >> "$GITHUB_OUTPUT"

  test:
    needs: plan
    runs-on: ${{ needs.plan.outputs.label }}
    steps:
      - uses: actions/checkout@v5

      - name: Check for a warm boot
        id: boot
        run: echo "warm=${WARPBUILD_SNAPSHOT_KEY:+true}" >> "$GITHUB_OUTPUT"

      - name: Start Postgres on the persisted data directory
        run: |
          docker rm -f pg >/dev/null 2>&1 || true
          mkdir -p "$HOME/pgdata"
          docker run -d --name pg -p 5432:5432 \
            -e POSTGRES_PASSWORD=postgres \
            -v "$HOME/pgdata:/var/lib/postgresql/data" \
            postgres:16
          until docker exec pg pg_isready -U postgres; do sleep 1; done

      - name: Seed the database
        if: steps.boot.outputs.warm != 'true'
        run: |
          psql "$DATABASE_URL" -f db/schema.sql
          for f in db/seed/*.sql; do psql "$DATABASE_URL" -f "$f"; done
          psql "$DATABASE_URL" -c 'VACUUM ANALYZE'
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres

      - name: Run the suite
        run: npm run test:integration
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres

      - name: Stop the database and remove credentials
        if: steps.boot.outputs.warm != 'true'
        run: |
          docker stop pg
          rm -rf $HOME/.ssh $HOME/.aws $HOME/.npmrc
          git clean -ffdx

      - name: Save the seeded machine
        if: steps.boot.outputs.warm != 'true'
        uses: WarpBuilds/snapshot-save@v1
        with:
          alias: ${{ needs.plan.outputs.alias }}
          fail-on-error: false
          wait-timeout-minutes: 45

Four details in that file are doing the work. WARPBUILD_SNAPSHOT_KEY lives on the runner process, and GitHub Actions does not read process-level variables into the env context on its own, so the Check for a warm boot step reads it from the shell and republishes it as the step output that the later if: conditions test. The docker stop before the save flushes the database to the data directory, so the disk that gets captured is consistent rather than mid-write. fail-on-error: false keeps a failed capture from turning a green pipeline red. And the bind mount under $HOME keeps the data out of /tmp, which would not survive.

The second start needs no initdb: Postgres finds a populated PGDATA and comes up against it, which is why the warm column above pays 15 seconds instead of the import.

The invalidation rule

The alias is the cache key, and it has to change whenever the data inside it would be wrong. pgdata-$(cat db/schema.sql db/seed/*.sql | sha256sum) covers both halves: a migration that alters a table and a fixture edit that adds rows both produce a new alias. Requesting an alias that has no snapshot is a documented fallback rather than an error, so the first job on the new schema boots from the base image, seeds, and publishes the new alias for everyone behind it.

Two lifetime rules belong in the same decision. Snapshots are deleted after 15 days (snapshot runners documentation), so an alias that no run refreshes stops resolving and the next job runs cold. Storage bills per snapshot-hour, so a scheme that produces an alias per branch holds many live snapshots at once. Aliasing on the schema hash rather than on the branch keeps the count near the number of open migrations. Database migrations on GitHub Actions covers running the migration itself, which is the step that changes the hash.

What belongs in a snapshot

A snapshot is readable by any job that names its alias. On a public repository, a contributor can name the alias in a pull request; on a private repository, WarpBuild provisions runners at the organization level, so a snapshot can reach other jobs in the organization. Seed data belongs in a snapshot; a production dump with customer records does not. Delete credentials in the step immediately before the save, as the workflow above does.

Snapshot runners are one part of the WarpBuild product surface, alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger. When the seeding cost is small enough that the 3.5-minute threshold is not met, the plain services block is the simpler answer, and the service containers guide covers health checks, port mapping, and readiness gating for that route.

Does the GitHub Actions services key keep data between jobs?

No. A service container is created from its image when the job starts and removed when the job ends, so every job gets an empty database and pays the seeding cost again. The data survives only if the data directory sits on a disk that outlives the job, which is what a snapshot runner provides. The service containers guide compares the two routes.

Can I keep a database warm on macOS, Windows, or BYOC runners?

No. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only, and a snapshot label on a macOS, Windows, or BYOC runner is silently ignored, so the job runs normally with no snapshot behavior and no error in the log (snapshot runners documentation). The snapshot runners hub lists the Ubuntu labels and their per-minute rates.

What happens to the warm database when the schema changes?

Put the hash of the schema and seed files in the snapshot alias. A schema change produces an alias that has no snapshot yet, the runner boots from the base image, the job seeds from scratch and saves under the new alias, and later jobs on that schema boot warm again. Database migrations on GitHub Actions covers ordering the migration against the test job.

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.