Database Migrations in GitHub Actions

Run database migrations in GitHub Actions as two jobs: a migration test against a fresh service container database, and a gated production apply job.

Run database migrations in GitHub Actions as two jobs with different trust levels: a migration test job that applies the whole pending chain to a throwaway database in a service container, and a production apply job that runs behind an environment gate in a single concurrency group. The test job proves the migration applies and reverses against a schema shaped like production, and the apply job is the only path in the repository allowed to write to the real database.

This page gives the workflow for both jobs on warp- labels, the runner sizes and per-minute rates each one needs, the serialization and rollback checklist that keeps two applies from colliding, and the parts of the job that actually consume the clock.

Overview

The two jobs answer different questions, which is why they are separate.

The migration test job answers "does this migration apply". It starts a database container, loads the schema baseline that production is on today, applies every pending migration, diffs the resulting schema against the snapshot checked into the repository, then applies the down migration and re-applies to prove the reverse path exists. It runs on pull requests, it holds no production credentials, and it can fail as often as it likes.

The production apply job answers "should this migration run now". It runs on the default branch after the test job passes, it waits for a human approver on a protected deployment environment, it holds the one credential that can write to the production database, and it must never overlap with another copy of itself.

Both jobs belong on the Linux runners, because service containers need Docker and the cloud runners documentation states that macOS runners do not support nested virtualization and cannot run Docker.

Two other parts of the product surface show up in this workflow. Snapshot runners capture a runner VM mid-workflow so a later job boots with a database volume already restored, and CI observability gives you the per-step duration history that tells you whether the baseline restore or the migration itself is growing. The surface also includes remote Docker builders, an MCP server, and the Action Debugger.

Configuration

The migration test job

name: migrations
on:
  pull_request:
    paths:
      - "db/migrations/**"
      - ".github/workflows/migrations.yml"
  push:
    branches: [main]

jobs:
  migration-test:
    runs-on: warp-ubuntu-latest-x64-4x
    timeout-minutes: 20
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: app_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U app -d app_test"
          --health-interval 2s
          --health-timeout 3s
          --health-retries 30
    env:
      DATABASE_URL: postgres://app:app@localhost:5432/app_test
      PGOPTIONS: "-c lock_timeout=5s -c statement_timeout=600s"
    steps:
      - uses: actions/checkout@v5

      - name: Load the production schema baseline
        run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f db/schema/baseline.sql

      - name: Apply pending migrations
        run: ./scripts/migrate up

      - name: Diff against the checked-in schema snapshot
        run: |
          pg_dump --schema-only --no-owner "$DATABASE_URL" > /tmp/schema.sql
          diff -u db/schema/schema.sql /tmp/schema.sql

      - name: Prove the reverse path
        run: |
          ./scripts/migrate down 1
          ./scripts/migrate up

The baseline file is the point of the job. Applying migrations to an empty database only proves the chain is internally consistent, and most production incidents come from a migration meeting data and indexes that an empty database does not have. Dump the schema from a production replica on a schedule, commit it as db/schema/baseline.sql, and the test job runs against the shape you are about to change.

The health options are what make services usable. GitHub Actions waits for the container to report healthy before the first step runs, so the job never needs a sleep in front of psql. Interval and retry tuning is covered in service containers in GitHub Actions.

PGOPTIONS sets a short lock_timeout in the test job for the same reason it belongs in production: a migration that cannot take its lock inside five seconds should fail loudly rather than sit at the head of the lock queue blocking every reader behind it.

The production apply job

  apply-production:
    needs: migration-test
    if: github.ref == 'refs/heads/main'
    runs-on: warp-ubuntu-latest-x64-2x
    environment: production-db
    concurrency:
      group: db-migrate-production
      cancel-in-progress: false
    timeout-minutes: 30
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v5

      - name: Record the restore point
        run: ./scripts/pitr-marker >> "$GITHUB_STEP_SUMMARY"

      - name: Show pending migrations
        run: ./scripts/migrate status
        env:
          DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}

      - name: Apply
        run: ./scripts/migrate up
        env:
          DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}
          PGOPTIONS: "-c lock_timeout=5s -c statement_timeout=1800s"

      - name: Record the applied version
        run: ./scripts/migrate version >> "$GITHUB_STEP_SUMMARY"

Three lines carry the weight. environment: production-db puts the job behind whatever required reviewers and wait timer you configured on that environment, and it scopes PRODUCTION_DATABASE_URL to the environment so a pull request job cannot read it. cancel-in-progress: false queues a second run instead of killing a migration that is mid-statement. needs: migration-test means the chain has already applied and reversed once against the baseline before anyone is asked to approve it.

The credential handling on the runner side is worth knowing when the secret in that job is a production database password. The security documentation states that each runner runs in its own virtual machine, created on demand and destroyed after the build, with its own encrypted storage volume, and that WarpBuild does not access or store build secrets.

Serialization and rollback checklist

