Migrate a Supabase project to another region, end to end
What you will do
Section titled “What you will do”Move a Supabase project from one region to another with near-zero downtime:
- Rehearse on a throwaway sbshift sandbox pair created in your two real regions.
- Move: create the target project, then either dump/restore (simple, downtime = copy time) or replicate with logical replication and cut over (write-stop measured in seconds) - both variants in full, by hand and automated, in Part 2.
- Repoint: the region-specific part - app env, OAuth callbacks, custom domain, Edge Functions, storage bytes.
Prerequisites: sbshift1 (Bun, no build step), the supabase CLI,
psql/pg_dump, and a SUPABASE_ACCESS_TOKEN. The pipeline below was validated live on 2026-07-30
against a throwaway cross-region pair (eu-central-1 -> eu-west-1); the full
measured-output table lives in the Postgres upgrade
guide,
whose Path B is the same pipeline run as an upgrade path.
Why a region move always means a new project
Section titled “Why a region move always means a new project”Region is chosen at project creation and never changes - and neither dashboard flow can help: the in-place upgrade swaps the instance under the same project in the same region, and Restore to a New Project clones into the same region as the source.2 So a region move is always: new project in the target region + move the data + repoint the app.
Because it is a new project, the standard new-project reconfiguration applies in full - new ref, new API keys, new JWT secret (all existing sessions invalidate), SMTP, Data API schema exposure, Edge Functions, Realtime settings. That list is enumerated, with measured behaviour, in the carries-over table of the upgrade guide. This guide covers only what is additional when the new project is in a different region.
Pick your path
Section titled “Pick your path”| Path | Downtime | Effort | When to pick |
|---|---|---|---|
| sbshift logical replication (this guide) | seconds (lag drain) | medium, scriptable | production apps; any DB too big for a dump window |
| dump / restore | minutes to hours | low | small DBs, staging, a maintenance window you already have |
Both paths are written out in full in Part 2 - Variant A (dump/restore) and Variant B (replication), each with the sbshift commands and the by-hand equivalent. The repointing work in Part 3 applies to both.
A region move is also your chance to upgrade the Postgres major version for free - a new project lands on the platform default major (17 as of 2026-07), so the same pipeline doubles as the upgrade. The upgrade guide covers the version-specific blockers (deprecated extensions, md5 roles) if you take that combination.
Every task, three ways
Section titled “Every task, three ways”A region move reduces to the same task list as any new-project migration.
Each task has a manual checklist method (raw SQL + dashboard clicks), a
UI/API method (dashboard flows, Management API, supabase CLI), and an
sbshift method. The full matrix lives in the upgrade
guide;
the rows that matter most here:
| Task | Manual checklist | UI / API | sbshift |
|---|---|---|---|
| Create the target project in the new region | dashboard > New project | supabase projects create --region | sandbox up --src-region --tgt-region (rehearsal) |
| Roles + schema + auth users | pg_dump/pg_dumpall + single-transaction psql | supabase db dump (add -x auth.schema_migrations for auth data) | bootstrap --with-auth-data --confirm |
| Move table data | pg_dump --data-only + restore (downtime = copy time) | not offered cross-region by the dashboard | replicate + watch |
| Copy config + secrets | transcribe settings pages; dashboard re-entry | Management API GET/PATCH per endpoint; POST /secrets | config-sync (+ projectSecrets opt-in) |
| Storage buckets + bytes | recreate buckets; rclone/aws s3 sync | POST /storage/v1/bucket; supabase storage cp | storage <localDir> (auto-creates buckets private) |
| Edge Functions | paste in the dashboard editor | supabase functions deploy --project-ref | functions |
| Cron jobs | re-run cron.schedule(...) by hand | same - no automation anywhere | same |
| Cut over + retire | stop writes, repoint by hand; dashboard > Delete project | DELETE /v1/projects/{ref} | cutover; teardown; sandbox down |
Part 1: pick the region, rehearse the move
Section titled “Part 1: pick the region, rehearse the move”Choose the region
Section titled “Choose the region”Closest to your users - or to your edge-to-origin path if your app talks to Supabase from a server/Worker rather than browsers.3 The choice moves more than the database: Realtime is colocated with the project’s region, and storage object bytes live in that region’s S3 - so every subsystem’s latency shifts with the move. (Edge Functions are globally distributed, but their distance to the database follows the DB region.)
Measure before committing, from where your app actually runs:
# create the target (or the sandbox pair below), then from your app host:time psql 'postgresql://postgres.<ref>:<pw>@aws-0-<region>.pooler.supabase.com:5432/postgres' -c 'select 1'# and against the API:curl -so /dev/null -w 'TTFB %{time_starttransfer}s\n' \ https://<ref>.supabase.co/rest/v1/ -H "apikey: <anon-key>"Record the same numbers for the old project - that pair is your before/after for the validation window in Part 4. Also take a baseline of error rates and p95 from your usual monitoring; you will be comparing against it post-move.
Rehearse on a throwaway pair
Section titled “Rehearse on a throwaway pair”bun start sandbox up --org <your-org-id> \ --src-region <current-region> --tgt-region <target-region># ... drive the full pipeline below against the sandbox ...bun start sandbox down # deletes both projects + generated filesThe sandbox seeds an awkward fixture (STORED generated column, IDENTITY
sequence, a no-PK table) and prints the drive-through. The pair bills until
sandbox down. Two things to harvest from the rehearsal beyond “it works”:
the config-sync --dry-run diff - on your real project that diff is your
reconfiguration checklist, read it line by line - and the watch copy
percentages, which calibrate how long your real initial copy will take.
Part 2: move the data
Section titled “Part 2: move the data”Two variants. Variant A is the simplest thing that works and takes the full copy time as downtime. Variant B is the near-zero-downtime path - the app keeps writing to the source until a write-stop measured in seconds. Both land at the same place; Part 3’s repointing checklist applies to either.
Variant A: dump and restore (simple, downtime = copy time)
Section titled “Variant A: dump and restore (simple, downtime = copy time)”# 1. create the target in the NEW region; new projects default to PG 17supabase projects create <name> --org-id <your-org-id> \ --region <target-region> --db-password "$(openssl rand -base64 24)"export SRC='postgresql://postgres.<src-ref>:<pw>@aws-0-<src-region>.pooler.supabase.com:5432/postgres'export TGT='postgresql://postgres.<tgt-ref>:<pw>@aws-0-<tgt-region>.pooler.supabase.com:5432/postgres'
# 2. roles + schema + auth users (no downtime yet - nothing is writing yet)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 targets
psql -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
# 3. STOP WRITES on the source - the downtime window starts heresupabase db dump --db-url "$SRC" -f data.sql --data-only --use-copypsql --single-transaction -v ON_ERROR_STOP=1 -d "$TGT" \ -c 'SET session_replication_role = replica' -f data.sqlSequence values come with the data dump at their write-stop state, so no
resync is needed (contrast with Variant B, where they must be resynced by
hand). session_replication_role = replica defers triggers/FK enforcement
during the load - required for the auth rows and for any FK-ordered restore.
Then: storage bytes (Part 3 item 5), config + secrets (the matrix above),
cron jobs (Part 3 item 6), and the repoint (Part 3). Downtime ends when the
app points at the new project.
Variant B: replicate and cut over (near-zero downtime)
Section titled “Variant B: replicate and cut over (near-zero downtime)”The replication pipeline is identical to the upgrade guide’s Path B - every step, its gate, and its failure modes are explained there (full step-by-step). The sbshift drive-through:
# 1. create the target in the NEW region; new projects default to PG 17supabase projects create <name> --org-id <your-org-id> \ --region <target-region> --db-password "$(openssl rand -base64 24)"
# 2. migrate.config.yaml: refs, enumerated table list, watchdog threshold# .env: SOURCE/TARGET_DB_URL (+ SOURCE_REPLICATION_URL for the IPv6 split)
# 3. restore what replication does not carry (roles, schema, extensions, auth rows)bun start bootstrap --with-auth-data --confirm
# 4. gates - do not proceed past a NOT READYbun start doctorbun start preflight
# 5. replicate; the app keeps writing to the source throughoutbun start replicatebun start watch
# 6. cutover window (the only downtime): STOP source writes firstbun start reconcile # must print RECONCILE PASSEDbun start cutover # drains lag to 0, resyncs sequences, drops subscriptionbun start config-sync --dry-run && bun start config-syncbun start provision # match compute size; preview unless --confirmbun start verify # advisor health gate on the targetbun start teardownThe same pipeline by hand - exactly what sbshift automates above:
-- source: publication over the tables to move (enumerate; FOR ALL TABLES-- needs superuser)create publication region_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 region_move_sub connection 'host=db.<src-ref>.supabase.co port=5432 dbname=postgres user=postgres password=<pw> sslmode=require' publication region_move with (slot_name = 'region_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 region_move_sub; -- on the targetdrop publication region_move; -- on the sourceselect pg_drop_replication_slot('region_move_slot'); -- on the source, if the slot remainsThe prerequisites the tooling checks for you when doing it by hand:
wal_level = logical (on by default on Supabase), the CREATE SUBSCRIPTION
privilege on the target’s postgres role (granted on Supabase), and replica
identity on every published table - a primary key is enough; a table without
one needs alter table <t> replica identity full or deletes/updates will
not replicate. Schema, roles, extensions, and auth users must already exist
on the target (same dump/restore as Variant A steps 2) before creating
the subscription, or the initial copy fails row by row.
Part 3: repoint - the region-specific checklist
Section titled “Part 3: repoint - the region-specific checklist”This is the work that makes a region move different from a version upgrade. Do it in this order; items 1-3 break loudly if forgotten, 4-7 silently.
-
App env: URL, keys, AND the pooler hostname. Three values change -
SUPABASE_URL, the anon key, and the service key - plus the pooler host if you connect via pooler: the host embeds the region (aws-0-eu-west-1.pooler.supabase.comfor a project in eu-west-1), so editing only the ref in your connection strings is not enough. The direct host (db.<ref>.supabase.co) is region-agnostic but IPv6-only unless the project has the IPv4 add-on.4 -
OAuth callback URLs contain the project ref. Every social provider is configured with a callback of the form
https://<ref>.supabase.co/auth/v1/callback, and the provider rejects anything unregistered - so social logins fail with a redirect-mismatch error the moment you flip the app to the new ref. In each provider’s console (Google Cloud > OAuth client > Authorized redirect URIs; GitHub > OAuth Apps > Authorization callback URL; etc.) add the new callback, and in the new project’s dashboard (Authentication > URL Configuration) set the site URL and any additional redirect URLs.5 Remove the old callback at the provider after the validation window. -
Custom domain: re-add + DNS cutover. Custom domains are DNS-coupled and do not migrate.6 The day before, lower the DNS TTL on the record to ~60s. In the window: add the custom domain on the new project (Settings > Custom Domains), complete its TXT verification, wait for it to report ready, then flip the CNAME to the new project. (Skip the whole item if you serve clients on the default
<ref>.supabase.cohostname and update envs instead.) -
Edge Functions: redeploy.
supabase functions deploy --project-ref <new-ref>; invoke URLs change with the ref. Re-set function secrets (or opt intoconfigSync.projectSecrets). Import maps /deno.jsonare not downloaded by the CLI - re-add those by hand if your functions use them. -
Storage: buckets, then bytes, then visibility. Nothing storage-related arrives by itself - the schema restore excludes the managed
storageschema, so the target starts with zero buckets (verified:storage.bucketsempty after bootstrap). The flow: download the source objects, push them withbun start storage <localDir>(or the S3-sync procedure in the upgrade guide’s storage section), then restore bucket visibility - the push auto-creates the bucket but lands it private even when the source bucket was public (verified), so public object URLs fail until you flip it back:update storage.buckets set public = true where id = '<bucket>';If you drive the S3-compatible endpoint directly (rclone/aws-cli), the S3 access keys are per-project - generate a new pair on the target (Settings
Storage > S3) before syncing.
-
Cron jobs: re-create after cutover. The schema restore carries your job functions but not
cron.schedule(...)rows (schedules are data, not DDL), so nothing double-fires - but nothing fires at all until you re-add the schedules on the target. Enumerate the source’s jobs before the window so re-adding is transcription, not archaeology:select jobname, schedule, command from cron.job order by jobid; -
Plan for session invalidation. A new project means a new JWT signing secret: every user session dies at cutover. Web apps absorb this as a re-login. Mobile and distributed clients need the new anon key shipped before cutover (or a forced upgrade) - options: ship the new key in an app update ahead of the window and flip the backend endpoint at cutover, dual-read both projects briefly (only viable for read-mostly apps), or accept that the installed base re-logs-in on next open.
Part 4: validation window and decommission
Section titled “Part 4: validation window and decommission”- Keep the old project paused, not deleted, for a few days as a safety net. Never re-enable writes on it - that is split-brain.
- Watch, against your Part 1 baseline: error rates, auth success (email and
each social provider), Realtime connection counts, and p95 latency from
your app host.
sbshift verifydoubles as a post-move advisor-lint health check on the new project. - Re-run the Part 1 latency measurements - the move was for these numbers; confirm they actually improved.
- Then delete the old project - it bills until you do. Remove the old OAuth callbacks at the providers and the old DNS records.
Measured run (2026-07-30)
Section titled “Measured run (2026-07-30)”A sandbox pair (eu-central-1 -> eu-west-1) seeded with the repointing-surface cases: a pg_cron job, a public storage bucket + object, an auth user, a project secret, a custom login role, and a PostgREST config change. The replication-core numbers are the upgrade guide’s measured table; this table is the region-move surface:
| Check | Observed |
|---|---|
cron.job on target after bootstrap | 0 rows - schedules do not carry; re-create post-cutover |
storage.buckets on target after bootstrap | 0 rows - bucket metadata does not arrive at all |
auth user after bootstrap --with-auth-data | present on the target |
| custom login role after bootstrap | present, rolpassword NULL - must re-password |
config-sync apply (write path) | Realtime / Pooler / PostgREST / Storage / project secrets all applied; max_rows=777 and the test secret confirmed on the target via the Management API |
| storage push to a bucket-less target | bucket auto-created private; public URL returned 400 “Bucket not found” until public = true was restored |
| anon keys, source vs target | different ref-scoped JWTs - old-project tokens cannot validate on the new project |
provision preview | no-op when the target already matches the (default) source infra |
Verification
Section titled “Verification”| Check | How | Expected |
|---|---|---|
| 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 |
| Latency actually improved | psql connect time / TTFB from your app host, old vs new | improvement toward your users |
| Email login | sign in on the new project | works (user exists, password hash carried) |
| Social login | one OAuth provider end to end | works after callback update |
| Storage | GET a synced object via the new ref’s public URL | 200 (404 = bytes not synced; 400 “Bucket not found” = visibility not restored) |
| Edge Function | invoke on the new ref | 200 |
| Cron | select * from cron.job; on the target | schedules re-added |
| Billing | dashboard | old project deleted at window end |
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”- The replication is the easy part; the repointing is the move. OAuth callbacks, the region-specific pooler hostname, custom-domain DNS, mobile key rollouts - enumerate them before the window, not during it.
- The pooler hostname is per-region.
aws-0-<region>.pooler.supabase.com- connection strings need the ref and the region updated.
- Realtime and storage move with the DB region. Both are colocated with the project - their latency to your users shifts with the move, which is usually the point. Edge Functions stay globally distributed; what changes is their round-trip to the database.
- S3 access keys are per-project. Any rclone/aws-cli/S3-API tooling needs a freshly generated pair on the target before the byte sync.
- The
config-sync --dry-rundiff is your reconfiguration checklist. Run it early (even weeks ahead, read-only) and turn every section it prints into a to-do item; secrets stay redacted, so plan the credential re-entry separately. - Drop the custom-domain TTL the day before, or your DNS cutover inherits hours of stale caching.
- Storage fails two different ways on this track. Before the push, the dashboard listing is empty (no bucket metadata at all - unlike the clone track, where the listing looks complete but bytes 404). After the push, bytes are there but public URLs still fail with 400 “Bucket not found” until visibility is restored. Test a GET on the public path, not the listing and not an authenticated GET.
- Do not confuse “paused” with “free”. The old project bills until deleted; pausing only stops compute serving.
- Bucket metadata does not arrive at all on this track (unlike the clone track, which copies it): buckets exist on the target only after you push objects or recreate them.
- Public buckets come back private. The storage push auto-creates the
bucket with
public = false; restore visibility explicitly or your public object URLs fail (verified: 400 “Bucket not found” until flipped). - If
bootstrapfails mid-way, re-running it re-plans the schema restore against tables that now exist. Recover by running the auth-data dump/restore commands it printed (they are in the preview output) rather than re-applying the whole schema. - Sandbox rehearsal is nearly free and takes under 15 minutes end to end (measured 2026-07-30, including project provisioning) - there is no reason to learn the pipeline against your production data.
References
Section titled “References”-
sbshift, “Postgres-to-Postgres logical-replication migrator,” GitHub. https://github.com/erfianugrah/sbshift ↩
-
Supabase, “Restore to a new project,” Supabase Docs. https://supabase.com/docs/guides/platform/clone-project ↩
-
Supabase, “Regions,” Supabase Docs. https://supabase.com/docs/guides/platform/regions ↩
-
Supabase, “IPv4 Address,” Supabase Docs. https://supabase.com/docs/guides/platform/ipv4-address ↩
-
Supabase, “Redirect URLs,” Supabase Docs. https://supabase.com/docs/guides/auth/redirect-urls ↩
-
Supabase, “Custom Domains,” Supabase Docs. https://supabase.com/docs/guides/platform/custom-domains ↩