Skip to content

Upgrade a Supabase project to a new Postgres major version, end to end

Upgrade a Supabase project to a new Postgres major version (e.g. 15 -> 17) with minimal risk. Three tracks - pick one:

TrackDowntimeReconfiguration afterPick when
A: upgrade in place, rehearsed on a clone~1 min per GB of disk, derived not measured - see belownone - same project, URL, keys, JWT secretthe default, for almost everyone
B: cut over to a new PG 17 project with sbshiftseconds (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 labn/a - rehearsal onlyn/ayou 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.

production project(PG 15)clone(Restore to a New Project)PITR /physical backupproduction on PG 17(in-place upgrade)in place:Settings > Infrastructure >Upgrade projectnew project on PG 17(sbshift cutover)sbshift: schema dump +logical replication,write-stop cutoverclone on PG 17+ app validationreconfigure+ upgradegreen?schedule window

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.

ItemIn-place upgrade (prod)Restore to a New Project (clone)
Project ref / API URLunchangedNEW - update client env
anon / service API keysunchangedNEW
JWT signing secretunchangedNEW (existing sessions invalid on the clone)
Schema, data, indexes, rolesupgraded in placecopied
auth.users (accounts, hashed passwords)keptcopied
Vault / encryption root keykeptcopied
Compute + disk attributesunchangedmirrors source at clone time
Regionunchangedsame as source
Storage buckets + object metadatauntouchedcopied - 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 bytesuntouchedNOT 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)untouchedNOT copied - reset to defaults
Data API exposed schemasuntouchedNOT copied - reset to public,graphql_public
Edge Functions + secretsuntouchedNOT copied
Realtime settingsuntouchedNOT copied
Read replicasmust DELETE before, recreate afterNOT copied
Deprecated extensions (e.g. pgjwt on PG 17)must drop beforecarried - drop on the clone too
pg_cron / pg_net jobsn/acarried and still active - and they fire within minutes, see below
Using the clone as a source for another clonen/anot supported - a restored project cannot itself be cloned2, though it does get physical backups of its own within a minute of coming up
Data currencyn/a - upgraded in placeas of the backup you pick, not the moment you click - with PITR, the instant you pick
Vault secretskeptcopied and they decrypt - the encryption root key travels2
A non-default Data API schemauntouchedthe SCHEMA and its rows copy; only the exposure config resets, so it is present but unreachable over PostgREST
PITR on the clonen/anot inherited - a clone of a PITR project comes up with daily backups
Custom roles with md5 passwordsreset or they fail post-upgradesame on the clone after its upgrade
Logical replication slotsmust drop beforen/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.

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):

Terminal window
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/s3
aws s3 sync ./restore-staging s3://my-bucket \
--endpoint-url https://CLONE_REF.supabase.co/storage/v1/s3

Bucket 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

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 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.

TaskManual checklistUI / APIsbshift
Create the target projectdashboard > New projectsupabase projects create / POST /v1/projectssandbox up (rehearsal pair)
Enable extensionsdashboard > Database > ExtensionsSQL create extensionbootstrap (auto-diffs the source)
Restore roles + schemapg_dumpall --roles-only + pg_dump --schema-only into psqlsupabase db dump (roles / schema)bootstrap --confirm
Copy auth userspg_dump --data-only --schema=auth --exclude-table-data=auth.schema_migrations, restore in one transaction with session_replication_role = replicasupabase db dump --data-only --schema auth -x auth.schema_migrationsbootstrap --with-auth-data
Move table datapg_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 identicalcount(*) per table-reconcile (chunked checksums)
Copy project config (auth, realtime, postgrest, storage, pooler)transcribe the dashboard settings pagesManagement API GET/PATCH per endpointconfig-sync
Copy secrets (function env, integration creds)dashboard re-entryPOST /v1/projects/{ref}/secretsconfigSync.projectSecrets (opt-in)
Create storage bucketsdashboard > Storage > New bucketPOST /storage/v1/bucketthe storage push auto-creates them (lands private)
Move storage bytesrclone / aws s3 sync over the S3 endpointsupabase storage cpstorage <localDir>
Deploy Edge Functionspaste in the dashboard editorsupabase functions deploy --project-reffunctions
Re-create cron jobsre-run cron.schedule(...) by handsame - SQL only, no automation anywheresame
Match compute sizedashboard > Settings > ComputePATCH /billing/addonsprovision
Health-check the targetdashboard > AdvisorsManagement API advisors endpointverify
Cut overstop writes, repoint app env by handsamecutover (quiesce check, lag drain, sequence resync)
Retire the old projectdashboard > Settings > Delete projectDELETE /v1/projects/{ref}teardown; sandbox down for rehearsals

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.

  1. 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
  2. Sanitize the clone (before it does anything):
    • unschedule / deactivate pg_cron jobs (they copied over active),
    • drop pgjwt and any other extension deprecated on your target version,
    • drop leftover logical replication slots, if any.
  3. 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.
  4. Optionally sync storage bytes (see above) if validation needs them.
  5. 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.
  6. 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.
  7. 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:

Terminal window
bun start upgrade doctor --to 17 # read-only blocker audit of the source
bun start upgrade capture --out-dir capture # dump roles + schema + data
bun start upgrade lab --capture-dir capture --runs 3 --keep
bun start upgrade verify # prove the upgraded cluster is data-identical

upgrade 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.

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

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