Serialization:

  • One concurrency group for the apply job across every workflow that can write to that database, with cancel-in-progress: false.
  • The apply job is the only holder of the write credential, scoped to the production-db environment rather than to the repository.
  • lock_timeout set to single-digit seconds so blocked DDL fails instead of queueing.
  • statement_timeout set above the longest expected migration and below the job timeout-minutes.
  • Long backfills split out of the schema migration and run as their own batched job, so the DDL transaction stays short.

Rollback:

  • Every migration ships with a down step, and the test job runs down 1 then up on every pull request.
  • Expand and contract ordering: the additive migration ships before the code that uses it, and the destructive migration ships in a later release than the code that stopped using the column.
  • No DROP in the same release as the code change that made the column unused.
  • A restore point is recorded in the step summary before the apply step runs, so recovery does not start with a search through the console.
  • A failed apply is recovered with a new forward migration rather than by editing the failed file, because the version table already records the attempt.

Sizing

Neither job is compute bound, so size them separately and stop paying build-job rates for a job that waits on a server.

JobRunner labelvCPURAMStoragePer minuteWhat sets the size
Migration test, schema-only baselinewarp-ubuntu-latest-x64-4x416GB150GB SSD$0.008Postgres and psql share the VM; the baseline restore is I/O bound
Migration test, baseline with seed data over 5GBwarp-ubuntu-latest-x64-8x832GB150GB SSD$0.016Restore parallelism and page cache for index builds
Production applywarp-ubuntu-latest-x64-2x28GB150GB SSD$0.004One connection waiting on the server

Rates from the pricing page and the cloud runners documentation, checked on 2026-08-13. A service container is billed as part of the runner, so the database in the test job costs nothing beyond the minutes the job already runs.

A monthly model for a repository where 260 pull request runs touch migrations at 6 minutes each, and 60 merges reach production at 4 minutes each:

LineArithmeticMonthly
Migration test on warp-ubuntu-latest-x64-4x260 x 6 min x $0.008$12.48
Production apply on warp-ubuntu-latest-x64-2x60 x 4 min x $0.004$0.96
Total$13.44

For the smallest size, warp-ubuntu-latest-x64-2x costs $0.004 per minute against $0.006 per minute for GitHub-hosted ubuntu-latest: 33 percent lower list price (GitHub pricing, checked 2026-08-13).

Bottlenecks

The baseline restore, not the migration

On most repositories the migration statement takes a second and the baseline restore takes minutes. Restore schema-only where the migration does not depend on data volume. Where it does, use a template database and create each test database with CREATE DATABASE app_test TEMPLATE app_seeded, which copies files instead of replaying inserts. Where the seeded dataset is large and changes on a merge cadence rather than per pull request, carry the restored volume in a snapshot runner and boot from it, which is the approach in can I keep a database warm between GitHub Actions jobs.

Lock queues behind a long read

An ALTER TABLE that needs an ACCESS EXCLUSIVE lock waits behind any open transaction on that table, and every new query queues behind the waiting ALTER. A thirty second analytics query turns a one second migration into a thirty second outage. The lock_timeout in both jobs converts that into a failed step you can retry, and adding an index with CREATE INDEX CONCURRENTLY keeps the index build out of the queue entirely.

Two applies at once

Two merges landing a minute apart, a manual re-run of a failed job, and a scheduled workflow that also migrates are the three ways teams end up with concurrent applies. The runner platform does not stop this for you: run as many jobs as your workflows need, because generally available Linux and Windows runners do not have plan-level concurrency caps and capacity adjusts dynamically. The single named concurrency group in the apply job is the control, and it has to be the same group string in every workflow that can write to that database.

The deploy that races the migration

An application deploy that starts before the migration finishes runs new code against an old schema. Make the deploy job depend on the apply job, and keep the expand and contract ordering so that either side can be ahead of the other for one release without breaking. Deployment jobs on GitHub Actions covers the shape of the job on the other side of that dependency.

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 for 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 read what runs on the runner beside your migration.

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 the migration workflow at a warp- label, read the baseline restore step timing in the job log, and size from that number.

FAQ

Should migrations run in the same job as the deploy?

No. Keep the migration apply in its own job with its own environment gate and its own database credentials, and make the deploy job depend on it. One job that migrates and deploys has a single timeout, a single log, and one approval that covers the schema change and the release together.

How do I stop two migration jobs from running at once?

Put the apply job in a concurrency group with cancel-in-progress: false, so a second run queues instead of starting or cancelling the first. Generally available Linux and Windows runners do not have plan-level concurrency caps, so configure this deployment's serialization in the workflow file.

What does a fresh database in a GitHub Actions job cost?

A migration test job on warp-ubuntu-latest-x64-4x costs $0.008 per minute, and the Postgres service container shares that runner rather than billing separately. A 6 minute test run is $0.048, and the 2 vCPU production apply job at $0.004 per minute is cheaper still because it spends its life waiting on the server.

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.