Edit

Migrate Supabase workloads to Azure

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_dump and pg_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

  1. Choose either the offline database path or the online database path. Don't run both paths against the same target database.
  2. Resolve every required input and application-specific script in Phase 0.
  3. Run a full rehearsal against a nonproduction copy before the production migration.
  4. Halt on any failed prerequisite, validation mismatch, unrecognized error, or missing approval.
  5. 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, and vacuumdb) 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.All application 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_SCHEMAS is empty or includes a Supabase-managed schema without explicit approval.
  • The source contains large objects and MIGRATE_LARGE_OBJECTS isn't explicitly set to yes.
  • 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=online and the owner hasn't approved migration of every non-system schema in the source database by setting MIGRATE_ENTIRE_DATABASE=yes.
  • MIGRATION_MODE=online and 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.sql for application-owned roles only.
  • $MIGRATION_WORK_DIR/database/application-grants.sql for 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_DIR passed 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.

  1. Freeze every Azure-side writer:

    bash "$CUTOVER_PLAN_DIR/freeze-azure-writes.sh"
    
  2. 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"
    
  3. Replay the delta into Supabase:

    bash "$CUTOVER_PLAN_DIR/replay-source-delta.sh"
    
  4. Rerun database, Storage, Auth, and workload-specific validation.

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

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

  2. 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 \
      --yes
    
  3. Enable 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 \
      --yes
    
  4. Configure backups, alerts, maintenance, connection pooling, least-privilege application roles, and disaster recovery.

  5. 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"
    
  6. Securely archive the approved inventory, validation evidence, identity mapping, and cutover record. Don't archive access tokens or temporary passwords.

  7. Confirm zero production traffic to Supabase for at least 14 consecutive days.

  8. Take a final source backup and place it in a separate immutable archive.

  9. Delete initial-credentials.csv after the identity transition is complete. Retain the password-free identity mapping.

  10. 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_SCHEMAS for 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.