CaveatWho is affectedDo this
cron.job_run_details bloatanyone using pg_cron without pruningPrune 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 introspectionanyone 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 rebuildupgrades 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 estimatorsanyone with a user- or extension-provided oprrest / oprjoin on their own operatorsAttaching 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

  • Announce the maintenance window to users (for small databases the real downtime is minutes, but budget for validation).
  • Prune cron.job_run_details if you use pg_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
  • 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_dump logical backup - belt and braces on top of physical backups / PITR.
  • Prepare your write-stop (maintenance page, deploy freeze, queue pause - whatever stops writes cleanly).
  • 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).
  • 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 CONCURRENTLY any ltree indexes (caveat table above).
  • Check extension versions and the slow-query log for plan regressions.
  • Re-enable writes.
  • 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_URL point at the IPv4 session pooler (used for dumps, admin, reconcile) and SOURCE_REPLICATION_URL points 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.
Terminal window
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 files

The 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.

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.

Terminal window
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:

Terminal window
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:

Terminal window
bun start bootstrap --with-auth-data --confirm

bootstrap 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.

Terminal window
bun start doctor # fail-closed readiness audit of BOTH ends
bun start preflight # hard gate: wal_level, grants, replica identity

Do 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.

Terminal window
bun start replicate # publication + slot + subscription; initial copy starts
bun 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.

Terminal window
# 1. STOP application writes to the source (read-only / maintenance page).
bun start reconcile # must print RECONCILE PASSED
bun 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”
Terminal window
bun start config-sync --dry-run # review the diff first
bun start config-sync # Auth/Realtime/PostgREST/Storage/pooler settings
bun start provision # preview matching billable infra (compute size!)
bun start verify # Supabase advisors on the target, as a health gate
bun start teardown # drop subscription -> slot -> publication

Secrets 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).

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:

Terminal window
# schema + roles + auth users (steps 3's manual fallback, in full)
supabase db dump --db-url "$SRC" -f roles.sql --role-only
supabase db dump --db-url "$SRC" -f schema.sql
supabase db dump --db-url "$SRC" -f auth.sql --data-only --schema auth --use-copy \
-x auth.schema_migrations # SELECT-only for postgres on managed targets
psql -d "$TGT" -f roles.sql # errors on pre-existing roles are expected
psql --single-transaction -v ON_ERROR_STOP=1 -d "$TGT" -f schema.sql
psql --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.srsubstate
from pg_subscription_rel r
join pg_subscription s on s.oid = r.srsubid
join pg_class c on c.oid = r.srrelid;
-- cutover: STOP WRITES, then confirm the lag has drained on the source
select application_name, write_lag, flush_lag, replay_lag
from 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));
-- teardown
drop subscription upgrade_move_sub; -- on the target
drop publication upgrade_move; -- on the source
select pg_drop_replication_slot('upgrade_move_slot'); -- on the source, if the slot remains

What 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.

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):

StepObserved
sandbox uppair 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 --confirmextensions + 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 + watchpublication + slot + subscription created; 5/5 tables synced in under 5s at this size
reconcileRECONCILE PASSED - source == target row counts and hashes on all tables
cutoverWAL-quiescent check passed, lag 0.0 KB, sequence public.items_id_seq set to 300, subscription dropped
post-cutover insertinsert into items ... returning id -> 301, no PK collision
config-sync --dry-runper-section diffs (Realtime, Database Pooler, PostgREST, Storage); secrets stripped
verifyadvisor gate fired correctly (rls_disabled_in_public on the intentionally RLS-less sandbox fixture)
total wall timeunder 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.

CheckHowExpected
Clone is a faithful data copycompare row counts / spot-check marker rowsidentical
Clone storage metadatalist buckets + objects via APIall present
Clone storage bytesGET a pre-existing object via clone ref404 (expected, not a bug)
Clone upload pathPUT then GET a new object200 / 200
Clone config gapsSettings > API, Auth > SMTPdefaults; re-entered values stick
Clone upgrade pre-flightupgrade screen blocker listonly known blockers (e.g. pgjwt)
Prod post-upgradeshow server_version via psqltarget version
Prod identityAPI URL, keys, JWT secretunchanged
Replica recreatedapp env holds new replica stringreads flowing
Replication caught up (sbshift track)sbshift watch (or status --require-synced)every table srsubstate='r'
Data identical pre-cutoversbshift reconcileRECONCILE PASSED
Sequence resyncpost-cutover insert ... returning id on a serial/IDENTITY tablenext value, no PK collision
Target health gatesbshift verifyno advisor lints at/above your --fail-on level
  • 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_cron and wrappers afterwards.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_cron on 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 - cutover setval()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). watch shows live per-table copy %; calibrate your window from that, not from row counts.
  • config-sync is blind to pg_db_role_setting. ALTER ROLE ... SET / ALTER DATABASE ... SET overrides (statement_timeout, auto_explain.*, …) are not on the Management API endpoints it reads. doctor diffs them between source and target and warns; re-apply by hand.
  1. Supabase, “Upgrading,” Supabase Docs. https://supabase.com/docs/guides/platform/upgrading 2 3 4 5 6 7 8 9

  2. 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

  3. Supabase, “Restore Dashboard backup - Migrate storage objects,” Supabase Docs. https://supabase.com/docs/guides/platform/migrating-within-supabase/dashboard-restore

  4. Supabase, “Using Custom Schemas,” Supabase Docs. https://supabase.com/docs/guides/api/using-custom-schemas

  5. Supabase, “CLI workflows,” Supabase Docs. https://supabase.com/docs/guides/local-development/cli-workflows

  6. Supabase, “Working with branches - Seeding behavior,” Supabase Docs. https://supabase.com/docs/guides/deployment/branching/working-with-branches

  7. sbshift, “Postgres-to-Postgres logical-replication migrator,” GitHub. https://github.com/erfianugrah/sbshift

  8. Supabase, “IPv4 Address,” Supabase Docs. https://supabase.com/docs/guides/platform/ipv4-address

  9. Supabase, “Compute and Disk,” Supabase Docs. https://supabase.com/docs/guides/platform/compute-and-disk 2