Skip to content

Migrate a Supabase project to another region, end to end

Move a Supabase project from one region to another with near-zero downtime:

  1. Rehearse on a throwaway sbshift sandbox pair created in your two real regions.
  2. 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.
  3. 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.

your appold projectregion A(source of truth until cutover)writes untilcutovernew projectregion Brepoint at cutoverlogical replication:consistent copy + WAL stream
PathDowntimeEffortWhen to pick
sbshift logical replication (this guide)seconds (lag drain)medium, scriptableproduction apps; any DB too big for a dump window
dump / restoreminutes to hourslowsmall 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.

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:

TaskManual checklistUI / APIsbshift
Create the target project in the new regiondashboard > New projectsupabase projects create --regionsandbox up --src-region --tgt-region (rehearsal)
Roles + schema + auth userspg_dump/pg_dumpall + single-transaction psqlsupabase db dump (add -x auth.schema_migrations for auth data)bootstrap --with-auth-data --confirm
Move table datapg_dump --data-only + restore (downtime = copy time)not offered cross-region by the dashboardreplicate + watch
Copy config + secretstranscribe settings pages; dashboard re-entryManagement API GET/PATCH per endpoint; POST /secretsconfig-sync (+ projectSecrets opt-in)
Storage buckets + bytesrecreate buckets; rclone/aws s3 syncPOST /storage/v1/bucket; supabase storage cpstorage <localDir> (auto-creates buckets private)
Edge Functionspaste in the dashboard editorsupabase functions deploy --project-reffunctions
Cron jobsre-run cron.schedule(...) by handsame - no automation anywheresame
Cut over + retirestop writes, repoint by hand; dashboard > Delete projectDELETE /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”

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:

Terminal window
# 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.

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

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

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)”
Terminal window
# 1. create the target in the NEW region; new projects default to PG 17
supabase 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-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
# 3. STOP WRITES on the source - the downtime window starts here
supabase db dump --db-url "$SRC" -f data.sql --data-only --use-copy
psql --single-transaction -v ON_ERROR_STOP=1 -d "$TGT" \
-c 'SET session_replication_role = replica' -f data.sql

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

Terminal window
# 1. create the target in the NEW region; new projects default to PG 17
supabase 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 READY
bun start doctor
bun start preflight
# 5. replicate; the app keeps writing to the source throughout
bun start replicate
bun start watch
# 6. cutover window (the only downtime): STOP source writes first
bun start reconcile # must print RECONCILE PASSED
bun start cutover # drains lag to 0, resyncs sequences, drops subscription
bun start config-sync --dry-run && bun start config-sync
bun start provision # match compute size; preview unless --confirm
bun start verify # advisor health gate on the target
bun start teardown

The 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.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 region_move_sub; -- on the target
drop publication region_move; -- on the source
select pg_drop_replication_slot('region_move_slot'); -- on the source, if the slot remains

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

  1. 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.com for 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

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

  3. 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.co hostname and update envs instead.)

  4. Edge Functions: redeploy. supabase functions deploy --project-ref <new-ref>; invoke URLs change with the ref. Re-set function secrets (or opt into configSync.projectSecrets). Import maps / deno.json are not downloaded by the CLI - re-add those by hand if your functions use them.

  5. Storage: buckets, then bytes, then visibility. Nothing storage-related arrives by itself - the schema restore excludes the managed storage schema, so the target starts with zero buckets (verified: storage.buckets empty after bootstrap). The flow: download the source objects, push them with bun 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.

  6. 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;
  7. 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 verify doubles 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.

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:

CheckObserved
cron.job on target after bootstrap0 rows - schedules do not carry; re-create post-cutover
storage.buckets on target after bootstrap0 rows - bucket metadata does not arrive at all
auth user after bootstrap --with-auth-datapresent on the target
custom login role after bootstrappresent, 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 targetbucket auto-created private; public URL returned 400 “Bucket not found” until public = true was restored
anon keys, source vs targetdifferent ref-scoped JWTs - old-project tokens cannot validate on the new project
provision previewno-op when the target already matches the (default) source infra
CheckHowExpected
Data identical pre-cutoversbshift reconcileRECONCILE PASSED
Sequence resyncpost-cutover insert ... returning id on a serial/IDENTITY tablenext value, no PK collision
Latency actually improvedpsql connect time / TTFB from your app host, old vs newimprovement toward your users
Email loginsign in on the new projectworks (user exists, password hash carried)
Social loginone OAuth provider end to endworks after callback update
StorageGET a synced object via the new ref’s public URL200 (404 = bytes not synced; 400 “Bucket not found” = visibility not restored)
Edge Functioninvoke on the new ref200
Cronselect * from cron.job; on the targetschedules re-added
Billingdashboardold project deleted at window end
  • 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-run diff 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 bootstrap fails 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.
  1. sbshift, “Postgres-to-Postgres logical-replication migrator,” GitHub. https://github.com/erfianugrah/sbshift

  2. Supabase, “Restore to a new project,” Supabase Docs. https://supabase.com/docs/guides/platform/clone-project

  3. Supabase, “Regions,” Supabase Docs. https://supabase.com/docs/guides/platform/regions

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

  5. Supabase, “Redirect URLs,” Supabase Docs. https://supabase.com/docs/guides/auth/redirect-urls

  6. Supabase, “Custom Domains,” Supabase Docs. https://supabase.com/docs/guides/platform/custom-domains