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 upThe 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-dbenvironment rather than to the repository. lock_timeoutset to single-digit seconds so blocked DDL fails instead of queueing.statement_timeoutset above the longest expected migration and below the jobtimeout-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 1thenupon 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
DROPin 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.
| Job | Runner label | vCPU | RAM | Storage | Per minute | What sets the size |
|---|---|---|---|---|---|---|
| Migration test, schema-only baseline | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | Postgres and psql share the VM; the baseline restore is I/O bound |
| Migration test, baseline with seed data over 5GB | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | Restore parallelism and page cache for index builds |
| Production apply | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | One 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:
| Line | Arithmetic | Monthly |
|---|---|---|
| Migration test on warp-ubuntu-latest-x64-4x | 260 x 6 min x $0.008 | $12.48 |
| Production apply on warp-ubuntu-latest-x64-2x | 60 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.