Supabase Migrations Runbook
Local - Branch - Production.
A step-by-step operational guide for adopting the Supabase CLI migration workflow on an existing remote project, with type-safe queries that provably match your remote schema and high confidence when running psql or supabase db push.
Verified against the current Supabase CLI and docs (June 2026).
Everything downstream depends on understanding two independent tracking systems:
- Git tracks your migration files — the
.sqlfiles insupabase/migrations/. - Supabase tracks what has been applied to each database — a table called
supabase_migrations.schema_migrationsthat exists separately in your local DB, each branch DB, and production.
supabase db push compares your local supabase/migrations/ folder against the target database’s history table and runs only the migrations not yet applied, in timestamp order. Drift happens when these two systems disagree (almost always because someone changed a database directly instead of through a migration file).
The shadow database is your safety net. When you run supabase db diff or supabase db push, the CLI spins up a throwaway “shadow” Postgres container, replays your migration files into it from scratch, and compares. This is how it validates that your migration set is internally consistent before touching anything real.
Why type safety becomes a guarantee, not a hope: your TypeScript types are generated from the same migration files that get pushed to production. If (a) local replays cleanly, (b) the committed types match what those migrations produce, and (c) a post-push diff against remote comes back empty — then your client types match your remote schema by construction. Sections 3, 4, 7, and 9 enforce each link in that chain.
The golden rule: once you are on migrations, never change a remote database directly (no DDL in the Dashboard SQL Editor or Table Editor against production). Every schema change — even a one-line column add — goes through a migration file. Direct changes bypass the history table and cause db push to fail with sync errors.
The CLI runs the entire Supabase stack (Postgres, Auth, Storage, Realtime, Studio, a local SMTP server, and the diff tool) in Docker containers. You need Docker Desktop or any Docker-compatible runtime — OrbStack (lightest on macOS), Podman, Colima, or Rancher Desktop all work.
- Install Docker Desktop: https://www.docker.com/products/docker-desktop/
- Allocate at least 7 GB RAM to Docker (Docker Desktop → Settings → Resources). The stack will refuse health checks below this.
- Verify it’s running:
docker ps # should return without error (an empty table is fine)Do not
npm install -g supabase— global install via npm is unsupported and will warn/fail. Use a package manager ornpx.
macOS (recommended):
brew install supabase/tap/supabaseWindows:
scoop bucket add supabase https://github.com/supabase/scoop-bucket.gitscoop install supabaseCross-platform / pin per project (good for teams + CI): add it as a dev dependency and call it through npx:
npm install --save-dev supabasenpx supabase --versionIf you use the dev-dependency approach, prefix every command in this runbook with
npx(e.g.npx supabase start). Pinning the version inpackage.jsonkeeps everyone — and CI — on the same Studio/stack version.
Verify and keep current (the CLI version determines your local Studio version, so stay updated):
supabase --versionbrew upgrade supabase # or: scoop update supabase / npm i -D supabase@latest- Git — your migration files live in version control.
- Node.js 18+ — only needed for your app +
supabase-js, not for the CLI itself if installed via brew/scoop. psql(optional, for Section 9) — installed with thelibpq/postgresql-clientpackage.- A Supabase account plus your existing remote project, and your database password (Dashboard → Project Settings → Database → Reset password if you don’t have it).
Run these from the root of your application repo (e.g. your Next.js project).
supabase initThis creates a supabase/ directory containing config.toml. It is safe — and expected — to commit this folder to git. It also offers to scaffold editor settings; accept if you want VS Code / Deno hints for Edge Functions.
supabase loginOpens a browser to generate a Personal Access Token, stored in your OS credential store. (In CI you skip this by setting the SUPABASE_ACCESS_TOKEN environment variable instead.)
supabase link --project-ref <your-project-ref>- Find
<your-project-ref>in your project URL:https://supabase.com/dashboard/project/<project-ref>, or just runsupabase linkwith no flag and pick from the interactive list. - It will prompt for your database password. Linking is what lets
db push,db pull, anddb dumpknow which remote to talk to.
supabase init adds sensible ignores, but confirm your repo ignores environment files and CLI temp state:
# Supabasesupabase/.temp/supabase/.branches/
# Local env — never commit.env.env.local.env*.localCommit:
git add supabase/ .gitignore && git commit -m "chore: initialize Supabase CLI"
This is the single most important step for an existing project, and the linchpin of “local matches remote.” Because production already has tables, your first migration is not a new table — it’s a snapshot of what’s already live.
supabase db pullThis connects to your linked remote, captures the current schema, and writes supabase/migrations/<timestamp>_remote_schema.sql. It also records the baseline in the remote history table so future pushes know where they’re starting from.
db pull excludes the auth and storage schemas by default, and — importantly — when the migrations/ folder is still empty, the first pull ignores the --schema flag entirely. So pull those after 3.1, as a second step, if you have custom triggers, RLS policies, or buckets defined there:
supabase db pull --schema auth,storageOpen the generated file(s) and skim them. The auto-generated DDL is more verbose than hand-written SQL (it spells out identity sequences, default privileges, etc.) — that’s expected. The diff tool can occasionally miss exotic objects, so a quick read pays off.
git add supabase/migrationsgit commit -m "migration: baseline existing remote schema"After this, your local migration history and your remote are in sync, both anchored to the same baseline, and both tracked in git.
supabase startFirst run downloads the Docker images (a few minutes). When it finishes it prints your local endpoints and keys — keep this handy:
| Service | Local URL |
|---|---|
| API gateway | http://127.0.0.1:54321 |
| Postgres (direct) | postgresql://postgres:postgres@127.0.0.1:54322/postgres |
| Studio (GUI) | http://127.0.0.1:54323 |
| Inbucket (test email) | http://127.0.0.1:54324 |
It also prints local anon / service_role (and publishable/secret) keys. These are well-known, non-secret demo values used only by the local stack — they are not your production keys.
You can re-print these any time (handy for scripting .env.local):
supabase status # human-readablesupabase status -o env # as KEY=VALUE pairs (ANON_KEY, SERVICE_ROLE_KEY, etc.)supabase db resetThis destroys and rebuilds your local database, replays every migration in supabase/migrations/ from scratch in order, then runs supabase/seed.sql if present. If this succeeds, your migration set is internally consistent — which is exactly what will happen on a fresh branch and what db push validates against remote. Run db reset liberally; it’s free confidence and only touches local.
Open Studio at http://127.0.0.1:54323 and confirm your tables look like production (minus data).
There are two distinct environment files in play:
- Your app’s
.env.local(e.g. Next.js) — the keys your application uses to reach Supabase. Point it at local while developing:
# .env.local — LOCAL developmentNEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<local publishable/anon key from `supabase status`>SUPABASE_SECRET_KEY=<local service_role/secret key — server-side only>For production/preview builds you swap these for the remote project’s URL and its publishable key (client) and secret key (server) from Dashboard → Project Settings → API Keys. (More on the new key model in Section 11.)
- The CLI’s
.env— used bysupabaseitself to substituteenv(...)references insideconfig.toml(e.g. OAuth provider secrets for local Auth). The CLI auto-reads a.envin your project root. Changes toconfig.tomlrequiresupabase stop && supabase startto take effect.
Rule of thumb:
.env.localconfigures your app;.envconfigures the CLI. Neither is ever committed.
mkdir -p src/typessupabase gen types typescript --local > src/types/database.types.ts--local reads the running local stack — which is your migration files made real, so the generated types reflect exactly what will ship. (You can also generate from the linked project with --linked, or any project by ref with --project-id <ref>; use those for verification, but treat --local as your authoring source of truth.)
import { createClient } from '@supabase/supabase-js'import type { Database } from '@/types/database.types'
export const supabase = createClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,)Passing <Database> gives you autocomplete and compile-time errors on table names, column names, return shapes, inserts, and updates — across client, server actions, and Edge Functions.
{ "scripts": { "db:start": "supabase start", "db:stop": "supabase stop", "db:reset": "supabase db reset", "db:new": "supabase migration new", "db:diff": "supabase db diff -f", "db:types": "supabase gen types typescript --local > src/types/database.types.ts", "db:lint": "supabase db lint", "db:push": "supabase db push" }}Regenerate types every time you change the schema: migrate → npm run db:reset → npm run db:types → commit both.
This is what guarantees committed types never silently fall out of step with the schema your migrations produce. Add .github/workflows/generate-types.yml:
name: generate-typeson: pull_request:jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: supabase/setup-cli@v1 with: version: latest - run: supabase init - run: supabase db start - name: Verify generated types match the schema run: | supabase gen types typescript --local > src/types/database.types.ts if ! git diff --ignore-space-at-eol --exit-code --quiet src/types/database.types.ts; then echo "::error::Committed types are out of date. Run 'npm run db:types' and commit." git diff exit 1 fiIf a PR changes the schema but forgets to regenerate types, CI fails. That closes the loop.
Two equally valid styles. Pick per change.
# 1. Create an empty, timestamped migrationsupabase migration new add_invoices_table
# 2. Write the DDL in the new file:# supabase/migrations/<timestamp>_add_invoices_table.sqlcreate table if not exists public.invoices ( id bigint primary key generated always as identity, org_id uuid not null references public.organizations (id), amount numeric(12,2) not null, status text not null default 'draft', created_at timestamptz not null default now());
-- RLS-first: enable and write policies in the same migrationalter table public.invoices enable row level security;
create policy "members read their org invoices" on public.invoices for select to authenticated using ( org_id = (select auth.jwt() ->> 'org_id')::uuid );# 3. Apply just the new pending migration to localsupabase migration up# (or `supabase db reset` to replay the entire set from scratch — best before pushing)
# 4. Regenerate types + sanity-checknpm run db:typessupabase db lint # plpgsql_check for schema errors
# 5. Commit migration + regenerated types togethergit add supabase/migrations src/types/database.types.tsgit commit -m "feat(db): add invoices table with org-scoped RLS"Use this when you’d rather click through the local Studio UI. Only ever do this against the local database, then capture it:
# 1. Make your changes in local Studio (http://127.0.0.1:54323)
# 2. Diff local against the shadow DB and write a migration filesupabase db diff -f add_invoices_table
# 3. Review the generated SQL (migra can miss some object types — always read it)
# 4. Verify it replays cleanly, then regenerate typessupabase db resetnpm run db:types
# 5. Commitgit add supabase/migrations src/types/database.types.tsgit commit -m "feat(db): add invoices table"Either way: never make these changes in the remote Studio. That’s the golden-rule violation that breaks
db push.
supabase test new invoices_rls # stubs a pgTAP testsupabase test db # runs all tests via pg_proveThis is the middle environment that catches problems before production. You have two paths depending on whether you want managed Supabase Branching.
Branching gives every pull request its own isolated Supabase instance with a full copy of your schema (deliberately no production data), so you can validate migrations and test the app end-to-end before merging.
- Preview branches are ephemeral — created per PR, paused after inactivity, deleted when the PR merges/closes.
- Persistent branches are long-lived — use one for
staging/ QA. - Branching (preview environments) requires the Pro plan. GitHub-based deploys and CLI deploys work on all plans; only the per-PR preview environments need Pro.
Set up once:
- Dashboard → your project → Branches → enable Branching, connect your GitHub repo, set the production branch (usually
main) and the directory (supabase/). - (Optional) Add a seed file so branches start with reference data — see Configuration → seeding.
Then your loop becomes:
git checkout -b feat/invoices→ commit your migration + types → push → open a PR.- Supabase spins up a preview branch, runs the deployment workflow (clone → pull migrations from main → health → configure → migrate → seed → deploy functions). If your migration has a bug, the Migrate step fails on the branch — never on production.
- The
generate-typesCI guard (Section 5.4) runs in the same PR. - Review the branch’s own Studio/API, test the app against it.
- Merge to
main→ Supabase automatically runs the same deployment workflow against production. No manualdb pushneeded.
If you’re not using Branching, mirror production with a separate Supabase project for staging and deploy via CLI/CI:
- Create a brand-new staging project (don’t reuse one whose schema was hand-edited — the CLI would try to reapply changes).
- In CI, set
SUPABASE_ACCESS_TOKEN,SUPABASE_DB_PASSWORD, and the stagingSUPABASE_PROJECT_IDas encrypted secrets. - Wire two GitHub Actions: on merge to a
developbranch →supabase db pushto staging; on merge tomain→supabase db pushto production. Switch the target via thePROJECT_IDsecret per workflow.
Whether you push manually or your CI does it, this is the safe sequence.
supabase migration listShows, side by side, which migrations are applied locally vs on remote and where they diverge. The rows that are local-only are precisely what a push will apply.
supabase db push --dry-runPrints the migrations that would be applied without applying them. (Note: dry-run lists the pending files; it does not fully execute the SQL — your real correctness checks are the clean local db reset in 4.2 and the branch Migrate step in Phase 6. Treat dry-run as “confirm the set,” not “validate the SQL.”)
For schema changes that drop or rewrite columns, snapshot first:
supabase db dump --linked -f backups/pre-deploy-$(date +%Y%m%d).sql # schemasupabase db dump --linked --data-only -f backups/pre-deploy-data-$(date +%Y%m%d).sql # data(Production projects also have Point-in-Time Recovery on paid plans — know your recovery window before a big migration.)
supabase db pushOn its first run against a database, this creates the supabase_migrations.schema_migrations history table; thereafter it applies only the not-yet-applied migrations, in order, and records each. Seed data is not pushed unless you explicitly pass --include-seed (you usually don’t want to seed production).
Coordinate so only one person/pipeline pushes at a time. Concurrent pushes from different machines can collide because migrations apply in timestamp order.
supabase db diff --linked --schema publicAfter a clean deploy this should print nothing. An empty diff means your local migration state and the live remote schema are identical — and therefore your generated types match production. Then re-verify types against the live schema:
supabase gen types typescript --linked > /tmp/remote.types.tsdiff src/types/database.types.ts /tmp/remote.types.ts # expect no differencesSince you run RLS-first, confirm the deploy didn’t expose anything: Dashboard → Advisors → Security Advisor, and review every finding before dismissing. (The Supabase MCP get_advisors tool can pull these programmatically.)
In the Dashboard, click Connect (top bar). You’ll see three options:
- Direct connection (
db.<ref>.supabase.co:5432) — a real Postgres connection, IPv6-only unless you have the IPv4 add-on. Best for long-lived sessions from an IPv6-capable network. - Session pooler (
aws-0-<region>.pooler.supabase.com:5432) — IPv4-friendly, persistent sessions. Use this for interactivepsqlif you’re not sure about IPv6. - Transaction pooler (port
6543) — for serverless/edge; not ideal for an interactive shell.
# Interactive psql via the session pooler (IPv4-safe)psql "postgresql://postgres.<project-ref>:<db-password>@aws-0-<region>.pooler.supabase.com:5432/postgres"Percent-encode special characters in the password if you inline it. Better: leave the password out and let it prompt, or use a
~/.pgpassfile.
- ✅ Reads, inspection, data fixes are fine. For ad-hoc reads, wrap them so you can’t accidentally write:
begin; set transaction read only;select count(*) from public.invoices where status = 'overdue';rollback;
- 🚫 Never run schema DDL (
create table,alter table,drop ...,create policy, etc.) directly on production viapsqlor the SQL Editor. That bypasses the migration history and re-breaks the local↔remote sync you worked to establish. All DDL goes through a migration file → branch → push.
| Situation | Command | What it does |
|---|---|---|
| “Are local and remote in sync?” | supabase migration list |
Side-by-side applied status |
| “Is there hidden schema drift?” | supabase db diff --linked |
Empty = no drift |
| Someone changed remote directly | supabase db pull |
Recaptures remote into a new migration file → commit it |
| Migration missing in history but change is live | supabase migration repair --status applied <timestamp> |
Marks as applied without running SQL |
| Migration recorded as applied but never actually ran | supabase migration repair --status reverted <timestamp> |
Corrects the record the other way |
| Rebuild a non-prod branch from migrations | supabase db reset --linked |
⚠️ Drops & recreates all user objects on that DB — never on production |
migration repaironly edits the history table — it never applies or reverts SQL. Use it to make the record match a database state you’ve already verified.
Supabase replaced the legacy JWT-based anon / service_role keys with a new format. Get both from Dashboard → Project Settings → API Keys.
| New key | Replaces | Where it goes | Privilege |
|---|---|---|---|
Publishable sb_publishable_… |
anon |
Client code — browser, mobile, CLI, public source | Low — governed entirely by your RLS policies |
Secret sb_secret_… |
service_role |
Server only — Edge Functions, backend, cron/workers | High — bypasses RLS (BYPASSRLS) |
Notes that matter for you:
- The secret key must never reach a browser — the gateway actively rejects it based on the User-Agent. Keep it in server-side env only.
- Legacy
anon/service_rolekeys still work during the migration window but are slated for removal in late 2026; new projects created after Nov 2025 don’t get them at all. - The publishable key carries the same low privileges as the old anon key, so your RLS policies behave identically — no policy changes needed when you switch.
- Send these keys on the
apikeyheader (not as a bearer token); user-session JWTs from Supabase Auth still go inAuthorization: Bearer ….
Before you trust a deploy, all of these should be true:
-
supabase db resetreplays the entire migration set locally with no errors (Section 4.2) - Every schema change since the last deploy exists as a committed migration file — nothing was hand-edited on remote (golden rule)
-
src/types/database.types.tswas regenerated and committed alongside the migration (Section 5) - The PR’s type-drift CI guard passed, and the branch Migrate step succeeded on a fresh DB (Sections 5.4, 7)
-
supabase migration listshows the expected pending migrations and nothing surprising (Section 8.1) - A backup was taken before any destructive change (Section 8.3)
- Post-push,
supabase db diff --linked --schema publicis empty and remote types match committed types (Section 8.5) - Security Advisor reviewed after deploy (Section 8.6)
When every box is checked, “my type-safe queries match the actual remote schema” is a fact you’ve verified end to end — not an assumption.
# Setup (once)supabase initsupabase loginsupabase link --project-ref <ref>
# Baseline an existing project (once)supabase db pullsupabase db pull --schema auth,storage # only if you have custom objects there
# Local stacksupabase startsupabase status -o envsupabase stop # add --no-backup to wipe local data
# Daily loopsupabase migration new <name>supabase migration up # apply pending to localsupabase db reset # replay ALL migrations + seed (local)supabase db diff -f <name> # capture local Studio changes as a migrationsupabase gen types typescript --local > src/types/database.types.tssupabase db lintsupabase test db
# Inspect & deploysupabase migration listsupabase db push --dry-runsupabase db dump --linked -f backup.sqlsupabase db pushsupabase db diff --linked --schema public # expect empty after deploy
# Recoverysupabase db pull # recapture drifted remotesupabase migration repair --status applied|reverted <timestamp>- Local development overview — https://supabase.com/docs/guides/local-development/overview
- CLI getting started (Docker) — https://supabase.com/docs/guides/local-development/cli/getting-started
- Database migrations (workflow, golden rule, repair) — https://supabase.com/docs/guides/deployment/database-migrations
- Managing environments (CI, staging) — https://supabase.com/docs/guides/deployment/managing-environments
- Branching — https://supabase.com/docs/guides/deployment/branching
- Generate types — https://supabase.com/docs/guides/deployment/ci/generating-types
- CLI reference — https://supabase.com/docs/reference/cli/introduction
- New API keys — https://supabase.com/docs/guides/api/api-keys