Upgrade a Supabase project to a new Postgres major version, end to end
What you will do
Section titled “What you will do”Upgrade a Supabase project to a new Postgres major version (e.g. 15 -> 17) with minimal risk. Three tracks - pick one:
| Track | Downtime | Reconfiguration after | Pick when |
|---|---|---|---|
| A: upgrade in place, rehearsed on a clone | ~1 min per GB of disk, derived not measured - see below | none - same project, URL, keys, JWT secret | the default, for almost everyone |
| B: cut over to a new PG 17 project with sbshift | seconds (replication lag drain) | the full new-project list (table below) | downtime budget is smaller than the in-place window; very large DB; the upgrade doubles as a region move |
| C: Docker pg_upgrade lab | n/a - rehearsal only | n/a | you want the blocker audit + a timing feel without creating anything billable; complements A |
Track A is the two dashboard flows run in order: rehearse on a throwaway Restore to a New Project clone, then run the real thing in place on production. Track B is logical replication orchestrated by sbshift (schema pre-step, replicate/watch, checksum reconcile, write-stop cutover); it lands on a new project - new ref, new API keys, new JWT secret, the same reconfiguration burden as the clone column below - and it is the only cross-region path (see the region-migration guide). Track C is sbshift’s local Docker rehearsal aid. For any task in any track, the three-ways matrix shows the manual, UI/API, and sbshift method side by side.
Prerequisites: a paid plan (physical backups enabled; PITR recommended), the
Supabase dashboard, and optionally rclone or aws-cli if your rehearsal
needs real storage files. Behaviour in this guide was validated against the
live platform on 2026-07-29 (clone from PITR, then byte-level and config-level
checks on the clone) and 2026-07-30 (full sbshift pipeline against a throwaway
cross-region pair, plus a Docker pg_upgrade lab); anything not re-verifiable by
you in an hour is marked as such.
The clone flow and the in-place upgrade are different operations. A clone is a new project - new project ref, new API keys, new JWT secret, and a pile of project-level settings that do not come along. The in-place upgrade swaps the instance underneath your existing project - nothing about how clients connect changes. Do not mix the two checklists.
What carries over and what does not
Section titled “What carries over and what does not”Validated 2026-07-29 by seeding a source project (public + private buckets,
custom schema exposed in the Data API, pgjwt, a live pg_cron job, SMTP
markers) and restoring it to a new project. The “clone” column is measured;
the “in-place” column follows from the upgrade being a same-project instance
swap.12
Two things about re-verifying this, established 2026-08-03. The clone has no
Management API endpoint - database/backups, restore, restore-pitr,
restore-point, undo and schedule all act on the same project, and nothing
creates a project from a backup, so this flow is a dashboard click and environment
creation cannot be scripted through it. And a project created for the occasion
cannot be cloned, because the clone needs a completed physical backup and backups
are daily. Re-checking the table therefore needs an existing project rather than
throwaway infrastructure. Thirteen of the clone-column rows below are independently
corroborated by Supabase’s own clone documentation.2 Four were
re-measured on 2026-08-04 against a different source and held: same region, new
anon key, auth settings reset to defaults, auth.users copied intact.
| Item | In-place upgrade (prod) | Restore to a New Project (clone) |
|---|---|---|
| Project ref / API URL | unchanged | NEW - update client env |
| anon / service API keys | unchanged | NEW |
| JWT signing secret | unchanged | NEW (existing sessions invalid on the clone) |
| Schema, data, indexes, roles | upgraded in place | copied |
auth.users (accounts, hashed passwords) | kept | copied |
| Vault / encryption root key | kept | copied |
| Compute + disk attributes | unchanged | mirrors source at clone time |
| Region | unchanged | same as source |
| Storage buckets + object metadata | untouched | copied - storage.buckets and storage.objects rows live in the database, so they come across even though the docs say “bucket configurations are NOT copied”2 |
| Storage file bytes | untouched | NOT copied - the object row arrives, the bytes do not. Re-confirmed 2026-08-04; note the body says 404 NoSuchKey while the wire status is 400, so assert on the body |
| Auth settings (SMTP, templates, site/redirect URLs) | untouched | NOT copied - reset to defaults |
| Data API exposed schemas | untouched | NOT copied - reset to public,graphql_public |
| Edge Functions + secrets | untouched | NOT copied |
| Realtime settings | untouched | NOT copied |
| Read replicas | must DELETE before, recreate after | NOT copied |
Deprecated extensions (e.g. pgjwt on PG 17) | must drop before | carried - drop on the clone too |
pg_cron / pg_net jobs | n/a | carried and still active - and they fire within minutes, see below |
| Using the clone as a source for another clone | n/a | not supported - a restored project cannot itself be cloned2, though it does get physical backups of its own within a minute of coming up |
| Data currency | n/a - upgraded in place | as of the backup you pick, not the moment you click - with PITR, the instant you pick |
| Vault secrets | kept | copied and they decrypt - the encryption root key travels2 |
| A non-default Data API schema | untouched | the SCHEMA and its rows copy; only the exposure config resets, so it is present but unreachable over PostgREST |
| PITR on the clone | n/a | not inherited - a clone of a PITR project comes up with daily backups |
| Custom roles with md5 passwords | reset or they fail post-upgrade | same on the clone after its upgrade |
| Logical replication slots | must drop before | n/a |
Clone cron jobs fire before you get to them
Section titled “Clone cron jobs fire before you get to them”The table says carried and still active. Measured 2026-08-04, that undersells
it. A source with two jobs on */5 and */15 schedules produced a clone where
both arrived active = true and both fired and succeeded at the first
matching tick - six minutes after the clone came up, before anyone could have
opened the dashboard to disable them.
So the interval between a clone existing and its jobs running is one cron tick, not a task for after the coffee. Disable them as part of the clone procedure, not as a follow-up.
Whether that matters depends entirely on what the job does. Jobs that only touch
their own database are harmless on a copy - they delete the clone’s rows.
pg_net, wrappers, or anything calling an external API are the problem: those
run from the clone against whatever they were pointed at, which is production,
on a schedule nobody chose.2
One related trap when you go looking. cron.job_run_details is copied like any
other table - the clone here inherited 40,843 run records going back four
months - so a clone appears to have been running its jobs for as long as the
source has. Filter on a start time after the clone was created to see what the
clone itself actually did.
The clone is a copy of a backup, not of the project
Section titled “The clone is a copy of a backup, not of the project”The row above that costs people the most is data currency, because nothing in the flow announces it. The clone is restored from a backup you select, and without PITR the newest one available is the last daily.
Measured 2026-08-04: the source’s newest physical backup was 18:43Z, the clone was created 04:33Z the next morning - 9 h 50 m later - and nine of ten tables matched exactly. The tenth was short a single row, written at 00:01Z that morning: after the backup, before the click, present on the source, absent on the clone. A quiet day on a quiet project; the same window under real traffic is the whole delta.
With PITR you get a date-and-time selector instead of a list of dailies,2 and measured 2026-08-04, it does close the gap to the instant you pick. A project was seeded two and a half hours after its newest daily backup; a clone taken with the selector came up carrying every seeded artifact, where the same clone without PITR would have had none of them.
The floor is the restore itself, not zero. That clone took 150 s to reach
ACTIVE_HEALTHY (the two non-PITR ones took 100 s and 180 s), and a source under
real traffic keeps writing throughout. And the clone does not inherit PITR -
it comes up with ordinary daily backups, so the copy is protected differently
from the thing it was copied from.
Turning PITR on is not free of consequence either. It is refused below Small compute, and the resize that gets you there took the data plane down for 61 s going up and 58 s coming back. The addon endpoint rate-limits consecutive changes, so the compute change and the PITR change cannot be applied back to back.
That is fine for what this guide uses a clone for - a rehearsal, where being a few hours behind production changes nothing. It is not fine if you reach for the same button as a migration. Then you need writes stopped at the chosen instant, a reconcile of the delta afterwards, or a copy method that streams rather than snapshots.
Storage: metadata copies, bytes do not
Section titled “Storage: metadata copies, bytes do not”Object URLs are constructed from the project that serves the request - they are not stored as absolute URLs - so on a clone every file appears under the new project ref and the dashboard looks complete. But the bytes live in S3 keyed by the source project and are not copied:3
- pre-existing files return 404 under the clone’s ref (public URLs, nested paths, and signed URLs alike);
- files you upload to the clone work fine;
- deleting the source project breaks every old file on the clone.
If your rehearsal needs the real files, sync them over the S3-compatible endpoint (per-project access keys under Settings > Storage):
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...# download from source, then upload to clone (or rclone remote-to-remote)aws s3 sync s3://my-bucket ./restore-staging \ --endpoint-url https://SOURCE_REF.supabase.co/storage/v1/s3aws s3 sync ./restore-staging s3://my-bucket \ --endpoint-url https://CLONE_REF.supabase.co/storage/v1/s3Bucket metadata is already on the clone, so the targets exist. If the rehearsal only needs the API surface (upload + retrieval paths), skip the sync and treat old-file 404s as expected.
Data API schema exposure is project config, not data
Section titled “Data API schema exposure is project config, not data”The Exposed schemas setting (Settings > API) is PostgREST db_schema
configuration, which is project-level config - it is not in Postgres, so a
physical restore cannot carry it. Your schemas, tables, and data all arrive;
the clone just does not expose them until you re-add them under
Settings > API. Symptom if you forget: PGRST106 / “schema not exposed”
errors from the Data API against an otherwise perfectly restored database.
An empty list fails differently: PostgREST cannot build a schema cache at
all, so it serves a steady 503 PGRST002 retry loop (measured) and takes
/graphql/v1 down with it. Check the exposed-schemas list before chasing
that as an outage.
The same applies to every Auth setting - SMTP credentials, email templates,
site URL, redirect URLs - all reset to defaults on a clone.
One nearby toggle does NOT save you here: Automatically expose new tables
(Integrations > Data API settings, a.k.a. “Default privileges for new
entities”) only auto-grants new tables and functions in an already-exposed
schema - it never exposes a new schema. Its mechanism is ALTER DEFAULT PRIVILEGES, which lives in Postgres, so those grants do survive a clone;
the schema list does not.4
pg_cron jobs come along running
Section titled “pg_cron jobs come along running”Extensions themselves are in the database, so they copy - including
deprecated ones like pgjwt (which then blocks the clone’s own PG 17
upgrade until dropped). More dangerously, pg_cron jobs copy active: a
clone of production will happily fire production cron jobs a second time.
Disable them before anything else:
select cron.unschedule(jobid) from cron.job; -- or selectively:select cron.alter_job(<jobid>, active := false);The same caution applies to pg_net, wrappers, and anything else that
performs external side effects.2
Every task, three ways
Section titled “Every task, three ways”Every way of landing on a new Postgres major - or a new project - reduces to
the same task list. Each task has three methods: the manual checklist
(raw SQL + dashboard clicks), the UI/API method (dashboard flows, the
Management API, the supabase CLI), and the sbshift method (the
orchestrated pipeline). The tracks below tell you when to use which; this
table is the how at a glance.
| Task | Manual checklist | UI / API | sbshift |
|---|---|---|---|
| Create the target project | dashboard > New project | supabase projects create / POST /v1/projects | sandbox up (rehearsal pair) |
| Enable extensions | dashboard > Database > Extensions | SQL create extension | bootstrap (auto-diffs the source) |
| Restore roles + schema | pg_dumpall --roles-only + pg_dump --schema-only into psql | supabase db dump (roles / schema) | bootstrap --confirm |
| Copy auth users | pg_dump --data-only --schema=auth --exclude-table-data=auth.schema_migrations, restore in one transaction with session_replication_role = replica | supabase db dump --data-only --schema auth -x auth.schema_migrations | bootstrap --with-auth-data |
| Move table data | pg_dump --data-only + restore (downtime = full copy time) | dashboard > Restore to a New Project (same region only) | replicate + watch (consistent copy + WAL stream) |
| Verify data identical | count(*) per table | - | reconcile (chunked checksums) |
| Copy project config (auth, realtime, postgrest, storage, pooler) | transcribe the dashboard settings pages | Management API GET/PATCH per endpoint | config-sync |
| Copy secrets (function env, integration creds) | dashboard re-entry | POST /v1/projects/{ref}/secrets | configSync.projectSecrets (opt-in) |
| Create storage buckets | dashboard > Storage > New bucket | POST /storage/v1/bucket | the storage push auto-creates them (lands private) |
| Move storage bytes | rclone / aws s3 sync over the S3 endpoint | supabase storage cp | storage <localDir> |
| Deploy Edge Functions | paste in the dashboard editor | supabase functions deploy --project-ref | functions |
| Re-create cron jobs | re-run cron.schedule(...) by hand | same - SQL only, no automation anywhere | same |
| Match compute size | dashboard > Settings > Compute | PATCH /billing/addons | provision |
| Health-check the target | dashboard > Advisors | Management API advisors endpoint | verify |
| Cut over | stop writes, repoint app env by hand | same | cutover (quiesce check, lag drain, sequence resync) |
| Retire the old project | dashboard > Settings > Delete project | DELETE /v1/projects/{ref} | teardown; sandbox down for rehearsals |
Path A: upgrade in place (default)
Section titled “Path A: upgrade in place (default)”Rehearse on a clone
Section titled “Rehearse on a clone”Zero user impact. Do this even if the production run feels trivial - the clone upgrade previews the exact blocker list and time estimate the production upgrade will show you.
- Clone. Source project -> Database -> Backups -> Restore to a New Project -> pick the latest PITR point (or a daily backup). Note the cost preview: the clone mirrors your source’s current compute and bills until deleted. A clone cannot itself be a clone source.2
- Sanitize the clone (before it does anything):
- unschedule / deactivate
pg_cronjobs (they copied over active), - drop
pgjwtand any other extension deprecated on your target version, - drop leftover logical replication slots, if any.
- unschedule / deactivate
- Reconfigure the clone (the part that is always manual):
- new API keys -> your preview/staging deployment env,
- SMTP provider + sender details (Auth > SMTP),
- re-expose custom schemas (Settings > API),
- email templates, site URL, redirect URLs.
- Optionally sync storage bytes (see above) if validation needs them.
- Upgrade the clone: Settings > Infrastructure > Upgrade project -> target version. Read the blocker list and the downtime estimate before confirming - this is the same pre-flight the production project will get.
- Validate with your app against the target Postgres version:
- login flow + JWKS endpoint,
- your heaviest write paths,
- storage upload and retrieval,
- any custom-schema Data API calls,
- watch the query logs for plan regressions.
- Green -> schedule the production window. Red -> you just saved your production window; debug on the clone.
If you want a production-data seed for preview branches instead of the clone
flow, the documented one-liner is
supabase db dump --data-only --linked > supabase/seed.sql - branches run
seed.sql once at creation.56
Optional: rehearse pg_upgrade itself in Docker (sbshift upgrade lab)
Section titled “Optional: rehearse pg_upgrade itself in Docker (sbshift upgrade lab)”The clone rehearsal previews the blocker list and the platform time
estimate, but it cannot tell you how long the pg_upgrade step itself takes
on data shaped like yours, and it costs a mirrored-compute project while it
exists. sbshift’s upgrade rehearsal
runs the whole thing locally in Docker, for $0, with no project at all:
bun start upgrade doctor --to 17 # read-only blocker audit of the sourcebun start upgrade capture --out-dir capture # dump roles + schema + databun start upgrade lab --capture-dir capture --runs 3 --keepbun start upgrade verify # prove the upgraded cluster is data-identicalupgrade lab loads your captured data (or a synthetic fixture via
--seed-gib) into a PG 15 container, snapshots the data directory, then runs
pg_upgrade --link to PG 17 N times from a fresh snapshot each time, followed
by post-upgrade ANALYZE, and emits a timing report. upgrade verify
chunked-checksums the pre-upgrade source against the upgraded cluster.
Measured 2026-07-30 on a 0.23 GiB fixture (this machine, 15 -> 17, 3 runs):
pg_upgrade --link itself took 3.7s per run (+ ~1.2s analyze, ~4-6s
initdb/prep). The lesson generalises: at real database sizes pg_upgrade --link is rarely the bottleneck - the Supabase in-place upgrade’s downtime is
dominated by the disk copy that precedes it (GP3 default ~100 Mbps, so ~1
minute per GB of disk), which is why the dashboard estimate scales with disk
size.1 The lab also accepts --prod-bytes <n> to fold that
copy-time estimate into its report.
That per-GB figure is derived, not measured: it is 100 Mbps of documented GP3 throughput turned into a rate, and the arithmetic actually gives ~1.3 min per GB rather than 1. Nothing here timed a real in-place upgrade, and a throughput ceiling bounds one phase rather than measuring downtime.
One observation that does not corroborate the scaling, from reading the
eligibility endpoint on 2026-08-04: duration_estimate_hours came back as a
flat 1 on three small projects, each offering a single patch-level target.
If the published estimate scaled with disk at that size, three different
projects would not all report the same integer - so either the field is a
floor, or it is coarse, or it does not scale the way this derivation assumes.
Treat the per-GB rate as a planning heuristic with an unverified slope.
Use the lab when you want the blocker audit + a timing feel without creating anything billable; use the clone when you need to validate your app against the real platform. They complement each other - the lab has no Data API, no Auth, no storage.
Run the production upgrade
Section titled “Run the production upgrade”Everything in the reconfiguration list above is irrelevant here - the
project, URL, keys, JWT secret, auth settings, and storage stay exactly as
they are. What changes is the Postgres version (and the rest of the service
stack images). Mechanics: the project goes offline, a new instance is
provisioned, data is copied (GP3 default: ~100 Mbps, so ~1 minute per GB of
disk), pg_upgrade runs, platform validations run, a base backup is taken,
and the project comes back. If the upgrade fails, the original database is
brought back online automatically.1
Version-specific caveats to check first
Section titled “Version-specific caveats to check first”Four platform caveats the upgrade screen does not raise as blockers. Two are silent post-upgrade regressions, one can fail the upgrade run, one fires on a later restore.1
| Caveat | Who is affected | Do this |
|---|---|---|
cron.job_run_details bloat | anyone using pg_cron without pruning | Prune it before the window. The upgrade drops and recreates pg_cron, duplicating the details table first; a huge one adds disk pressure that degrades or fails the upgrade. |
| pg_graphql 1.6.0 disables introspection | anyone whose tooling calls __schema / __type (GraphiQL, graphql-codegen, Relay) | Re-enable per schema after the upgrade: comment on schema public is e'@graphql({"introspection": true})'; - merge with existing directives, a new comment overwrites the old. Data queries are unaffected. |
ltree indexes need a rebuild | upgrades to 15.18 / 17.10 on a multibyte encoding (UTF-8) or a non-libc collation provider (ICU, builtin) | Post-upgrade REINDEX INDEX CONCURRENTLY every ltree index. Until then, label searches silently miss rows - no error. |
| Custom operator selectivity estimators | anyone with a user- or extension-provided oprrest / oprjoin on their own operators | Attaching a non-built-in estimator now needs superuser. Existing operators keep working; the check fires when one is re-created - pg_dump/pg_restore, a logical restore, or a branch - failing with must be superuser to specify a non-built-in restriction estimator function. Affects Path B’s schema restore and preview branches more than the in-place upgrade. |
The queries that tell you whether the last two apply to you are in the platform doc; run them before the window rather than reading the caveat afterwards.1
T-7 days
Section titled “T-7 days”- Announce the maintenance window to users (for small databases the real downtime is minutes, but budget for validation).
- Prune
cron.job_run_detailsif you usepg_cron(see the caveat table above) - the only one of the four that can fail the upgrade run. - Pick the window: your users’ quietest hours; avoid Friday/weekend.
- Run your own readiness review: enabled extensions vs the deprecated
list,
reg*data types referencing system OIDs, logical replication slots, custom roles with md5 passwords (pg_authid.rolpassword LIKE 'md5%'), unused indexes / bloat (smaller DB = shorter window). The dashboard surfaces hard blockers on the upgrade screen itself.1
T-1 day
Section titled “T-1 day”- Drop deprecated extensions (for PG 17:
pgjwt,plcoffee,plls,plv8- drop them from the dashboard’s database extensions page). - Delete read replicas (projects with replicas cannot be upgraded; you will recreate them after). Make sure the app tolerates reading from the primary in the meantime.
- Take a fresh
pg_dumplogical backup - belt and braces on top of physical backups / PITR. - Prepare your write-stop (maintenance page, deploy freeze, queue pause - whatever stops writes cleanly).
In the window
Section titled “In the window”- Stop application writes.
- Settings > Infrastructure > Upgrade project -> target version. The dashboard’s estimate is the authoritative downtime figure.
- Watch it through; keep the rollback fact in mind (failure restores the original DB).
Post-upgrade, same window
Section titled “Post-upgrade, same window”- Validate auth first (login + JWKS), then your heaviest write paths.
- Recreate read replicas -> they get new connection strings -> update app env.
- Reset passwords of any custom md5 roles to rehash as scram-sha-256.
- Re-enable pg_graphql introspection if any tooling needs it, and
REINDEX INDEX CONCURRENTLYanyltreeindexes (caveat table above). - Check extension versions and the slow-query log for plan regressions.
- Re-enable writes.
Days after
Section titled “Days after”- Keep monitoring (advisors, slow queries, error rates) before declaring done.
- Delete the rehearsal clone - it bills at mirrored compute until you do.
Path B: cut over to a new PG 17 project with sbshift
Section titled “Path B: cut over to a new PG 17 project with sbshift”Pick this track when the in-place window (disk-copy minutes per GB) is too long, or when the upgrade doubles as a region move. If a region move is the primary goal, the region-migration guide covers the region-specific repointing work (OAuth callbacks, custom-domain DNS, region-specific pooler hostnames) on top of this same pipeline. What you trade: the target is a new project, so every row in the clone column of the carries-over table applies - new ref, new keys, new JWT secret (existing sessions invalidate), plus the Auth/Realtime/Edge-Function reconfiguration. You are buying a downtime window measured in the seconds it takes to drain replication lag, at the price of the full new-project reconfiguration.
How it works: native Postgres logical replication, orchestrated by sbshift. A publication + replication slot on the source, a subscription on the new PG 17 project; the subscription takes a consistent initial copy of your data tables, then streams WAL to stay caught up while the app keeps writing to the source. When you are ready: stop writes, prove the two databases are byte-identical with a chunked checksum, drain the last lag, resync sequences, repoint the app.7
Prerequisites: Bun (sbshift runs from source, no build step), psql /
pg_dump, the supabase CLI, and a personal access token
(SUPABASE_ACCESS_TOKEN) for project creation and the config copy.
Connectivity: direct vs pooler, and the IPv6 question
Section titled “Connectivity: direct vs pooler, and the IPv6 question”Logical replication needs a direct connection
(db.<ref>.supabase.co:5432); the pooler cannot stream WAL. Direct hosts are
IPv6-only unless the project has the IPv4 add-on.8 Decide where
sbshift runs before anything else:
- IPv6-capable host (e.g. a small VM in the target region): point both URLs at the direct hosts. Simplest.
- IPv4 add-on on both projects for the migration window: same config, run from anywhere; remove it after.
- Split (works from a plain IPv4 laptop):
SOURCE_DB_URL/TARGET_DB_URLpoint at the IPv4 session pooler (used for dumps, admin, reconcile) andSOURCE_REPLICATION_URLpoints at the source direct host. The subscription connection is dialed by the target’s walreceiver over Supabase’s internal network, so your box never needs an IPv6 route. This is the configuration the measured run below used.
Step 0: rehearse on a throwaway pair
Section titled “Step 0: rehearse on a throwaway pair”bun start sandbox up --org <org-id> # creates + seeds a throwaway src/tgt pair# ... drive every step below against it ...bun start sandbox down # deletes both projects + generated filesThe sandbox seeds an awkward fixture on purpose (STORED tsvector generated
column, IDENTITY sequence, a no-PK table) and writes a ready-made
migrate.sandbox.yaml + .env.sandbox. Steps 1-7 below are exactly its
printed drive-through - run them against the sandbox once before touching
production config. Every drive-through command takes the sandbox config + env
files, e.g. bun start -c migrate.sandbox.yaml --env-file .env.sandbox doctor.
The pair bills until sandbox down deletes it.
e.g. bun start sandbox up --org <your-org-id> provisions
sbshift-sandbox-src (eu-central-1) + sbshift-sandbox-tgt (eu-west-1) -
the measured run below was provisioned exactly this way, in ~3 minutes.
Step 1: stage config + env
Section titled “Step 1: stage config + env”migrate.config.yaml: source/target refs, the enumerated table list
(never FOR ALL TABLES - it needs superuser), publication/slot/subscription
names, the reconcile table list, and the WAL watchdog threshold
(watchdog.maxRetainedWalMb). .env: the connection strings +
SUPABASE_ACCESS_TOKEN.
Step 2: create the target project
Section titled “Step 2: create the target project”supabase projects create <name> --org-id <org> --region <target-region> \ --db-password "$(openssl rand -base64 24)"New projects land on the current platform default major (17 as of 2026-07),
which is what makes this track an upgrade path. Put the new ref in the config
and its pooler/direct strings in .env. e.g. for a PG 15 production in
eu-central-1 moving to eu-west-1:
supabase projects create my-app-pg17 \ --org-id <your-org-id> --region eu-west-1 \ --db-password "$(openssl rand -base64 24)"Step 3: restore what replication does not carry
Section titled “Step 3: restore what replication does not carry”Logical replication moves table rows only - no DDL, roles, sequences,
extensions, or the managed auth/storage schemas:
bun start bootstrap --with-auth-data --confirmbootstrap enables non-default extensions and restores roles + schema onto
the target in one pass; --with-auth-data also loads the auth-schema rows
(with triggers deferred via session_replication_role = replica). The auth
part is not optional when any replicated table has an FK into
auth.users - the initial copy is FK-rejected row by row otherwise, and
doctor flags that cross-schema FK explicitly. Manual fallback: supabase db dump for roles/schema/auth + a single-transaction psql restore -
excluding auth.schema_migrations from the auth data dump (it is SELECT-only
for the postgres role on managed targets; the restore fails on it otherwise).
A PG 17 target adds one schema-restore failure mode: an operator carrying a
non-built-in selectivity estimator cannot be re-created without superuser, so
the restore dies on must be superuser to specify a non-built-in restriction estimator function.1 Most projects have no such operator; the
platform doc carries the pg_operator query that tells you whether yours
does.
Step 4: the gates - doctor + preflight
Section titled “Step 4: the gates - doctor + preflight”bun start doctor # fail-closed readiness audit of BOTH endsbun start preflight # hard gate: wal_level, grants, replica identityDo not proceed past a NOT READY. In the measured run doctor exited 1
with exactly the 5 missing target tables before bootstrap, and reported
READY (with warnings) - 29 pass / 4 warn / 0 fail - after it.
Step 5: replicate + watch
Section titled “Step 5: replicate + watch”bun start replicate # publication + slot + subscription; initial copy startsbun start watch # polls until every table is srsubstate='r'The app keeps writing to the source throughout - that is the point of the
track. watch aborts on its own if the slot bloats source WAL past the
watchdog threshold - an unconsumed slot retains WAL until the source disk
fills - or the slot’s wal_status flips to lost.
Step 6: cutover (the only downtime)
Section titled “Step 6: cutover (the only downtime)”# 1. STOP application writes to the source (read-only / maintenance page).bun start reconcile # must print RECONCILE PASSEDbun start cutover # drains lag to 0, resyncs sequences, drops subscription# 2. Repoint the app at the new project (URL + keys). Never re-enable# writes on the source.reconcile chunked-checksums every replicated table on both ends.
cutover samples the source WAL LSN twice and warns loudly if it is still
advancing (writes not actually stopped), waits for lag to reach zero, then
setval()s every sequence owned by a replicated column - sequences do not
replicate, and the schema restore only carried their dump-time values, so
rows inserted after bootstrap would otherwise collide on the next
serial/IDENTITY insert.
Step 7: copy non-data config, verify, tear down
Section titled “Step 7: copy non-data config, verify, tear down”bun start config-sync --dry-run # review the diff firstbun start config-sync # Auth/Realtime/PostgREST/Storage/pooler settingsbun start provision # preview matching billable infra (compute size!)bun start verify # Supabase advisors on the target, as a health gatebun start teardown # drop subscription -> slot -> publicationSecrets are stripped by default - re-enter SMTP/OAuth credentials on the target. The JWT signing secret and API keys are never copied (they are not on any synced endpoint), so users re-login after cutover. Leave the old project paused, not deleted, for a few days as a safety net.
Storage and Edge Functions, if you have them, transfer separately here:
bun start storage <localDir> for object bytes (buckets do not arrive with
the schema restore, and the push re-creates them private - restore
visibility) and supabase functions deploy --project-ref <new-ref>. The
region-migration guide details both
transfers plus the wider repointing checklist (OAuth callbacks, custom
domains) that a new project on this track needs too.
Two things the schema restore deliberately leaves behind, to re-apply by
hand after cutover: cron.schedule(...) job rows (job schedules are data,
not DDL - the target will not fire your pg_cron jobs until you re-add them,
which also means no double-firing while both projects are live), and any
ALTER ROLE ... SET / ALTER DATABASE ... SET GUC overrides, which live in
pg_db_role_setting and are invisible to the Management API endpoints
config-sync reads (doctor diffs them and warns).
The same pipeline by hand
Section titled “The same pipeline by hand”Everything sbshift automates above is plain Postgres + Management API calls. If you cannot (or do not want to) run the tooling, the manual equivalent of steps 3-6:
# schema + roles + auth users (steps 3's manual fallback, in full)supabase db dump --db-url "$SRC" -f roles.sql --role-onlysupabase db dump --db-url "$SRC" -f schema.sqlsupabase db dump --db-url "$SRC" -f auth.sql --data-only --schema auth --use-copy \ -x auth.schema_migrations # SELECT-only for postgres on managed targetspsql -d "$TGT" -f roles.sql # errors on pre-existing roles are expectedpsql --single-transaction -v ON_ERROR_STOP=1 -d "$TGT" -f schema.sqlpsql --single-transaction -v ON_ERROR_STOP=1 -d "$TGT" \ -c 'SET session_replication_role = replica' -f auth.sql-- source: publication over the tables to move (enumerate; FOR ALL TABLES-- needs superuser)create publication upgrade_move for table public.t1, public.t2;
-- target: subscription (creates its slot on the source, takes a consistent-- initial copy, then streams WAL)create subscription upgrade_move_sub connection 'host=db.<src-ref>.supabase.co port=5432 dbname=postgres user=postgres password=<pw> sslmode=require' publication upgrade_move with (slot_name = 'upgrade_move_slot', copy_data = true);
-- target: watch until every table is ready (i -> d -> s -> r)select c.relname, r.srsubstatefrom pg_subscription_rel rjoin pg_subscription s on s.oid = r.srsubidjoin pg_class c on c.oid = r.srrelid;
-- cutover: STOP WRITES, then confirm the lag has drained on the sourceselect application_name, write_lag, flush_lag, replay_lagfrom pg_stat_replication;
-- target: resync every owned sequence (sequence VALUES do not replicate)select setval('public.items_id_seq', (select max(id) from public.items));
-- teardowndrop subscription upgrade_move_sub; -- on the targetdrop publication upgrade_move; -- on the sourceselect pg_drop_replication_slot('upgrade_move_slot'); -- on the source, if the slot remainsWhat you give up by hand: the gates (readiness audit, wal_level/grant/
replica-identity preflight), the WAL-bloat watchdog while the subscription
is live, the chunked-checksum reconcile (a manual count(*) per table is
not a proof of identical data), the quiescence check, and the config copy -
transcribe the settings pages or script the Management API endpoints from
the three-ways matrix. If you can take the full
copy time as downtime instead, plain dump/restore to a new PG 17 project
skips the replication machinery entirely - the region guide’s Variant
A
writes it out; the reconfiguration burden is identical.
Measured run (2026-07-30)
Section titled “Measured run (2026-07-30)”Full pipeline against a throwaway cross-region pair (eu-central-1 -> eu-west-1, driven from a non-IPv6 box via the pooler + replication-URL split; projects deleted after):
| Step | Observed |
|---|---|
sandbox up | pair ACTIVE_HEALTHY in ~3 min; seeded 3,000 documents (~2 KB each) + 300 IDENTITY items |
doctor (pre-bootstrap) | exit 1: 24 pass / 4 warn / 5 fail - the 5 fails were exactly the missing target tables |
bootstrap --confirm | extensions + roles + schema restored in one pass |
doctor (post-bootstrap) | READY (with warnings): 29 pass / 4 warn / 0 fail (warnings: subscriber worker-count GUCs, fine at this size) |
replicate + watch | publication + slot + subscription created; 5/5 tables synced in under 5s at this size |
reconcile | RECONCILE PASSED - source == target row counts and hashes on all tables |
cutover | WAL-quiescent check passed, lag 0.0 KB, sequence public.items_id_seq set to 300, subscription dropped |
| post-cutover insert | insert into items ... returning id -> 301, no PK collision |
config-sync --dry-run | per-section diffs (Realtime, Database Pooler, PostgREST, Storage); secrets stripped |
verify | advisor gate fired correctly (rls_disabled_in_public on the intentionally RLS-less sandbox fixture) |
| total wall time | under 15 minutes including project provisioning and sandbox down |
A second pass the same day seeded the repointing surface (pg_cron job, public
bucket, auth user, project secret, custom role, config change) and exercised
the config-sync apply path live; results are tabulated in the
region-migration guide’s measured
run.
Timing caveat: 3,000 small rows copy in seconds. At real sizes the initial
copy is the long pole, and a STORED generated column is the measured
throughput killer (~7x slower copy, ~11 MiB/s vs ~80 MiB/s in sbshift’s
scale rehearsal) - watch shows live per-table copy %, so calibrate your
window from that, and set maxRetainedWalMb generous enough to hold a full
copy’s worth of WAL on the source.
Verification
Section titled “Verification”| Check | How | Expected |
|---|---|---|
| Clone is a faithful data copy | compare row counts / spot-check marker rows | identical |
| Clone storage metadata | list buckets + objects via API | all present |
| Clone storage bytes | GET a pre-existing object via clone ref | 404 (expected, not a bug) |
| Clone upload path | PUT then GET a new object | 200 / 200 |
| Clone config gaps | Settings > API, Auth > SMTP | defaults; re-entered values stick |
| Clone upgrade pre-flight | upgrade screen blocker list | only known blockers (e.g. pgjwt) |
| Prod post-upgrade | show server_version via psql | target version |
| Prod identity | API URL, keys, JWT secret | unchanged |
| Replica recreated | app env holds new replica string | reads flowing |
| Replication caught up (sbshift track) | sbshift watch (or status --require-synced) | every table srsubstate='r' |
| Data identical pre-cutover | sbshift reconcile | RECONCILE PASSED |
| Sequence resync | post-cutover insert ... returning id on a serial/IDENTITY table | next value, no PK collision |
| Target health gate | sbshift verify | no advisor lints at/above your --fail-on level |
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”- Supabase’s clone doc contradicts itself on extensions, and only one reading is
safe. It lists “Database extensions and settings” under what needs manual
reconfiguration, and then states that because the entire database is copied the
clone includes every extension enabled at the source - with a warning to disable
pg_net,pg_cronandwrappersafterwards.2 The second reading matches what we measured. Someone who trusted the first list would expect to re-enable extensions by hand and would never go looking for a live cron job on the clone that is already firing at production endpoints. - Do not confuse the two flows. Clone = new project + reconfiguration. In-place upgrade = same project, no reconfiguration. Nearly every “upgrade checklist” mistake is applying one flow’s steps to the other.
- A clone’s dashboard looks complete for storage. It is not - bytes are missing. Test a GET, not the listing.
pg_cronon clones is a production hazard. Jobs copy active and will double-fire (emails, webhooks, billing jobs) until unscheduled.- Schema exposure and SMTP are the two most-forgotten reconfigurations - both fail silently-ish: the API 404s on your custom schema, and auth emails fall back to the rate-limited built-in sender.
- Disk only grows outside an upgrade - and shrinks during one. You can increase disk size but never decrease it by hand or via auto-resize.9 The upgrade is the documented exception: it right-sizes the disk to ~1.2x the current database size, so a 100 GB database on a 200 GB disk comes back on 120 GB.1 On gp3 the IOPS you can provision scales with disk size (500 extra IOPS per GB), so a right-sized disk also lowers that ceiling.9
- The clone mirrors compute at clone time. If you resized production after your last clone, your next clone lands on the new size and bills accordingly.
- The upgrade keeps your compute size. It is a version change, not a resize.
- The same in-place process covers minor and future major versions (e.g. PG 18 when it becomes available as a target).1
- Restore-to-new-project is dashboard-only - there is no Management API or CLI path for it as of 2026-07 (the API only restores in place). Re-verify before scripting around it.
- Sequences do not replicate (sbshift track). The schema restore carries
their dump-time values, so every row inserted on the source between
bootstrap and cutover would collide on the next serial/IDENTITY insert
without a resync -
cutoversetval()s every owned sequence (verified: id 301 after 300 seeded rows). - pg_cron does not double-fire on the sbshift track (unlike the clone
track):
cron.schedule(...)rows are data, not DDL, so they are simply absent on the target until you re-add them after cutover. The risk is forgotten jobs, not duplicate ones. - A STORED generated column is the copy bottleneck - measured ~7x slower
initial copy (~11 MiB/s vs ~80 MiB/s in sbshift’s scale rehearsal).
watchshows live per-table copy %; calibrate your window from that, not from row counts. config-syncis blind topg_db_role_setting.ALTER ROLE ... SET/ALTER DATABASE ... SEToverrides (statement_timeout,auto_explain.*, …) are not on the Management API endpoints it reads.doctordiffs them between source and target and warns; re-apply by hand.
References
Section titled “References”-
Supabase, “Upgrading,” Supabase Docs. https://supabase.com/docs/guides/platform/upgrading ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
-
Supabase, “Restore to a new project,” Supabase Docs. https://supabase.com/docs/guides/platform/clone-project ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
Supabase, “Restore Dashboard backup - Migrate storage objects,” Supabase Docs. https://supabase.com/docs/guides/platform/migrating-within-supabase/dashboard-restore ↩
-
Supabase, “Using Custom Schemas,” Supabase Docs. https://supabase.com/docs/guides/api/using-custom-schemas ↩
-
Supabase, “CLI workflows,” Supabase Docs. https://supabase.com/docs/guides/local-development/cli-workflows ↩
-
Supabase, “Working with branches - Seeding behavior,” Supabase Docs. https://supabase.com/docs/guides/deployment/branching/working-with-branches ↩
-
sbshift, “Postgres-to-Postgres logical-replication migrator,” GitHub. https://github.com/erfianugrah/sbshift ↩
-
Supabase, “IPv4 Address,” Supabase Docs. https://supabase.com/docs/guides/platform/ipv4-address ↩
-
Supabase, “Compute and Disk,” Supabase Docs. https://supabase.com/docs/guides/platform/compute-and-disk ↩ ↩2