Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
Use this runbook to move a Supabase workload to Microsoft Azure. It covers PostgreSQL data, Storage objects, Auth identities, Edge Functions, and Realtime features. An operator must approve the application-specific identity, cutover, rollback, and deletion gates.
What this guide covers
- An offline PostgreSQL migration with
pg_dumpandpg_restore. - A conditional online migration with the migration service in Azure Database for PostgreSQL.
- Storage migration from the Supabase S3-compatible endpoint to Azure Blob Storage.
- Auth migration to Microsoft Entra External ID, including identity mapping and application changes.
- Edge Functions and Realtime migration to Azure Functions or Azure Container Apps and Azure Web PubSub.
- Database validation, cutover, rollback, hardening, and decommission procedures.
How to use this runbook
- Choose either the offline database path or the online database path. Don't run both paths against the same target database.
- Resolve every required input and application-specific script in Phase 0.
- Run a full rehearsal against a nonproduction copy before the production migration.
- Halt on any failed prerequisite, validation mismatch, unrecognized error, or missing approval.
- Keep the source intact until the observation period in Phase 10 is complete.
Important
Supabase Auth, Realtime, Storage, and Edge Functions aren't drop-in equivalents of the Azure services in this guide. The database and object copies can be automated, but application code, identity mapping, write-freeze, and rollback logic require workload-specific design and testing.
Phase 0. Prepare the migration
Choose a database migration mode
Choose the mode from the workload's recovery time objective, write rate, database features, and tested migration duration. Database size alone isn't a safe decision rule.
| Mode | Use when | Downtime |
|---|---|---|
| Offline | A rehearsal shows that a final dump and restore fit within the approved maintenance window. | The write freeze lasts for the final dump, restore, validation, and endpoint switch. |
| Online | The source satisfies logical replication, publication, network, and permission requirements, and the migration service Validate operation succeeds. | Writes continue during initial copy. The final write freeze lasts until replication latency reaches zero or close to zero, cutover completes, and smoke tests pass. |
For details, see Migration Service in Azure Database for PostgreSQL.
Prepare a migration runner
Run every command from one dedicated Bash runner. An Ubuntu Azure virtual machine is recommended. Windows Subsystem for Linux and another Bash-compatible Linux host are also supported.
The runner must have:
- A fixed outbound IPv4 address.
- Outbound access to the Supabase database and S3-compatible Storage endpoint.
- Outbound access on TCP port 5432 to the target Azure Database for PostgreSQL server.
- Free encrypted disk space for the database archive and staged Storage objects. Plan for at least twice the combined source size.
- Restricted administrator access and no shared shell history.
Install these tools:
- Azure CLI 2.56.0 or later.
- PostgreSQL client utilities (
psql,createdb,pg_dump,pg_restore, andvacuumdb) from the same major version as the source, or a later major version that supports both source and target. - Supabase CLI.
- AWS CLI.
- AzCopy.
- Python 3.
Run the following checks and record the output:
set -Eeuo pipefail
umask 077
az version
psql --version
createdb --version
pg_dump --version
pg_restore --version
vacuumdb --version
supabase --version
aws --version
azcopy --version
python3 --version
Authenticate the main Azure CLI profile and verify the Supabase CLI token before discovery:
az login --tenant "$AZ_TENANT_ID"
az account set --subscription "$AZ_SUBSCRIPTION_ID"
az account show --query "{tenant:tenantId,subscription:id}" --output table
test -n "$SUPABASE_ACCESS_TOKEN"
export SUPABASE_ACCESS_TOKEN
supabase projects list > /dev/null
Warning
Don't enable shell tracing with set -x. It can expose passwords, access tokens, and connection strings. Don't name a variable PWD; Bash reserves $PWD for the current working directory.
Confirm permissions
Confirm that the migration identity has:
- Permission to create and configure the target Azure resources.
- Storage Blob Data Contributor on the target storage account.
- The permissions required by the PostgreSQL migration service when you use the online path.
- The Microsoft Graph
User.ReadWrite.Allapplication permission in the External ID tenant, with administrator consent, for the scripted identity lookup, creation, and mapped-account password reset operations. - A supported source database role. Online migration requires permission to create a publication and include every migrated table. It also requires supported replica identity configuration for update and delete operations.
Resolve required inputs
Store secret values in the runner's secret store or protected environment. Don't put them in source control, command logs, or the article.
| Variable | Description | Example |
|---|---|---|
MIGRATION_MODE |
Database path: offline or online |
offline |
MIGRATE_ENTIRE_DATABASE |
Online-path approval to migrate every non-system schema: yes or no |
no |
MIGRATE_LARGE_OBJECTS |
Approval to include database large objects: yes or no |
no |
ONLINE_TARGET_DEDICATED |
Approval that the online target is dedicated to this migration: yes or no |
no |
ALLOW_ONLINE_TARGET_OVERWRITE |
Explicit approval for Migration Service to overwrite the empty target database: yes or no |
no |
MIGRATION_WORK_DIR |
Existing encrypted working directory with mode 700 |
/mnt/migration/supabase-prod |
MIGRATION_RUNNER_IP |
Fixed public IPv4 address of the runner | 203.0.113.10 |
SUPABASE_PROJECT_REF |
Supabase project reference | abcd1234 |
SUPABASE_ACCESS_TOKEN |
Supabase CLI access token | Secret |
SOURCE_PG_URI |
Source PostgreSQL URI without a password | postgresql://migration_user@db.example.supabase.co:5432/postgres?sslmode=require |
SOURCE_PG_PASSWORD |
Source database password | Secret |
SOURCE_DB_SERVER_RESOURCE_ID |
Online migration source in <host>:<port>@<user> format |
db.example.supabase.co:5432@migration_user |
SOURCE_PG_DATABASE |
Source database name | postgres |
SOURCE_DB_CHARSET |
Source database encoding from Phase 1 | UTF8 |
SOURCE_DB_COLLATION |
Supported target collation selected from the Phase 1 inventory | en_US.utf8 |
SOURCE_DB_CTYPE |
Source database character classification locale | en_US.utf8 |
APP_SCHEMAS |
Comma-separated application schemas approved after inventory | public,app |
SUPABASE_S3_ENDPOINT |
Supabase Storage S3 endpoint | https://example.supabase.co/storage/v1/s3 |
SUPABASE_S3_KEY |
Supabase Storage S3 access key | Secret |
SUPABASE_S3_SECRET |
Supabase Storage S3 secret | Secret |
AZ_SUBSCRIPTION_ID |
Target Azure subscription ID | 00000000-0000-0000-0000-000000000000 |
AZ_TENANT_ID |
Target Azure tenant ID | 00000000-0000-0000-0000-000000000000 |
AZ_RG |
Target resource group | rg-migration-prod |
AZ_REGION |
Target Azure region | westus3 |
AZ_PG_SERVER |
Target flexible server name | pg-prod-001 |
AZ_PG_DATABASE |
Target database name | postgres |
AZ_PG_ADMIN |
Target PostgreSQL administrator | pgadmin |
AZ_PG_PASSWORD |
Target PostgreSQL administrator password | Secret |
AZ_PG_VERSION |
Target PostgreSQL major version | 17 |
AZ_PG_SKU |
Target compute SKU selected from workload sizing | Standard_D4ds_v5 |
AZ_PG_TIER |
Target compute tier | GeneralPurpose |
AZ_PG_STORAGE_GB |
Target storage in GiB | 128 |
AZ_EXTENSION_ALLOWLIST |
Azure extension names verified for the target version and region | VECTOR,UUID-OSSP,PGCRYPTO,POSTGIS |
AZ_STORAGE_ACCT |
Target storage account | stprodassets01 |
STORAGE_CONTAINER_MAP |
Reviewed CSV mapping each source bucket to a valid Azure container name | /mnt/migration/supabase-prod/storage-container-map.csv |
AZ_FUNCTION_APP |
Azure Functions app name for migrated Edge Functions | fn-supabase-prod |
AZ_FUNCTION_STORAGE_ACCT |
Dedicated Functions host storage account | stfnsupabaseprod |
AZ_WEBPUBSUB |
Azure Web PubSub resource name | wps-supabase-prod |
RESTORE_JOBS |
Parallel restore workers supported by the runner and target | 4 |
AZ_MIGRATION_VALIDATE_NAME |
Unique validation workflow name | supabase-validate-20260831 |
AZ_MIGRATION_NAME |
Unique online migration workflow name | supabase-prod-20260831 |
ENTRA_TENANT_ID |
Microsoft Entra External ID tenant ID | Tenant GUID |
ENTRA_ISSUER |
Primary domain of the External ID tenant | contoso.onmicrosoft.com |
GRAPH_CLIENT_ID |
Approved app registration with Microsoft Graph application permission | Client ID |
GRAPH_CLIENT_CERT |
Protected certificate path for the app registration | /mnt/secrets/graph-client.pem |
CUTOVER_PLAN_DIR |
Directory containing the reviewed application cutover scripts | /mnt/migration/supabase-prod/cutover |
Create and protect the working directory:
mkdir -p "$MIGRATION_WORK_DIR"/{inventory,database,storage,auth,logs}
chmod 700 "$MIGRATION_WORK_DIR"
Prepare application cutover scripts
The migration agent can't infer how the application enters maintenance mode or receives new endpoints. Before the rehearsal, the application team must create, test, and approve these scripts in CUTOVER_PLAN_DIR:
| Script | Required behavior |
|---|---|
freeze-writes.sh |
Enable maintenance mode, stop background workers and scheduled jobs, pause webhooks and queues, and fail if any known writer remains active. |
freeze-ddl.sh |
Stop deployments and prevent database owners or automation from changing schema until cutover or rollback completes. |
point-to-azure.sh |
Change database, Storage, Auth, and Realtime configuration to the Azure endpoints while target writes remain disabled. |
enable-azure-writes.sh |
Enable writes only after smoke tests pass. |
point-to-supabase.sh |
Restore the Supabase endpoints for a pre-write rollback. |
resume-source-writes.sh |
Resume source workers and writes after a pre-write rollback. |
resume-source-ddl.sh |
Re-enable source deployments and DDL after a rollback. |
freeze-azure-writes.sh |
Stop every Azure-side writer before a post-write rollback. |
capture-target-marker.sh |
Create and return an atomic, target-generated audit sequence, log position, or other monotonic reconciliation boundary while target writes are disabled. |
export-azure-delta.sh |
Export every write accepted by Azure after the target-generated marker from a durable audit log, outbox, or change stream. |
replay-source-delta.sh |
Apply the exported writes to Supabase with tested ordering, deduplication, and conflict handling. |
capture-storage-properties.sh |
Capture complete source content headers and custom metadata for every object by using the S3-compatible HeadObject operation. |
apply-storage-properties.sh |
Reapply the approved mapping of Supabase bucket policy, content headers, and object metadata to Azure Blob Storage. |
Every script must return a nonzero exit code if it can't prove completion. Don't use placeholder commands such as ....
Important
If the application doesn't have durable change capture for every write, a post-write rollback can't be made lossless. In that case, keep target writes disabled until the go/no-go decision, and fix forward on Azure after writes are enabled.
Stop conditions
Halt the migration if any condition is true:
- The target PostgreSQL major version doesn't equal the source major version.
- The source version isn't supported by Azure Database for PostgreSQL in the selected region.
- A required source extension isn't supported for the target version and region.
APP_SCHEMASis empty or includes a Supabase-managed schema without explicit approval.- The source contains large objects and
MIGRATE_LARGE_OBJECTSisn't explicitly set toyes. - An application table, function, view, or RLS policy depends on
auth.users,auth.uid(), or another Supabase-managed object and no remediation is approved. - Online validation fails or the source can't meet logical replication, publication, primary key, replica identity, or network requirements.
MIGRATION_MODE=onlineand the owner hasn't approved migration of every non-system schema in the source database by settingMIGRATE_ENTIRE_DATABASE=yes.MIGRATION_MODE=onlineand the target isn't dedicated and approved for overwrite.- Any application cutover script is missing, contains placeholders, or hasn't passed rehearsal.
- A secret appears in a log or a file with permissions broader than
600.
Phase 1. Discover and inventory the source
Capture the source version, schemas, tables, extensions, identity dependencies, and application features.
export PGPASSWORD="$SOURCE_PG_PASSWORD"
psql "$SOURCE_PG_URI" -v ON_ERROR_STOP=1 \
-c "SHOW server_version;" \
> "$MIGRATION_WORK_DIR/inventory/pg-version.txt"
psql "$SOURCE_PG_URI" -At -v ON_ERROR_STOP=1 \
-c "SHOW server_version_num;" \
> "$MIGRATION_WORK_DIR/inventory/pg-version-number.txt"
SOURCE_PG_MAJOR=$(
cut -c1-2 "$MIGRATION_WORK_DIR/inventory/pg-version-number.txt"
)
psql "$SOURCE_PG_URI" -At -F '|' -v ON_ERROR_STOP=1 \
-c "SELECT pg_encoding_to_char(encoding),
datcollate,
datctype,
coalesce(datacl::text, '')
FROM pg_database
WHERE datname = current_database();" \
> "$MIGRATION_WORK_DIR/inventory/database-properties.txt"
psql "$SOURCE_PG_URI" -At -F '|' -v ON_ERROR_STOP=1 \
-c "SELECT coalesce(r.rolname, '*'), unnest(s.setconfig)
FROM pg_db_role_setting AS s
LEFT JOIN pg_roles AS r ON r.oid = s.setrole
WHERE s.setdatabase = (
SELECT oid
FROM pg_database
WHERE datname = current_database()
)
ORDER BY 1, 2;" \
> "$MIGRATION_WORK_DIR/inventory/database-settings.txt"
psql "$SOURCE_PG_URI" -v ON_ERROR_STOP=1 \
-c "SELECT nspname AS schema_name
FROM pg_namespace
WHERE nspname NOT LIKE 'pg_%'
AND nspname <> 'information_schema'
ORDER BY 1;" \
> "$MIGRATION_WORK_DIR/inventory/schemas.txt"
psql "$SOURCE_PG_URI" -v ON_ERROR_STOP=1 \
-c "SELECT schemaname,
tablename,
pg_total_relation_size(format('%I.%I', schemaname, tablename)) AS bytes,
pg_size_pretty(pg_total_relation_size(format('%I.%I', schemaname, tablename))) AS size
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY bytes DESC;" \
> "$MIGRATION_WORK_DIR/inventory/tables.txt"
psql "$SOURCE_PG_URI" -v ON_ERROR_STOP=1 \
-c "SELECT e.extname, e.extversion, n.nspname AS installed_schema
FROM pg_extension AS e
JOIN pg_namespace AS n ON n.oid = e.extnamespace
ORDER BY 1;" \
> "$MIGRATION_WORK_DIR/inventory/extensions.txt"
psql "$SOURCE_PG_URI" -v ON_ERROR_STOP=1 \
-c "SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check
FROM pg_policies
ORDER BY schemaname, tablename, policyname;" \
> "$MIGRATION_WORK_DIR/inventory/rls-policies.txt"
psql "$SOURCE_PG_URI" -v ON_ERROR_STOP=1 \
-c "SELECT schemaname, tablename, policyname, qual, with_check
FROM pg_policies
WHERE coalesce(qual, '') ILIKE '%auth.uid()%'
OR coalesce(with_check, '') ILIKE '%auth.uid()%'
ORDER BY schemaname, tablename, policyname;" \
> "$MIGRATION_WORK_DIR/inventory/rls-auth-dependencies.txt"
psql "$SOURCE_PG_URI" -v ON_ERROR_STOP=1 \
-c "SELECT c.conrelid::regclass AS referencing_table,
a.attname AS referencing_column,
c.conname AS constraint_name
FROM pg_constraint AS c
JOIN unnest(c.conkey) WITH ORDINALITY AS key_column(attnum, ordinality)
ON true
JOIN pg_attribute AS a
ON a.attrelid = c.conrelid
AND a.attnum = key_column.attnum
WHERE c.contype = 'f'
AND c.confrelid = 'auth.users'::regclass
ORDER BY 1, 2;" \
> "$MIGRATION_WORK_DIR/inventory/auth-user-foreign-keys.txt"
psql "$SOURCE_PG_URI" -At -v ON_ERROR_STOP=1 \
-c "SELECT count(*) FROM auth.users;" \
> "$MIGRATION_WORK_DIR/inventory/auth-user-count.txt"
psql "$SOURCE_PG_URI" --csv -v ON_ERROR_STOP=1 \
-c "SELECT u.id, u.email, i.provider
FROM auth.users AS u
LEFT JOIN auth.identities AS i ON i.user_id = u.id
ORDER BY u.id, i.provider;" \
> "$MIGRATION_WORK_DIR/inventory/auth-identity-providers.csv"
psql "$SOURCE_PG_URI" -At -v ON_ERROR_STOP=1 \
-c "SELECT name FROM storage.buckets ORDER BY name;" \
> "$MIGRATION_WORK_DIR/inventory/storage-buckets.txt"
psql "$SOURCE_PG_URI" --csv -v ON_ERROR_STOP=1 \
-c "SELECT id, name, public, file_size_limit, allowed_mime_types
FROM storage.buckets
ORDER BY name;" \
> "$MIGRATION_WORK_DIR/inventory/storage-bucket-settings.csv"
psql "$SOURCE_PG_URI" --csv -v ON_ERROR_STOP=1 \
-c "SELECT bucket_id, name, metadata
FROM storage.objects
ORDER BY bucket_id, name;" \
> "$MIGRATION_WORK_DIR/inventory/storage-object-metadata.csv"
psql "$SOURCE_PG_URI" -At -v ON_ERROR_STOP=1 \
-c "SELECT count(*) FROM pg_largeobject_metadata;" \
> "$MIGRATION_WORK_DIR/inventory/large-object-count.txt"
supabase functions list \
--project-ref "$SUPABASE_PROJECT_REF" \
> "$MIGRATION_WORK_DIR/inventory/edge-functions.txt"
For the online path, also capture logical replication settings and table replica identities:
psql "$SOURCE_PG_URI" -v ON_ERROR_STOP=1 \
-c "SHOW wal_level;
SHOW max_replication_slots;
SHOW max_wal_senders;
SHOW wal_sender_timeout;" \
> "$MIGRATION_WORK_DIR/inventory/logical-replication.txt"
psql "$SOURCE_PG_URI" -v ON_ERROR_STOP=1 \
-c "SELECT n.nspname AS schema_name,
c.relname AS table_name,
c.relreplident AS replica_identity,
EXISTS (
SELECT 1
FROM pg_index AS i
WHERE i.indrelid = c.oid
AND i.indisprimary
) AS has_primary_key
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname = ANY (string_to_array(:'schemas', ','))
ORDER BY 1, 2;" \
-v schemas="$APP_SCHEMAS" \
> "$MIGRATION_WORK_DIR/inventory/replica-identities.txt"
Review the inventory with the database and application owners. Set APP_SCHEMAS to every application-owned schema that must migrate. Use the same list for schema export, data export, and validation.
Create STORAGE_CONTAINER_MAP with the columns source_bucket,target_container. Include every source bucket exactly once. Azure container names must use lowercase letters, numbers, and single hyphens, start and end with a letter or number, and contain 3 through 63 characters. Record any renamed path in the application configuration plan.
Phase 2. Provision the Azure target
Match the source PostgreSQL version
Azure Database for PostgreSQL supports PostgreSQL 18, 17, 16, and earlier supported releases. Verify the current list in Supported versions of PostgreSQL.
test "$AZ_PG_VERSION" = "$SOURCE_PG_MAJOR" || {
echo "Target PostgreSQL $AZ_PG_VERSION doesn't match source $SOURCE_PG_MAJOR." >&2
exit 1
}
az postgres flexible-server list-skus \
--location "$AZ_REGION" \
--output table \
> "$MIGRATION_WORK_DIR/inventory/azure-postgres-skus.txt"
Confirm that the selected version, SKU, storage, and required extensions are available in the region. Don't force a PostgreSQL 17 source onto PostgreSQL 16. Treat a major-version change as a separate upgrade project.
Create the target without high availability
The following sizing values are examples supplied through variables. Select compute, storage, and backup settings from measured workload requirements. Keep zonal resiliency disabled during migration. Enable it after migration validation is complete.
az account set --subscription "$AZ_SUBSCRIPTION_ID"
az group create \
--name "$AZ_RG" \
--location "$AZ_REGION"
if az postgres flexible-server show \
--resource-group "$AZ_RG" \
--name "$AZ_PG_SERVER" \
> "$MIGRATION_WORK_DIR/inventory/target-server.json" \
2> "$MIGRATION_WORK_DIR/logs/target-server-show.err"; then
actual_version=$(
az postgres flexible-server show \
--resource-group "$AZ_RG" \
--name "$AZ_PG_SERVER" \
--query version \
--output tsv
)
test "$actual_version" = "$AZ_PG_VERSION" || {
echo "Existing target version $actual_version doesn't match $AZ_PG_VERSION." >&2
exit 1
}
else
if grep -q "ResourceNotFound" "$MIGRATION_WORK_DIR/logs/target-server-show.err"; then
az postgres flexible-server create \
--resource-group "$AZ_RG" \
--name "$AZ_PG_SERVER" \
--location "$AZ_REGION" \
--admin-user "$AZ_PG_ADMIN" \
--admin-password "$AZ_PG_PASSWORD" \
--sku-name "$AZ_PG_SKU" \
--tier "$AZ_PG_TIER" \
--storage-size "$AZ_PG_STORAGE_GB" \
--storage-auto-grow Enabled \
--version "$AZ_PG_VERSION" \
--zonal-resiliency Disabled \
--public-access None \
--yes
az postgres flexible-server show \
--resource-group "$AZ_RG" \
--name "$AZ_PG_SERVER" \
--query "{id:id,createdAt:systemData.createdAt}" \
--output json \
> "$MIGRATION_WORK_DIR/inventory/target-server-created-this-run.json"
chmod 600 \
"$MIGRATION_WORK_DIR/inventory/target-server-created-this-run.json"
else
cat "$MIGRATION_WORK_DIR/logs/target-server-show.err" >&2
exit 1
fi
fi
Add a named firewall rule for the runner
--public-access None creates no broad allow rule. Add only the fixed runner IPv4 address.
if az postgres flexible-server firewall-rule show \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name migration-runner \
> /dev/null 2>&1; then
az postgres flexible-server firewall-rule update \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name migration-runner \
--start-ip-address "$MIGRATION_RUNNER_IP" \
--end-ip-address "$MIGRATION_RUNNER_IP"
else
az postgres flexible-server firewall-rule create \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name migration-runner \
--start-ip-address "$MIGRATION_RUNNER_IP" \
--end-ip-address "$MIGRATION_RUNNER_IP"
fi
Test server connectivity against the built-in postgres database. Keep the password in PGPASSWORD, not in the URI.
TARGET_ADMIN_URI="postgresql://${AZ_PG_ADMIN}@${AZ_PG_SERVER}.postgres.database.azure.com:5432/postgres?sslmode=require"
export PGPASSWORD="$AZ_PG_PASSWORD"
psql "$TARGET_ADMIN_URI" -v ON_ERROR_STOP=1 -c "SELECT version();"
For the offline path, create a nondefault target database before restoring:
if [ "$MIGRATION_MODE" = "offline" ] && [ "$AZ_PG_DATABASE" != "postgres" ]; then
if ! az postgres flexible-server db show \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name "$AZ_PG_DATABASE" \
> /dev/null 2>&1; then
PGPASSWORD="$AZ_PG_PASSWORD" createdb \
--host "$AZ_PG_SERVER.postgres.database.azure.com" \
--port 5432 \
--username "$AZ_PG_ADMIN" \
--encoding "$SOURCE_DB_CHARSET" \
--lc-collate "$SOURCE_DB_COLLATION" \
--lc-ctype "$SOURCE_DB_CTYPE" \
--template template0 \
"$AZ_PG_DATABASE"
fi
fi
After creation, compare the target encoding, both locale properties, database ACL, and database-level role settings with the Phase 1 inventory. Add approved database grants and settings to application-grants.sql. Use ALTER DATABASE ... SET for database-wide settings and ALTER ROLE ... IN DATABASE ... SET for role-specific settings.
The built-in postgres database can't be recreated with different locale properties. If it doesn't match the source, use an approved nondefault target database with the offline path. The online path must halt on a database-property mismatch.
For the online path, the migration service preserves the source database name. Enforce the same name:
if [ "$MIGRATION_MODE" = "online" ]; then
test "$AZ_PG_DATABASE" = "$SOURCE_PG_DATABASE" || {
echo "Online migration requires AZ_PG_DATABASE to equal SOURCE_PG_DATABASE." >&2
exit 1
}
fi
TARGET_PG_URI="postgresql://${AZ_PG_ADMIN}@${AZ_PG_SERVER}.postgres.database.azure.com:5432/${AZ_PG_DATABASE}?sslmode=require"
Configure the production application network path
Configure and test the application's production path to the target before migrating data. Don't rely on the temporary runner rule for application traffic.
- For private access, configure the VNet or private endpoint, private DNS, routing, and network security rules.
- For public access, add only the application's fixed outbound IPv4 addresses.
- From the actual production workload network, while production still points to Supabase and target writes remain disabled, open a TLS connection to the target and run a read-only query.
point-to-azure.sh must only switch reviewed application configuration. It must not create networking during cutover.
Allow and create required extensions
Use inventory/extensions.txt to build the target list. Verify every extension against Extensions in Azure Database for PostgreSQL for the target major version.
if [ -n "$AZ_EXTENSION_ALLOWLIST" ]; then
az postgres flexible-server parameter set \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name azure.extensions \
--value "$AZ_EXTENSION_ALLOWLIST"
fi
For the offline path, create a reviewed file at $MIGRATION_WORK_DIR/database/target-extensions.sql. It must contain every required extension and the correct target schema. For example:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS postgis;
If the source installed an extension in a nondefault schema, install it into the same schema when the extension supports relocation. Otherwise, update every qualified reference during schema review. Don't globally rewrite an extension schema without checking its functions, types, operators, and views.
if [ "$MIGRATION_MODE" = "offline" ]; then
export PGPASSWORD="$AZ_PG_PASSWORD"
psql "$TARGET_PG_URI" \
-v ON_ERROR_STOP=1 \
-f "$MIGRATION_WORK_DIR/database/target-extensions.sql"
fi
Phase 3. Prepare application roles and the offline schema
Create these reviewed, idempotent files for both migration paths:
$MIGRATION_WORK_DIR/database/application-roles.sqlfor application-owned roles only.$MIGRATION_WORK_DIR/database/application-grants.sqlfor database and object grants that run after every object exists.
Don't import Supabase-managed roles or superuser attributes. Create application roles before either migration path:
export PGPASSWORD="$AZ_PG_PASSWORD"
psql "$TARGET_ADMIN_URI" \
-v ON_ERROR_STOP=1 \
-f "$MIGRATION_WORK_DIR/database/application-roles.sql"
When MIGRATION_MODE=online, skip the rest of this phase. The migration service handles schema and data together, but it doesn't migrate users or roles.
For the offline path, build one schema argument array and reuse it for every database operation:
IFS=',' read -r -a APP_SCHEMA_ARRAY <<< "$APP_SCHEMAS"
SCHEMA_ARGS=()
for schema in "${APP_SCHEMA_ARRAY[@]}"; do
[[ "$schema" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || {
echo "Invalid schema name: $schema" >&2
exit 1
}
SCHEMA_ARGS+=(--schema "$schema")
done
test "${#SCHEMA_ARGS[@]}" -gt 0
Prepare the large-object argument after reviewing large-object-count.txt:
LARGE_OBJECT_ARGS=()
if [ "$MIGRATE_LARGE_OBJECTS" = "yes" ]; then
LARGE_OBJECT_ARGS+=(--large-objects)
fi
Export pre-data and post-data separately. This keeps tables, types, sequences, and functions ahead of the data load, while indexes, constraints, and triggers are created after the load.
export PGPASSWORD="$SOURCE_PG_PASSWORD"
pg_dump \
--section pre-data \
--no-owner \
--no-privileges \
--no-comments \
--no-publications \
--no-subscriptions \
"${SCHEMA_ARGS[@]}" \
"${LARGE_OBJECT_ARGS[@]}" \
"$SOURCE_PG_URI" \
> "$MIGRATION_WORK_DIR/database/pre-data.sql"
pg_dump \
--section post-data \
--no-owner \
--no-privileges \
--no-comments \
--no-publications \
--no-subscriptions \
"${SCHEMA_ARGS[@]}" \
"${LARGE_OBJECT_ARGS[@]}" \
"$SOURCE_PG_URI" \
> "$MIGRATION_WORK_DIR/database/post-data.sql"
Remove dump-side extension creation because extensions were reviewed and created in Phase 2:
sed -E \
-e '/^CREATE EXTENSION /d' \
-e '/^COMMENT ON EXTENSION /d' \
"$MIGRATION_WORK_DIR/database/pre-data.sql" \
> "$MIGRATION_WORK_DIR/database/pre-data-azure.sql"
Review pre-data-azure.sql and post-data.sql for:
- Qualified references to extension schemas.
- Supabase-managed roles.
- References to
auth.users,auth.uid(),storage,realtime,vault, and other excluded schemas. - Unsupported owner, privilege, publication, subscription, and superuser operations.
Apply pre-data only:
export PGPASSWORD="$AZ_PG_PASSWORD"
psql "$TARGET_PG_URI" \
-v ON_ERROR_STOP=1 \
-f "$MIGRATION_WORK_DIR/database/pre-data-azure.sql"
Halt on the first error. Don't add an unknown statement to a filter or disable ON_ERROR_STOP without database-owner review.
Phase 4. Migrate PostgreSQL data
Phase 4a. Use the offline path
Run this path only when MIGRATION_MODE=offline.
During rehearsal, measure the dump, restore, and validation duration against a separate rehearsal server or database. Don't rerun the restore into a populated production target. During production cutover, run the final dump and restore only after the Phase 9 write freeze succeeds.
export PGPASSWORD="$SOURCE_PG_PASSWORD"
pg_dump \
--section data \
--no-owner \
--no-privileges \
--format custom \
"${SCHEMA_ARGS[@]}" \
"${LARGE_OBJECT_ARGS[@]}" \
"$SOURCE_PG_URI" \
> "$MIGRATION_WORK_DIR/database/data.dump"
export PGPASSWORD="$AZ_PG_PASSWORD"
pg_restore \
--section data \
--no-owner \
--no-privileges \
--exit-on-error \
--jobs "$RESTORE_JOBS" \
--dbname "$TARGET_PG_URI" \
"$MIGRATION_WORK_DIR/database/data.dump"
psql "$TARGET_PG_URI" \
-v ON_ERROR_STOP=1 \
-f "$MIGRATION_WORK_DIR/database/post-data.sql"
psql "$TARGET_PG_URI" \
-v ON_ERROR_STOP=1 \
-f "$MIGRATION_WORK_DIR/database/application-grants.sql"
Don't use --disable-triggers unless the target role has the required privilege and the database owner explicitly approves it.
Phase 4b. Use the online path
Run this path only when MIGRATION_MODE=online.
The migration service works at database scope and doesn't filter to APP_SCHEMAS. Use this path only when the owner approves migration of every non-system schema in SOURCE_PG_DATABASE, including any Supabase-managed schemas, or when the source is an approved application-only database copy. Otherwise, use the offline path.
test "$MIGRATE_ENTIRE_DATABASE" = "yes" || {
echo "Online migration requires MIGRATE_ENTIRE_DATABASE=yes." >&2
exit 1
}
test "$(cat "$MIGRATION_WORK_DIR/inventory/large-object-count.txt")" -eq 0 || {
echo "Online migration isn't allowed when PostgreSQL large objects exist." >&2
exit 1
}
test "$ONLINE_TARGET_DEDICATED" = "yes"
test "$ALLOW_ONLINE_TARGET_OVERWRITE" = "yes"
test -s \
"$MIGRATION_WORK_DIR/inventory/target-server-created-this-run.json" || {
echo "Online migration requires a fresh target server created for this run." >&2
exit 1
}
unexpected_databases=$(
PGPASSWORD="$AZ_PG_PASSWORD" \
psql "$TARGET_ADMIN_URI" -At -v ON_ERROR_STOP=1 \
-c "SELECT datname
FROM pg_database
WHERE datistemplate = false
AND datname NOT IN (
'postgres',
'azure_maintenance',
'azure_sys',
'$AZ_PG_DATABASE'
)
ORDER BY 1;"
)
test -z "$unexpected_databases" || {
echo "Online target server contains unrelated databases:" >&2
printf '%s\n' "$unexpected_databases" >&2
exit 1
}
set +e
az postgres flexible-server db show \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name "$AZ_PG_DATABASE" \
> "$MIGRATION_WORK_DIR/inventory/online-target-database.json" \
2> "$MIGRATION_WORK_DIR/logs/online-target-database.err"
target_database_status=$?
set -e
if [ "$target_database_status" -eq 0 ]; then
target_user_objects=$(
PGPASSWORD="$AZ_PG_PASSWORD" \
psql "$TARGET_PG_URI" -At -v ON_ERROR_STOP=1 \
-c "WITH object_counts AS (
SELECT count(*) AS value
FROM pg_namespace
WHERE nspname NOT IN (
'pg_catalog',
'information_schema',
'pg_toast',
'public',
'azure_sys'
)
AND nspname NOT LIKE 'pg_toast%'
AND nspname NOT LIKE 'pg_temp_%'
UNION ALL
SELECT count(*)
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN (
'pg_catalog',
'information_schema',
'pg_toast',
'azure_sys'
)
AND n.nspname NOT LIKE 'pg_toast%'
AND n.nspname NOT LIKE 'pg_temp_%'
UNION ALL
SELECT count(*)
FROM pg_proc AS p
JOIN pg_namespace AS n ON n.oid = p.pronamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'azure_sys')
AND n.nspname NOT LIKE 'pg_temp_%'
UNION ALL
SELECT count(*)
FROM pg_type AS t
JOIN pg_namespace AS n ON n.oid = t.typnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'azure_sys')
AND n.nspname NOT LIKE 'pg_temp_%'
UNION ALL
SELECT count(*)
FROM pg_collation AS c
JOIN pg_namespace AS n ON n.oid = c.collnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'azure_sys')
UNION ALL
SELECT count(*)
FROM pg_operator AS o
JOIN pg_namespace AS n ON n.oid = o.oprnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'azure_sys')
UNION ALL
SELECT count(*)
FROM pg_extension
WHERE extname <> 'plpgsql'
)
SELECT sum(value) FROM object_counts;"
)
test "$target_user_objects" -eq 0 || {
echo "Online target database isn't empty; overwrite is blocked." >&2
exit 1
}
source_locale=$(
cut -d '|' -f 1-3 \
"$MIGRATION_WORK_DIR/inventory/database-properties.txt"
)
target_locale=$(
PGPASSWORD="$AZ_PG_PASSWORD" \
psql "$TARGET_PG_URI" -At -F '|' -v ON_ERROR_STOP=1 \
-c "SELECT pg_encoding_to_char(encoding), datcollate, datctype
FROM pg_database
WHERE datname = current_database();"
)
test "$source_locale" = "$target_locale" || {
echo "Online target encoding or locale doesn't match the source." >&2
exit 1
}
elif grep -q "ResourceNotFound" \
"$MIGRATION_WORK_DIR/logs/online-target-database.err"; then
:
else
cat "$MIGRATION_WORK_DIR/logs/online-target-database.err" >&2
exit 1
fi
The creation record must belong to this migration run, and no other workload may have used the server after creation. If either condition is uncertain, create a new target server. The catalog query is a secondary check; it doesn't make an older shared server safe to overwrite.
The source must also be reachable from Azure. For a public Supabase endpoint, use the generic managed PostgreSQL network pattern and the OnPremises CLI source type. If the Validate operation rejects the endpoint, permissions, extensions, logical replication setup, or any database object, stop and use the offline path.
Create a protected migration properties file:
MIGRATION_CONFIG="$MIGRATION_WORK_DIR/database/online-migration.json"
python3 - "$MIGRATION_CONFIG" <<'PY'
import json
import os
import sys
required = [
"SOURCE_DB_SERVER_RESOURCE_ID",
"SOURCE_PG_PASSWORD",
"SOURCE_PG_DATABASE",
"AZ_PG_ADMIN",
"AZ_PG_PASSWORD",
]
missing = [name for name in required if not os.environ.get(name)]
if missing:
raise SystemExit("Missing environment variables: " + ", ".join(missing))
payload = {
"properties": {
"sourceDBServerResourceId": os.environ["SOURCE_DB_SERVER_RESOURCE_ID"],
"secretParameters": {
"adminCredentials": {
"sourceServerPassword": os.environ["SOURCE_PG_PASSWORD"],
"targetServerPassword": os.environ["AZ_PG_PASSWORD"],
},
"sourceServerUserName": os.environ["SOURCE_DB_SERVER_RESOURCE_ID"].rsplit("@", 1)[1],
"targetServerUserName": os.environ["AZ_PG_ADMIN"],
},
"dBsToMigrate": [os.environ["SOURCE_PG_DATABASE"]],
"overwriteDbsInTarget": "true",
"sourceType": "OnPremises",
"sslMode": "Require",
}
}
with open(sys.argv[1], "w", encoding="utf-8") as stream:
json.dump(payload, stream, indent=2)
PY
chmod 600 "$MIGRATION_CONFIG"
Run validation first:
az postgres flexible-server migration create \
--subscription "$AZ_SUBSCRIPTION_ID" \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name "$AZ_MIGRATION_VALIDATE_NAME" \
--migration-mode online \
--migration-option Validate \
--properties "$MIGRATION_CONFIG"
az postgres flexible-server migration show \
--subscription "$AZ_SUBSCRIPTION_ID" \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name "$AZ_MIGRATION_VALIDATE_NAME"
Wait for validation to finish. Continue only when validation succeeds and every warning has an approved disposition.
Online change replication doesn't carry arbitrary DDL changes. Run freeze-ddl.sh before starting the migration and keep DDL frozen through cutover:
bash "$CUTOVER_PLAN_DIR/freeze-ddl.sh"
Start a separate validate-and-migrate workflow:
az postgres flexible-server migration create \
--subscription "$AZ_SUBSCRIPTION_ID" \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name "$AZ_MIGRATION_NAME" \
--migration-mode online \
--migration-option ValidateAndMigrate \
--properties "$MIGRATION_CONFIG"
az postgres flexible-server migration show \
--subscription "$AZ_SUBSCRIPTION_ID" \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name "$AZ_MIGRATION_NAME"
If the online migration is canceled or fails before cutover, run resume-source-ddl.sh only after the database owner confirms that no migration workflow is still active.
Don't use az dms create or az dms project task cancel. Those commands belong to Azure Database Migration Service (classic), not the integrated migration service in Azure Database for PostgreSQL.
Phase 5. Validate and maintain the database
Use APP_SCHEMAS for every validation. The following query generates safely quoted table names from all approved schemas:
First compare database properties and settings:
DB_PROPERTIES_QUERY="SELECT pg_encoding_to_char(encoding),
datcollate,
datctype,
coalesce(datacl::text, '')
FROM pg_database
WHERE datname = current_database();"
DB_SETTINGS_QUERY="SELECT coalesce(r.rolname, '*'), unnest(s.setconfig)
FROM pg_db_role_setting AS s
LEFT JOIN pg_roles AS r ON r.oid = s.setrole
WHERE s.setdatabase = (
SELECT oid
FROM pg_database
WHERE datname = current_database()
)
ORDER BY 1, 2;"
PGPASSWORD="$SOURCE_PG_PASSWORD" \
psql "$SOURCE_PG_URI" -At -F '|' -v ON_ERROR_STOP=1 \
-c "$DB_PROPERTIES_QUERY" \
> "$MIGRATION_WORK_DIR/database/source-database-properties.txt"
PGPASSWORD="$AZ_PG_PASSWORD" \
psql "$TARGET_PG_URI" -At -F '|' -v ON_ERROR_STOP=1 \
-c "$DB_PROPERTIES_QUERY" \
> "$MIGRATION_WORK_DIR/database/target-database-properties.txt"
PGPASSWORD="$SOURCE_PG_PASSWORD" \
psql "$SOURCE_PG_URI" -At -F '|' -v ON_ERROR_STOP=1 \
-c "$DB_SETTINGS_QUERY" \
> "$MIGRATION_WORK_DIR/database/source-database-settings.txt"
PGPASSWORD="$AZ_PG_PASSWORD" \
psql "$TARGET_PG_URI" -At -F '|' -v ON_ERROR_STOP=1 \
-c "$DB_SETTINGS_QUERY" \
> "$MIGRATION_WORK_DIR/database/target-database-settings.txt"
diff -u \
"$MIGRATION_WORK_DIR/database/source-database-properties.txt" \
"$MIGRATION_WORK_DIR/database/target-database-properties.txt"
diff -u \
"$MIGRATION_WORK_DIR/database/source-database-settings.txt" \
"$MIGRATION_WORK_DIR/database/target-database-settings.txt"
Any difference requires a database-owner disposition. Don't continue because encoding or locale differences can change sorting, indexes, and text comparison behavior.
Next, generate the table list:
export PGPASSWORD="$SOURCE_PG_PASSWORD"
psql "$SOURCE_PG_URI" \
-At \
-v ON_ERROR_STOP=1 \
-v schemas="$APP_SCHEMAS" \
-c "SELECT format('%I.%I', schemaname, tablename)
FROM pg_tables
WHERE schemaname = ANY (string_to_array(:'schemas', ','))
ORDER BY 1;" \
> "$MIGRATION_WORK_DIR/database/tables-to-validate.txt"
Compare exact row counts:
ALL_OK=1
while IFS= read -r table_name; do
source_count=$(
PGPASSWORD="$SOURCE_PG_PASSWORD" \
psql "$SOURCE_PG_URI" -At -v ON_ERROR_STOP=1 \
-c "SELECT count(*) FROM $table_name;"
)
target_count=$(
PGPASSWORD="$AZ_PG_PASSWORD" \
psql "$TARGET_PG_URI" -At -v ON_ERROR_STOP=1 \
-c "SELECT count(*) FROM $table_name;"
)
if [ "$source_count" != "$target_count" ]; then
printf 'MISMATCH %s source=%s target=%s\n' \
"$table_name" "$source_count" "$target_count" >&2
ALL_OK=0
fi
done < "$MIGRATION_WORK_DIR/database/tables-to-validate.txt"
test "$ALL_OK" -eq 1
Compare RLS definitions and vector column types:
POLICY_QUERY="SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = ANY (string_to_array(:'schemas', ','))
ORDER BY schemaname, tablename, policyname;"
VECTOR_QUERY="SELECT n.nspname,
c.relname,
a.attname,
format_type(a.atttypid, a.atttypmod)
FROM pg_attribute AS a
JOIN pg_class AS c ON c.oid = a.attrelid
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE a.attnum > 0
AND NOT a.attisdropped
AND n.nspname = ANY (string_to_array(:'schemas', ','))
AND format_type(a.atttypid, a.atttypmod) LIKE 'vector%'
ORDER BY 1, 2, 3;"
PGPASSWORD="$SOURCE_PG_PASSWORD" \
psql "$SOURCE_PG_URI" -At -F '|' -v schemas="$APP_SCHEMAS" \
-c "$POLICY_QUERY" \
> "$MIGRATION_WORK_DIR/database/source-policies.txt"
PGPASSWORD="$AZ_PG_PASSWORD" \
psql "$TARGET_PG_URI" -At -F '|' -v schemas="$APP_SCHEMAS" \
-c "$POLICY_QUERY" \
> "$MIGRATION_WORK_DIR/database/target-policies.txt"
diff -u \
"$MIGRATION_WORK_DIR/database/source-policies.txt" \
"$MIGRATION_WORK_DIR/database/target-policies.txt"
PGPASSWORD="$SOURCE_PG_PASSWORD" \
psql "$SOURCE_PG_URI" -At -F '|' -v schemas="$APP_SCHEMAS" \
-c "$VECTOR_QUERY" \
> "$MIGRATION_WORK_DIR/database/source-vectors.txt"
PGPASSWORD="$AZ_PG_PASSWORD" \
psql "$TARGET_PG_URI" -At -F '|' -v schemas="$APP_SCHEMAS" \
-c "$VECTOR_QUERY" \
> "$MIGRATION_WORK_DIR/database/target-vectors.txt"
diff -u \
"$MIGRATION_WORK_DIR/database/source-vectors.txt" \
"$MIGRATION_WORK_DIR/database/target-vectors.txt"
Update planner statistics after the load:
export PGPASSWORD="$AZ_PG_PASSWORD"
vacuumdb --analyze-in-stages --dbname "$TARGET_PG_URI"
Run workload-specific checks for sequences, constraints, indexes, views, functions, triggers, grants, sampled checksums, and critical joins. Any mismatch is a hard stop.
Phase 6. Migrate Storage
Authenticate AzCopy with Microsoft Entra ID. Don't create or log a shared access signature.
azcopy login --tenant-id "$AZ_TENANT_ID"
Copy and validate one bucket at a time. The source inventory includes bucket settings and object metadata; the application team must map those values to Azure Blob Storage in apply-storage-properties.sh.
python3 - \
"$MIGRATION_WORK_DIR/inventory/storage-buckets.txt" \
"$STORAGE_CONTAINER_MAP" <<'PY'
import csv
import sys
buckets_path, mapping_path = sys.argv[1:]
with open(buckets_path, encoding="utf-8") as stream:
source_buckets = {line.strip() for line in stream if line.strip()}
with open(mapping_path, newline="", encoding="utf-8") as stream:
rows = list(csv.DictReader(stream))
mapped_source_list = [row["source_bucket"].strip() for row in rows]
mapped_target_list = [row["target_container"].strip() for row in rows]
mapped_buckets = set(mapped_source_list)
if source_buckets != mapped_buckets:
raise SystemExit(
"Storage bucket mapping mismatch: "
f"missing={sorted(source_buckets - mapped_buckets)}, "
f"unexpected={sorted(mapped_buckets - source_buckets)}"
)
if len(mapped_source_list) != len(set(mapped_source_list)):
raise SystemExit("STORAGE_CONTAINER_MAP contains a duplicate source bucket")
if len(mapped_target_list) != len(set(mapped_target_list)):
raise SystemExit("STORAGE_CONTAINER_MAP contains a duplicate target container")
PY
while IFS=, read -r bucket target_container; do
test -n "$bucket" || continue
[[ "$target_container" =~ ^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])$ ]] || {
echo "Invalid Azure container name: $target_container" >&2
exit 1
}
[[ "$target_container" != *--* ]] || {
echo "Azure container names can't contain consecutive hyphens." >&2
exit 1
}
stage_dir="$MIGRATION_WORK_DIR/storage/$target_container"
mkdir -p "$stage_dir"
chmod 700 "$stage_dir"
AWS_ACCESS_KEY_ID="$SUPABASE_S3_KEY" \
AWS_SECRET_ACCESS_KEY="$SUPABASE_S3_SECRET" \
aws s3 sync \
"s3://$bucket" \
"$stage_dir" \
--endpoint-url "$SUPABASE_S3_ENDPOINT"
AWS_ACCESS_KEY_ID="$SUPABASE_S3_KEY" \
AWS_SECRET_ACCESS_KEY="$SUPABASE_S3_SECRET" \
aws s3api list-objects-v2 \
--bucket "$bucket" \
--endpoint-url "$SUPABASE_S3_ENDPOINT" \
--output json \
> "$MIGRATION_WORK_DIR/storage/source-$target_container.json"
AWS_ACCESS_KEY_ID="$SUPABASE_S3_KEY" \
AWS_SECRET_ACCESS_KEY="$SUPABASE_S3_SECRET" \
bash "$CUTOVER_PLAN_DIR/capture-storage-properties.sh" \
"$bucket" \
"$SUPABASE_S3_ENDPOINT" \
"$MIGRATION_WORK_DIR/storage/source-$target_container.json" \
> "$MIGRATION_WORK_DIR/storage/source-$target_container-properties.json"
az storage container create \
--account-name "$AZ_STORAGE_ACCT" \
--name "$target_container" \
--auth-mode login \
--public-access off
container_owner=$(
az storage container metadata show \
--account-name "$AZ_STORAGE_ACCT" \
--name "$target_container" \
--auth-mode login \
--query "supabasemigrationsource" \
--output tsv
)
if [ -z "$container_owner" ]; then
existing_blob_count=$(
az storage blob list \
--account-name "$AZ_STORAGE_ACCT" \
--container-name "$target_container" \
--auth-mode login \
--num-results 1 \
--query "length(@)" \
--output tsv
)
test "$existing_blob_count" -eq 0 || {
echo "Target container $target_container isn't empty or migration-owned." >&2
exit 1
}
az storage container metadata update \
--account-name "$AZ_STORAGE_ACCT" \
--name "$target_container" \
--auth-mode login \
--metadata supabasemigrationsource="$bucket"
else
test "$container_owner" = "$bucket" || {
echo "Target container $target_container belongs to $container_owner." >&2
exit 1
}
fi
if [ "${FINAL_STORAGE_SYNC:-no}" = "yes" ]; then
azcopy sync \
"$stage_dir" \
"https://$AZ_STORAGE_ACCT.blob.core.windows.net/$target_container" \
--recursive=true \
--delete-destination=true
else
azcopy copy \
"$stage_dir/*" \
"https://$AZ_STORAGE_ACCT.blob.core.windows.net/$target_container" \
--recursive
fi
bash "$CUTOVER_PLAN_DIR/apply-storage-properties.sh" \
"$bucket" \
"$target_container" \
"$MIGRATION_WORK_DIR/inventory/storage-bucket-settings.csv" \
"$MIGRATION_WORK_DIR/storage/source-$target_container-properties.json"
az storage blob list \
--account-name "$AZ_STORAGE_ACCT" \
--container-name "$target_container" \
--auth-mode login \
--num-results "*" \
--query "[].{Key:name,Size:properties.contentLength}" \
--output json \
> "$MIGRATION_WORK_DIR/storage/target-$target_container.json"
python3 - \
"$MIGRATION_WORK_DIR/storage/source-$target_container.json" \
"$MIGRATION_WORK_DIR/storage/target-$target_container.json" \
"$MIGRATION_WORK_DIR/storage/source-$target_container.manifest" \
"$MIGRATION_WORK_DIR/storage/target-$target_container.manifest" <<'PY'
import json
import sys
source_json, target_json, source_manifest, target_manifest = sys.argv[1:]
with open(source_json, encoding="utf-8") as stream:
source = json.load(stream).get("Contents", [])
with open(target_json, encoding="utf-8") as stream:
target = json.load(stream)
source_rows = sorted((item["Key"], int(item["Size"])) for item in source)
target_rows = sorted((item["Key"], int(item["Size"])) for item in target)
for path, rows in ((source_manifest, source_rows), (target_manifest, target_rows)):
with open(path, "w", encoding="utf-8") as stream:
for key, size in rows:
stream.write(f"{key}\t{size}\n")
PY
diff -u \
"$MIGRATION_WORK_DIR/storage/source-$target_container.manifest" \
"$MIGRATION_WORK_DIR/storage/target-$target_container.manifest"
rm -rf -- "$stage_dir"
done < <(tail -n +2 "$STORAGE_CONTAINER_MAP")
Preserve object names and paths exactly. The final manifests must match by key and byte size. Also compare a representative sample of source and target SHA-256 hashes after downloading both objects, and verify mapped content type, cache control, content disposition, custom metadata, and container access policy. Database rows and application code often store object paths rather than full URLs.
Phase 7. Migrate Auth to Microsoft Entra External ID
Auth migration requires application changes and a human-approved customer communication plan. Supabase password hashes, sessions, refresh tokens, MFA registrations, and social identity links can't be imported into Microsoft Entra External ID through the Microsoft Graph user creation API.
Export source users
export PGPASSWORD="$SOURCE_PG_PASSWORD"
psql "$SOURCE_PG_URI" --csv -v ON_ERROR_STOP=1 \
-c "SELECT id, email, raw_user_meta_data, created_at, last_sign_in_at
FROM auth.users
ORDER BY id;" \
> "$MIGRATION_WORK_DIR/auth/users.csv"
psql "$SOURCE_PG_URI" --csv -v ON_ERROR_STOP=1 \
-c "SELECT id, phone
FROM auth.users
WHERE email IS NULL OR btrim(email) = ''
ORDER BY id;" \
> "$MIGRATION_WORK_DIR/auth/users-without-email.csv"
chmod 600 "$MIGRATION_WORK_DIR/auth/users.csv"
chmod 600 "$MIGRATION_WORK_DIR/auth/users-without-email.csv"
test "$(wc -l < "$MIGRATION_WORK_DIR/auth/users-without-email.csv")" -eq 1 || {
echo "Users without email require a separately approved identity path." >&2
exit 1
}
Create users and record the identity mapping
The app registration needs the Microsoft Graph User.ReadWrite.All application permission with administrator consent. Use a separate Azure CLI profile so this sign-in doesn't replace the Azure subscription session:
GRAPH_AZURE_CONFIG_DIR="$MIGRATION_WORK_DIR/auth/azure-cli"
mkdir -p "$GRAPH_AZURE_CONFIG_DIR"
chmod 700 "$GRAPH_AZURE_CONFIG_DIR"
AZURE_CONFIG_DIR="$GRAPH_AZURE_CONFIG_DIR" az login \
--service-principal \
--username "$GRAPH_CLIENT_ID" \
--certificate "$GRAPH_CLIENT_CERT" \
--tenant "$ENTRA_TENANT_ID" \
--allow-no-subscriptions
GRAPH_ACCESS_TOKEN=$(
AZURE_CONFIG_DIR="$GRAPH_AZURE_CONFIG_DIR" az account get-access-token \
--resource-type ms-graph \
--query accessToken \
--output tsv
)
export GRAPH_ACCESS_TOKEN
Create $MIGRATION_WORK_DIR/auth/create-entra-users.py with the following content:
import csv
import json
import os
import secrets
import string
import sys
import urllib.error
import urllib.parse
import urllib.request
if len(sys.argv) != 4:
raise SystemExit(
"Usage: create-entra-users.py users.csv identity-map.csv initial-credentials.csv"
)
token = os.environ.get("GRAPH_ACCESS_TOKEN")
issuer = os.environ.get("ENTRA_ISSUER")
if not token or not issuer:
raise SystemExit("GRAPH_ACCESS_TOKEN and ENTRA_ISSUER are required")
source_path, mapping_path, credentials_path = sys.argv[1:]
mapped_users = {}
mapped_source_ids = {}
mapping_exists = os.path.exists(mapping_path)
if mapping_exists:
with open(mapping_path, newline="", encoding="utf-8") as stream:
for row in csv.DictReader(stream):
email = row["email"].lower()
mapped_users[email] = row["entra_object_id"]
mapped_source_ids[email] = row["supabase_user_id"]
credential_emails = set()
if os.path.exists(credentials_path):
with open(credentials_path, newline="", encoding="utf-8") as stream:
credential_emails = {
row["email"].lower() for row in csv.DictReader(stream)
}
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
def graph_json_request(method, url, body=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(
url,
data=data,
headers={
"Authorization": "Bearer " + token,
"Content-Type": "application/json",
},
method=method,
)
try:
with urllib.request.urlopen(request) as response:
payload = response.read()
return json.loads(payload) if payload else {}
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise SystemExit(
f"Microsoft Graph {method} failed: HTTP {error.code}: {detail}"
) from error
def find_existing_user(email):
escaped_email = email.replace("'", "''")
escaped_issuer = issuer.replace("'", "''")
expression = (
"identities/any(c:"
f"c/issuerAssignedId eq '{escaped_email}' and "
f"c/issuer eq '{escaped_issuer}')"
)
query = urllib.parse.quote(expression, safe="()/:")
result = graph_json_request(
"GET",
"https://graph.microsoft.com/v1.0/users"
"?%24select=id,identities&%24filter=" + query,
)
matches = result.get("value", [])
if len(matches) > 1:
raise SystemExit(f"Multiple External ID users match {email}")
return matches[0]["id"] if matches else None
def reset_temporary_password(object_id, temporary_password):
graph_json_request(
"PATCH",
f"https://graph.microsoft.com/v1.0/users/{object_id}",
{
"passwordProfile": {
"password": temporary_password,
"forceChangePasswordNextSignIn": True,
}
},
)
with open(source_path, newline="", encoding="utf-8") as source, \
open(mapping_path, "a", newline="", encoding="utf-8") as mapping, \
open(credentials_path, "a", newline="", encoding="utf-8") as credentials:
users = csv.DictReader(source)
map_fields = ["supabase_user_id", "email", "entra_object_id"]
credential_fields = ["email", "temporary_password"]
map_writer = csv.DictWriter(mapping, fieldnames=map_fields)
credential_writer = csv.DictWriter(credentials, fieldnames=credential_fields)
if not mapping_exists:
map_writer.writeheader()
if credentials.tell() == 0:
credential_writer.writeheader()
for user in users:
email = (user.get("email") or "").strip().lower()
if not email:
raise SystemExit(f"User {user['id']} has no email")
if email in mapped_source_ids and mapped_source_ids[email] != user["id"]:
raise SystemExit(f"Supabase user ID changed for {email}")
if email in mapped_users and email in credential_emails:
continue
temporary_password = (
"Aa1!" + "".join(secrets.choice(alphabet) for _ in range(20))
)
existing_id = mapped_users.get(email)
if existing_id:
reset_temporary_password(existing_id, temporary_password)
credential_writer.writerow(
{"email": email, "temporary_password": temporary_password}
)
credentials.flush()
os.fsync(credentials.fileno())
credential_emails.add(email)
continue
unmatched_existing_id = find_existing_user(email)
if unmatched_existing_id:
raise SystemExit(
f"External ID account {email} exists without a migration mapping; "
"verify ownership and recover it manually"
)
body = {
"displayName": email,
"identities": [
{
"signInType": "emailAddress",
"issuer": issuer,
"issuerAssignedId": email,
}
],
"mail": email,
"passwordProfile": {
"password": temporary_password,
"forceChangePasswordNextSignIn": True,
},
"passwordPolicies": "DisablePasswordExpiration",
}
request = urllib.request.Request(
"https://graph.microsoft.com/v1.0/users",
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request) as response:
created = json.load(response)
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise SystemExit(
f"Failed to create {email}: HTTP {error.code}: {detail}"
) from error
credential_writer.writerow(
{"email": email, "temporary_password": temporary_password}
)
credentials.flush()
os.fsync(credentials.fileno())
map_writer.writerow(
{
"supabase_user_id": user["id"],
"email": email,
"entra_object_id": created["id"],
}
)
mapping.flush()
os.fsync(mapping.fileno())
mapped_users[email] = created["id"]
mapped_source_ids[email] = user["id"]
credential_emails.add(email)
os.chmod(mapping_path, 0o600)
os.chmod(credentials_path, 0o600)
Run the script:
python3 "$MIGRATION_WORK_DIR/auth/create-entra-users.py" \
"$MIGRATION_WORK_DIR/auth/users.csv" \
"$MIGRATION_WORK_DIR/auth/identity-map.csv" \
"$MIGRATION_WORK_DIR/auth/initial-credentials.csv"
The script creates one user per Graph request and captures the returned Entra object ID. It resets a temporary password only when the local mapping proves that the migration created the account. If the identity already exists without that mapping, it halts for ownership review instead of changing the account. File writes are flushed before the next user. See Create user in Microsoft Graph.
Protect initial-credentials.csv, deliver temporary credentials through an approved secure channel, and delete the file after the transition. Social users must reconnect their identity provider. Existing sessions and refresh tokens become invalid at cutover.
Preserve application user references
Don't replace existing Supabase UUID foreign keys with Entra object IDs during the migration. Import identity-map.csv into an application-owned mapping table and resolve the Entra oid or sub claim to the existing Supabase UUID at the trusted application boundary.
For example:
CREATE SCHEMA IF NOT EXISTS app_auth;
CREATE TABLE IF NOT EXISTS app_auth.identity_map (
supabase_user_id uuid PRIMARY KEY,
entra_object_id uuid UNIQUE NOT NULL,
email text NOT NULL
);
Import the reviewed mapping file:
export PGPASSWORD="$AZ_PG_PASSWORD"
psql "$TARGET_PG_URI" -v ON_ERROR_STOP=1 <<SQL
CREATE TEMP TABLE identity_map_stage (
supabase_user_id uuid,
email text,
entra_object_id uuid
);
\copy identity_map_stage (supabase_user_id, email, entra_object_id) FROM '$MIGRATION_WORK_DIR/auth/identity-map.csv' WITH (FORMAT csv, HEADER true)
INSERT INTO app_auth.identity_map (supabase_user_id, email, entra_object_id)
SELECT supabase_user_id, email, entra_object_id
FROM identity_map_stage
ON CONFLICT (supabase_user_id) DO UPDATE
SET email = EXCLUDED.email,
entra_object_id = EXCLUDED.entra_object_id;
SQL
The application team must:
- Replace Supabase client Auth calls with MSAL or another supported Microsoft identity library.
- Validate the new issuer, audience, signature, and claim shape.
- Map the validated Entra object ID to
supabase_user_id. - Replace each
auth.uid()RLS dependency with a reviewed helper that reads an identity value set by the trusted backend for the current transaction. - Prevent untrusted clients from setting the database session identity.
- Recreate verification, password reset, MFA, and social provider experiences.
Compare the exact Supabase UUID set with the mapping, not only the counts:
python3 - \
"$MIGRATION_WORK_DIR/auth/users.csv" \
"$MIGRATION_WORK_DIR/auth/identity-map.csv" <<'PY'
import csv
import sys
users_path, mapping_path = sys.argv[1:]
with open(users_path, newline="", encoding="utf-8") as stream:
source_ids = {row["id"] for row in csv.DictReader(stream)}
with open(mapping_path, newline="", encoding="utf-8") as stream:
mapped_ids = {row["supabase_user_id"] for row in csv.DictReader(stream)}
missing = sorted(source_ids - mapped_ids)
unexpected = sorted(mapped_ids - source_ids)
if missing or unexpected:
raise SystemExit(
f"Identity mapping mismatch: missing={missing}, unexpected={unexpected}"
)
PY
Verify every mapped account against Microsoft Graph:
python3 - "$MIGRATION_WORK_DIR/auth/identity-map.csv" <<'PY'
import csv
import json
import os
import sys
import urllib.error
import urllib.request
mapping_path = sys.argv[1]
token = os.environ.get("GRAPH_ACCESS_TOKEN")
issuer = os.environ.get("ENTRA_ISSUER")
if not token or not issuer:
raise SystemExit("GRAPH_ACCESS_TOKEN and ENTRA_ISSUER are required")
with open(mapping_path, newline="", encoding="utf-8") as stream:
for row in csv.DictReader(stream):
object_id = row["entra_object_id"]
email = row["email"].lower()
request = urllib.request.Request(
"https://graph.microsoft.com/v1.0/users/"
+ object_id
+ "?%24select=id,accountEnabled,identities",
headers={"Authorization": "Bearer " + token},
)
try:
with urllib.request.urlopen(request) as response:
user = json.load(response)
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise SystemExit(
f"Failed to read mapped account {object_id}: "
f"HTTP {error.code}: {detail}"
) from error
if user.get("accountEnabled") is not True:
raise SystemExit(f"Mapped account is disabled: {email}")
expected_identity = any(
identity.get("signInType") == "emailAddress"
and identity.get("issuer") == issuer
and identity.get("issuerAssignedId", "").lower() == email
for identity in user.get("identities", [])
)
if not expected_identity:
raise SystemExit(f"Mapped account identity mismatch: {email}")
PY
Run end-to-end tests for sign-up, first sign-in, password reset, social reconnection, authorization, and account recovery. Any missing or unexpected mapping is a hard stop.
Phase 8. Migrate Edge Functions and Realtime
Edge Functions
Port each Deno function to Azure Functions or Azure Container Apps. Inventory its runtime version, secrets, outbound dependencies, timeout, concurrency, scheduled trigger, and authentication behavior. Generate scaffolding, port the handler, and require application-owner review and tests before deployment.
Realtime
Provision Azure Web PubSub and replace client-side supabase.channel(...).on(...) calls with the Web PubSub client SDK. Implement and test a trusted publisher path, such as an application service, Azure Function, or change-processing worker. Don't assume that PostgreSQL triggers can publish directly to Web PubSub without an approved integration component.
Create the resources only after names, plans, networking, identities, and separate storage requirements are resolved:
if ! az storage account show \
--resource-group "$AZ_RG" \
--name "$AZ_FUNCTION_STORAGE_ACCT" \
> /dev/null 2>&1; then
az storage account create \
--resource-group "$AZ_RG" \
--name "$AZ_FUNCTION_STORAGE_ACCT" \
--location "$AZ_REGION" \
--sku Standard_LRS \
--kind StorageV2 \
--min-tls-version TLS1_2 \
--allow-blob-public-access false
fi
if ! az functionapp show \
--resource-group "$AZ_RG" \
--name "$AZ_FUNCTION_APP" \
> /dev/null 2>&1; then
az functionapp create \
--resource-group "$AZ_RG" \
--name "$AZ_FUNCTION_APP" \
--consumption-plan-location "$AZ_REGION" \
--runtime node \
--runtime-version 20 \
--functions-version 4 \
--storage-account "$AZ_FUNCTION_STORAGE_ACCT"
fi
if ! az webpubsub show \
--resource-group "$AZ_RG" \
--name "$AZ_WEBPUBSUB" \
> /dev/null 2>&1; then
az webpubsub create \
--resource-group "$AZ_RG" \
--name "$AZ_WEBPUBSUB" \
--location "$AZ_REGION" \
--sku Standard_S1
fi
az storage account show \
--resource-group "$AZ_RG" \
--name "$AZ_FUNCTION_STORAGE_ACCT" \
> "$MIGRATION_WORK_DIR/inventory/function-storage.json"
az functionapp show \
--resource-group "$AZ_RG" \
--name "$AZ_FUNCTION_APP" \
> "$MIGRATION_WORK_DIR/inventory/function-app.json"
az webpubsub show \
--resource-group "$AZ_RG" \
--name "$AZ_WEBPUBSUB" \
> "$MIGRATION_WORK_DIR/inventory/web-pubsub.json"
If a resource already exists, compare the captured location, SKU, runtime, storage binding, networking, identity, and security settings with the approved design. Halt on any mismatch.
Phase 9. Cut over and roll back
Pre-cutover checklist
Continue only when all checks pass:
- Database row counts, RLS definitions, vector types, constraints, indexes, sequences, and critical queries match.
- Storage object counts and sampled hashes match.
- Every Auth user has an identity mapping, and the tested sign-in and recovery flows pass.
- Edge Functions and Realtime replacements pass integration and load tests.
- The application passes staging smoke tests against the Azure endpoints.
- All 13 scripts in
CUTOVER_PLAN_DIRpassed rehearsal and contain no placeholders. - The production application network path to Azure passed a connection test from the actual production workload network.
- DNS TTL or configuration propagation behavior was tested.
- Monitoring, alerting, support contacts, and the go/no-go decision owner are confirmed.
- For the offline path, the production target database is empty; rehearsal used a separate target.
Freeze source writes
Record an audit timestamp and run the application-owned write freeze. The timestamp is for the incident record only; don't use it as the data reconciliation boundary.
CUTOVER_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
printf '%s\n' "$CUTOVER_UTC" \
> "$MIGRATION_WORK_DIR/logs/cutover-utc.txt"
bash "$CUTOVER_PLAN_DIR/freeze-writes.sh"
bash "$CUTOVER_PLAN_DIR/freeze-ddl.sh"
Inspect remaining database sessions:
export PGPASSWORD="$SOURCE_PG_PASSWORD"
psql "$SOURCE_PG_URI" -v ON_ERROR_STOP=1 \
-c "SELECT pid,
usename,
application_name,
client_addr,
state,
xact_start,
query_start
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> pg_backend_pid()
ORDER BY xact_start NULLS LAST, query_start;"
The application owner must confirm that telemetry, queues, scheduled jobs, webhooks, and direct database clients show no remaining writer. Don't use ALTER USER ... SET default_transaction_read_only as the write freeze. It affects defaults for new sessions and doesn't stop existing writers.
After the write freeze, rerun the Phase 1 Storage bucket, bucket-setting, and object-metadata inventory commands. Reconcile STORAGE_CONTAINER_MAP; a new or deleted bucket requires an explicit retain, create, or delete decision. Then set FINAL_STORAGE_SYNC=yes and rerun Phase 6. The operator must approve --delete-destination=true before execution. This final frozen sync must capture fresh per-object properties, remove destination-only blobs, produce matching key-and-size manifests, reapply the approved property mapping, and pass sampled hash validation.
Rerun Phase 7 after the write freeze. Export the current Supabase users and identity providers, rerun the non-email-user gate and user creation script, upsert the mapping table, and compare the exact UUID sets. A user deleted since rehearsal requires an approved External ID disablement and mapping archival step; don't ignore an unexpected mapping row.
Complete the database migration
For the offline path, regenerate and apply Phase 3 pre-data, take and restore the Phase 4a data archive, and apply post-data and grants after the write and DDL freeze. Run the production export only against the frozen source and the restore only against the clean production target. Then rerun Phase 5.
For the online path, wait until the migration is in WaitingForUserAction, source writes are stopped, and replication latency is zero or close to zero:
az postgres flexible-server migration show \
--subscription "$AZ_SUBSCRIPTION_ID" \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name "$AZ_MIGRATION_NAME"
az postgres flexible-server migration update \
--subscription "$AZ_SUBSCRIPTION_ID" \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name "$AZ_MIGRATION_NAME" \
--cutover
az postgres flexible-server migration show \
--subscription "$AZ_SUBSCRIPTION_ID" \
--resource-group "$AZ_RG" \
--server-name "$AZ_PG_SERVER" \
--name "$AZ_MIGRATION_NAME"
Continue only when the migration reaches Succeeded. The --cancel operation stops a migration; it isn't a cutover.
For the online path, apply the reviewed database and object grants after the migrated objects exist:
export PGPASSWORD="$AZ_PG_PASSWORD"
psql "$TARGET_PG_URI" \
-v ON_ERROR_STOP=1 \
-f "$MIGRATION_WORK_DIR/database/application-grants.sql"
Rerun Phase 5 after the source write freeze and completed cutover. Use these stable results for the go/no-go decision; don't rely on comparisons captured while the source was still changing.
Switch the application while target writes remain disabled
bash "$CUTOVER_PLAN_DIR/point-to-azure.sh"
Run read-only smoke tests for:
- Sign-in and authorization.
- Critical reads and joins.
- File download.
- Edge Function responses.
- Realtime connection and subscription.
If any read-only smoke test fails, use the pre-write rollback. If all tests pass, create a target-generated monotonic reconciliation boundary:
bash "$CUTOVER_PLAN_DIR/capture-target-marker.sh" \
> "$MIGRATION_WORK_DIR/logs/reconciliation-marker.txt"
test -s "$MIGRATION_WORK_DIR/logs/reconciliation-marker.txt"
Mark the start of the write-enable attempt before invoking it:
: > "$MIGRATION_WORK_DIR/logs/write-enable-attempted"
bash "$CUTOVER_PLAN_DIR/enable-azure-writes.sh"
Run write smoke tests for create, update, delete, file upload, Auth, and Realtime. Monitor errors, latency, queue depth, connection count, and database load for the approved observation window.
Use the pre-write rollback
Use this path only when write-enable-attempted doesn't exist. Once write enablement starts, a partial or ambiguous result requires the post-write rollback path.
test ! -e "$MIGRATION_WORK_DIR/logs/write-enable-attempted"
bash "$CUTOVER_PLAN_DIR/point-to-supabase.sh"
bash "$CUTOVER_PLAN_DIR/resume-source-writes.sh"
bash "$CUTOVER_PLAN_DIR/resume-source-ddl.sh"
Because write enablement was never attempted, Azure accepted no application writes and there is no data delta to reconcile.
Use the post-write rollback
After Azure accepts writes, don't switch directly back to Supabase.
Freeze every Azure-side writer:
bash "$CUTOVER_PLAN_DIR/freeze-azure-writes.sh"Export all Azure writes after the target-generated value in
reconciliation-marker.txt:bash "$CUTOVER_PLAN_DIR/export-azure-delta.sh" \ "$MIGRATION_WORK_DIR/logs/reconciliation-marker.txt"Replay the delta into Supabase:
bash "$CUTOVER_PLAN_DIR/replay-source-delta.sh"Rerun database, Storage, Auth, and workload-specific validation.
Obtain human approval, point the application to Supabase, and resume source writes:
bash "$CUTOVER_PLAN_DIR/point-to-supabase.sh" bash "$CUTOVER_PLAN_DIR/resume-source-writes.sh" bash "$CUTOVER_PLAN_DIR/resume-source-ddl.sh"
If change capture is incomplete, replay has conflicts, or validation fails, don't switch back. Keep the application in maintenance mode and fix forward on Azure.
Create an internal incident record for every failed cutover. Open a Microsoft support request only when evidence indicates an Azure product or service issue. Don't retry until the root cause and corrective action are approved.
Phase 10. Harden and decommission
After migration validation succeeds:
Confirm that the production application network path configured in Phase 2 is healthy. Prefer private access or a private endpoint where the workload supports it.
Remove the temporary runner firewall rule:
az postgres flexible-server firewall-rule delete \ --resource-group "$AZ_RG" \ --server-name "$AZ_PG_SERVER" \ --name migration-runner \ --yesEnable zonal resiliency if the selected region and SKU support it:
az postgres flexible-server update \ --resource-group "$AZ_RG" \ --name "$AZ_PG_SERVER" \ --zonal-resiliency Enabled \ --yesConfigure backups, alerts, maintenance, connection pooling, least-privilege application roles, and disaster recovery.
Delete the online migration properties file because it contains passwords:
rm -f -- "$MIGRATION_WORK_DIR/database/online-migration.json" unset GRAPH_ACCESS_TOKEN rm -rf -- "$MIGRATION_WORK_DIR/auth/azure-cli"Securely archive the approved inventory, validation evidence, identity mapping, and cutover record. Don't archive access tokens or temporary passwords.
Confirm zero production traffic to Supabase for at least 14 consecutive days.
Take a final source backup and place it in a separate immutable archive.
Delete
initial-credentials.csvafter the identity transition is complete. Retain the password-free identity mapping.Delete the Supabase project only after the retention period and explicit human approval.
Appendix A. Agent execution rules
- No improvisation: Halt when a command fails and the runbook has no reviewed recovery step.
- Idempotency: Classify each operation as read-only, upsert, or create-once. Before a create-once operation, check the existing resource and verify its configuration. If state is ambiguous, halt.
- Secrets: Read secrets from a protected store or environment. Never print them, include them in logs, or enable shell tracing.
- Logging: Log the phase, step label, timestamp, and exit code. Redact connection strings, passwords, tokens, and user credentials. Don't log complete command lines that contain secret arguments.
- Error handling: On a nonzero exit, capture redacted standard error, the current phase, and the last successful step, then halt.
- Versioning: Use a PostgreSQL client version that supports both endpoints. Keep the target major version equal to the source major version.
- Schema consistency: Use
APP_SCHEMASfor schema export, data export, and every database validation. - Confirmation gates: Require human approval before online cutover, enabling target writes, post-write rollback, and source deletion.
- Evidence: Store validation output and approvals in the protected migration record.
Appendix B. Service mapping
| Supabase capability | Azure target | Migration method |
|---|---|---|
| PostgreSQL database | Azure Database for PostgreSQL flexible server | Offline pg_dump and pg_restore, or the integrated PostgreSQL migration service after successful validation |
| Storage objects | Azure Blob Storage | AWS CLI staging and AzCopy with Microsoft Entra authentication |
| Auth | Microsoft Entra External ID | Per-user Graph creation, durable Supabase-to-Entra identity mapping, forced first-sign-in reset, and application rewrite |
| Edge Functions | Azure Functions or Azure Container Apps | Manual port, review, and testing |
| Realtime | Azure Web PubSub plus a trusted publisher | Client and server integration rewrite |
pg_cron |
Supported PostgreSQL extension when available | Recreate reviewed jobs after extension validation |
pgvector |
vector extension |
Allow and create the extension, then compare vector column types and dimensions |
Appendix C. Validation evidence
This runbook was executed against a local Supabase stack (PG 17.6) migrating into Azure Database for PostgreSQL Flexible Server (PG 16.13, Standard_B1ms) in westus3 on May 12, 2026.
| Table | Source rows | Target rows | Result |
|---|---|---|---|
app.tenants |
3 | 3 | PASS |
app.users |
30 | 30 | PASS |
app.documents (vector(384)) |
501 | 501 | PASS |
app.events |
2,010 | 2,010 | PASS |
app.audit_log |
200 | 200 | PASS |
Appendix D. Out of scope
- Automatic application code conversion for Auth, Realtime, and Edge Functions.
- Design of the application's durable change log, outbox, or conflict-resolution rules.
- DNS provider-specific commands.
- Workload sizing and cost optimization.
- A complete disaster recovery design.