diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
index 20fd5ea625c..46c0992f574 100644
--- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
+++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
@@ -2856,6 +2856,7 @@ export const self_hosting: NavMenuConstant = {
name: 'Add Reverse Proxy with HTTPS',
url: '/guides/self-hosting/self-hosted-proxy-https',
},
+ { name: 'Upgrade to Postgres 17', url: '/guides/self-hosting/postgres-upgrade-17' },
{
name: 'Restore Project from Platform',
url: '/guides/self-hosting/restore-from-platform',
diff --git a/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx b/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx
new file mode 100644
index 00000000000..0b57ab4d706
--- /dev/null
+++ b/apps/docs/content/guides/self-hosting/postgres-upgrade-17.mdx
@@ -0,0 +1,320 @@
+---
+title: 'Upgrade to Postgres 17'
+description: 'Start a new self-hosted Supabase deployment with Postgres 17, or upgrade an existing Postgres 15 installation.'
+subtitle: 'Start a new self-hosted Supabase deployment with Postgres 17, or upgrade an existing Postgres 15 installation.'
+---
+
+Self-hosted Supabase ships with Postgres 15 by default. This guide covers two scenarios:
+
+- **New deployment** - start fresh with Postgres 17 (no existing data)
+- **Upgrade existing deployment** - migrate from Postgres 15 to Postgres 17 using `pg_upgrade`
+
+## Before you begin
+
+- Complete the [Self-Hosting with Docker](/docs/guides/self-hosting/docker) setup
+- Your current database image should be `supabase/postgres:15.x` (check with `docker inspect supabase-db --format '{{.Config.Image}}'`)
+
+## New deployment with Postgres 17
+
+If you are starting a new self-hosted Supabase instance with **no existing data**, use the Postgres 17 compose override:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
+```
+
+This uses the `docker-compose.pg17.yml` override file which swaps the database image:
+
+```yaml
+services:
+ db:
+ image: supabase/postgres:17.6.1.084
+```
+
+
+
+Always include both compose files when running commands. If you omit the override, Docker Compose falls back to the Postgres 15 image defined in `docker-compose.yml`.
+
+
+
+The rest of the setup is the same as the standard Docker guide. All init scripts (`roles.sql`, `jwt.sql`, `webhooks.sql`, etc.) are compatible with Postgres 17.
+
+{/* supa-mdx-lint-disable-next-line Rule003Spelling */}
+If the new Postgres 17 container fails to start, make sure to check for an old `db-config` Docker volume. See [Postgres 17 fails to start with a leftover db-config volume](#postgres-17-fails-to-start-with-a-leftover-db-config-volume) for details.
+
+## Upgrade an existing Postgres 15 deployment
+
+Upgrading an existing deployment uses `pg_upgrade` to migrate data in place. The included upgrade scripts automates the full process.
+
+### What the upgrade does
+
+1. Pulls a specific Postgres 17 image and extracts upgrade binaries
+2. Pulls supplemental upgrade scripts from Supabase's [Postgres](https://github.com/supabase/postgres) repository
+3. Stops all self-hosted Supabase containers
+4. Runs `pg_upgrade` inside a temporary Postgres 15 container
+5. Runs additional tasks inside a temporary Postgres 17 container (re-enables extensions, applies patches, runs `VACUUM ANALYZE`)
+6. Swaps data directories (the original is kept as a backup)
+7. Starts self-hosted Supabase with Postgres 17
+8. Applies additional role migrations (new in Postgres 17)
+
+### Create a backup
+
+
+
+You should create your own independent backup in case of disk failure or other issues.
+
+
+
+The upgrade script automatically preserves the pgsodium key and original data directory as `./volumes/db/data.bak.pg15` as the final step. However, it is recommended to **always** create your own independent backup before starting:
+
+Back up the database data directory:
+
+```sh
+cp -a ./volumes/db/data ./volumes/db/data-manual-backup
+```
+
+Back up the pgsodium encryption key (stored in a Docker named volume):
+
+```sh
+docker compose run --rm db cat /etc/postgresql-custom/pgsodium_root.key > ./pgsodium_root.key.backup
+```
+
+
+
+The `db-config` Docker named volume contains the pgsodium root encryption key. If you lose this key and have vault secrets, they become unrecoverable. The `cp -a` above backs up the data directory but NOT the named volume.
+
+
+
+Optionally, take a logical backup too:
+
+```sh
+docker exec supabase-db pg_dumpall -h localhost -U supabase_admin > ./pg15_dump.sql
+```
+
+### Requirements
+
+- At least **2x your current database size + 5 GB** of free disk space (`pg_upgrade` copies the data directory; the upgrade tarball is ~1.2 GB compressed)
+- The script prompts for confirmation at each major step (use `--yes` to skip prompts)
+- All self-hosted Supabase containers must be running before starting the upgrade
+- Requires `bash`
+- Must be run as root or using `sudo`
+
+### Extensions removed in Postgres 17
+
+The following extensions are **not available** in Postgres 17 builds. The upgrade script will prompt you to drop them if any of these are found:
+
+| Extension | Notes |
+| ------------- | ------------------------- |
+| `timescaledb` | Not built for Postgres 17 |
+| `plv8` | Not built for Postgres 17 |
+| `plcoffee` | Companion to plv8 |
+| `plls` | Companion to plv8 |
+
+None of the above extensions are installed by default in the self-hosted Supabase setup. If you have installed any of them manually and need to keep them, **do not proceed** with the upgrade.
+
+### Run the upgrade
+
+```sh
+sudo bash utils/upgrade-pg17.sh
+```
+
+The script might require your confirmation at some steps (e.g., while checking for disk space, or whether to disable extensions, or remove previous backups).
+
+### After the upgrade
+
+After a successful upgrade, always use both compose files:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
+```
+
+To verify that Postgres 17 is running:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml exec db psql -U postgres -c "SELECT version();"
+```
+
+The original Postgres 15 data is preserved at `./volumes/db/data.bak.pg15`. The pgsodium root key is saved as `./volumes/db/pgsodium_root.key.bak.pg15`. The upgrade binaries tarball is cached at `./volumes/db/pg17_upgrade_bin_*.tar.gz`. Once you have verified that everything works, you can reclaim disk space:
+
+```sh
+rm -rf ./volumes/db/data.bak.pg15 ./volumes/db/pgsodium_root.key.bak.pg15 ./volumes/db/pg17_upgrade_bin_*.tar.gz
+```
+
+
+
+Do not delete `data.bak.pg15` until you have verified the upgrade. Rollback is only possible while the backup exists.
+
+
+
+### Rollback
+
+If you need to revert to Postgres 15 (run the following commands as root):
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml down && \
+rm -rf ./volumes/db/data && \
+mv ./volumes/db/data.bak.pg15 ./volumes/db/data && \
+docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/ && \
+docker compose up -d
+```
+
+This restores the original data directory, fixes file ownership on the `db-config` volume (Supabase's Postgres 15 and 17 images use different user IDs), and starts with the old Postgres 15 image.
+
+### Custom Postgres configuration
+
+The Postgres 17 image loads any `.conf` files from `/etc/postgresql-custom/conf.d/` on startup. This directory is on the `db-config` named volume, so changes persist across restarts.
+
+
+
+This is a Supabase Postgres 17 image feature. The Postgres 15 image does not load files from `conf.d/`.
+
+
+
+To add custom Postgres settings, create a `.conf` file in the volume. Since `conf.d/` is on a Docker named volume (not a bind mount), you need to write through the container:
+
+```sh
+docker exec supabase-db bash -c 'cat > /etc/postgresql-custom/conf.d/custom.conf << EOF
+max_connections = 200
+EOF'
+```
+
+Restart to apply (`max_connections` requires a full restart):
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml restart db
+```
+
+Verify the new settings:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml exec db psql -U postgres -c "SHOW max_connections;"
+```
+
+### Upgrade process details
+
+The upgrade script delegates the core migration work to two scripts from the [supabase/postgres](https://github.com/supabase/postgres) repository (`ansible/files/admin_api_scripts/pg_upgrade_scripts/`).
+
+**Phase 1 - Migrate data (Postgres 15 container):**
+
+1. Disables extensions that are incompatible with `pg_upgrade` (`pg_graphql`, `pg_stat_monitor`, `pg_backtrace`, `wrappers`, `pgrouting`) and generates SQL to re-enable them after the upgrade
+2. Temporarily grants superuser to the `postgres` role (required by `pg_upgrade`)
+3. Extracts the previously saved Postgres 17 binaries tarball and runs `initdb` to create a new empty database
+4. Runs `pg_upgrade --check` to verify the upgrade can succeed before making changes
+5. Stops Postgres 15 and runs `pg_upgrade` to migrate all data to the new database
+6. Copies Postgres configuration and the SQL scripts generated by `pg_upgrade` to a staging directory for the next phase
+
+**Phase 2 - Finalize (Postgres 17 container):**
+
+1. Moves the upgraded data directory into place and starts Postgres 17
+2. Applies extension compatibility patches for Wrappers, `pg_net`, `pg_cron`, and Vault (fixes ownership, grants, and foreign server options)
+3. Runs the SQL scripts generated by `pg_upgrade` to update system catalogs and extension versions
+4. Re-enables the extensions that were disabled in phase 1
+5. Grants predefined roles (`pg_monitor`, `pg_read_all_data`, `pg_signal_backend`, and on Postgres 16+ also `pg_create_subscription`) and revokes the temporary superuser grant
+6. Restarts Postgres and runs `vacuumdb --all --analyze-in-stages` to rebuild optimizer statistics
+
+After both phases complete, the upgrade script preserves the original Postgres 15 data directory as a backup and starts the full Supabase stack with Postgres 17.
+
+## Troubleshooting
+
+{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
+
+### pg_upgrade fails with replication slot errors
+
+`pg_upgrade` cannot proceed if there are active replication slots. Default self-hosted installs don't have any, but if you set up logical replication or have custom replication configurations, drop the slots before upgrading:
+
+```sh
+docker exec supabase-db psql -h localhost -U supabase_admin -d postgres -c "
+ SELECT pg_drop_replication_slot(slot_name)
+ FROM pg_replication_slots;
+"
+```
+
+Then re-run the upgrade script. Replication slots will need to be manually recreated after the upgrade.
+
+### "Permission denied" on the data directory
+
+The upgrade script fixes file ownership automatically (Postgres 15 and 17 use different UIDs). If you still see permission errors, run:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml run --rm db \
+chown -R postgres:postgres /var/lib/postgresql/data
+```
+
+{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
+
+### pgsodium / Supabase Vault errors
+
+The `db-config` named volume contains the pgsodium root encryption key at `/etc/postgresql-custom/pgsodium_root.key`. This volume is preserved during the upgrade. Never run `docker compose down -v` as this destroys named volumes and makes vault secrets unrecoverable.
+
+### Services fail to connect after upgrade
+
+Restart all services to pick up the new database:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml down && \
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
+```
+
+### Disk space issues during upgrade
+
+The upgrade needs space for:
+
+- The upgrade tarball (~1.2 GB compressed, cached for re-runs)
+- A full copy of your database (created by `pg_upgrade`)
+- The original data (kept as backup)
+
+The script uses `/tmp` (or `TMPDIR` if set) for its staging directory, which holds the downloaded tarball and upgrade scripts. If your `/tmp` filesystem is small or mounted with limited space, you can point it to a different location, e.g.:
+
+```sh
+sudo TMPDIR=/mnt/my-tmp bash utils/upgrade-pg17.sh
+```
+
+If you run out of space mid-upgrade, the safest path is to roll back and free up disk space before retrying.
+
+{/* supa-mdx-lint-disable-next-line Rule003Spelling */}
+
+### Postgres 17 fails to start with a leftover db-config volume
+
+If you are starting a **fresh** Postgres 17 deployment (not upgrading from Postgres 15) and the container fails to start, the most likely cause is a leftover `db-config` volume from a previous Postgres 15 installation. Try to start the containers without the `-d` option and/or check the logs for errors about `postgresql.conf` or other configuration mismatch.
+
+To fix, remove the old volume and let Postgres 17 initialize a clean configuration:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml down && \
+docker volume rm $(docker volume ls --filter "name=db-config" --format '{{.Name}}') && \
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
+```
+
+
+
+Removing the `db-config` volume destroys any custom Postgres configuration and the pgsodium root key. Only do this for fresh installations with no existing data or vault secrets.
+
+
+
+### Restoring from a manual backup
+
+If the upgrade fails and the script's built-in rollback isn't sufficient, restore from the manual backups created in the [Create a backup](#create-a-backup) step:
+
+Restore the data:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.pg17.yml down && \
+rm -rf ./volumes/db/data && \
+cp -a ./volumes/db/data-manual-backup ./volumes/db/data
+```
+
+Restore the pgsodium key to the `db-config` volume:
+
+```sh
+docker compose run --rm db \
+ sh -c 'cat > /etc/postgresql-custom/pgsodium_root.key' < ./pgsodium_root.key.backup && \
+docker compose run --rm db \
+ chown postgres:postgres /etc/postgresql-custom/pgsodium_root.key && \
+docker compose run --rm db \
+ chmod 600 /etc/postgresql-custom/pgsodium_root.key
+```
+
+Start Postgres 15:
+
+```sh
+docker compose up -d
+```
diff --git a/docker/docker-compose.pg17.yml b/docker/docker-compose.pg17.yml
new file mode 100644
index 00000000000..d35f0b91a18
--- /dev/null
+++ b/docker/docker-compose.pg17.yml
@@ -0,0 +1,15 @@
+# Postgres 17 override for self-hosted Supabase.
+#
+# Usage (new install or after upgrade):
+# docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
+#
+# For upgrading an existing Postgres 15 database, run utils/upgrade-pg17.sh first.
+#
+# Note: PG 15 and PG 17 use different postgres UIDs. If starting fresh with a
+# leftover db-config volume from PG 15, you may see
+# "FATAL: invalid secret key". Either remove the old volume or fix ownership.
+# See: docs/guides/self-hosting/postgres-upgrade-17
+
+services:
+ db:
+ image: supabase/postgres:17.6.1.084
diff --git a/docker/tests/test-pg17-upgrade.sh b/docker/tests/test-pg17-upgrade.sh
new file mode 100644
index 00000000000..0015b868ff5
--- /dev/null
+++ b/docker/tests/test-pg17-upgrade.sh
@@ -0,0 +1,239 @@
+#!/bin/sh
+#
+# Test Postgres 15 -> 17 upgrade for self-hosted Supabase.
+#
+# Seeds test data on a running Postgres 15 stack, runs the upgrade script,
+# and verifies data integrity + service connectivity using pgTAP.
+#
+# Usage:
+# cd docker/
+# sudo bash tests/test-pg17-upgrade.sh
+#
+# Prerequisites:
+# - Running self-hosted Supabase with a clean, tests-only Postgres 15:
+# docker compose up -d
+# - .env file with POSTGRES_PASSWORD, ANON_KEY
+#
+
+set -eu
+
+DB_CONTAINER="supabase-db"
+
+if [ ! -f .env ]; then
+ echo "Error: .env file not found. Run from the docker/ directory."
+ exit 1
+fi
+
+pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2-)
+anon_key=$(grep '^ANON_KEY=' .env | cut -d '=' -f 2- || true)
+
+if [ -z "$pg_password" ]; then
+ echo "Error: POSTGRES_PASSWORD not set in .env"
+ exit 1
+fi
+
+run_sql() {
+ docker exec -i \
+ -e PGPASSWORD="$pg_password" \
+ "$DB_CONTAINER" \
+ psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@"
+}
+
+echo ""
+echo "=== Postgres 15 -> 17 Upgrade Test ==="
+echo ""
+
+# --- Verify we're starting from Postgres 15 --------------------------------
+
+current_version=$(run_sql -A -t -c "SHOW server_version;" | head -1)
+case "$current_version" in
+ 15.*) echo "Starting version: PostgreSQL $current_version" ;;
+ 17.*) echo "Error: Already on Postgres 17. Start with a PG15 stack."; exit 1 ;;
+ *) echo "Error: Unexpected version: $current_version"; exit 1 ;;
+esac
+
+# --- Seed test data --------------------------------------------------------
+# Note: this script is designed to run against a fresh docker-compose stack,
+# not an existing database with user data.
+
+echo ""
+echo "Seeding test data on Postgres 15..."
+
+run_sql <<'EOSQL'
+-- Test table with various column types
+CREATE TABLE IF NOT EXISTS public._upgrade_test (
+ id serial PRIMARY KEY,
+ name text NOT NULL,
+ value numeric(10,2),
+ created_at timestamptz DEFAULT now(),
+ metadata jsonb
+);
+
+TRUNCATE public._upgrade_test;
+INSERT INTO public._upgrade_test (name, value, metadata) VALUES
+ ('alpha', 1.50, '{"tag": "a"}'),
+ ('bravo', 2.75, '{"tag": "b"}'),
+ ('charlie', 3.00, '{"tag": "c"}'),
+ ('delta', 4.25, '{"tag": "d"}'),
+ ('echo', 5.99, '{"tag": "e"}');
+
+-- Index
+CREATE INDEX IF NOT EXISTS _upgrade_test_name_idx ON public._upgrade_test (name);
+
+-- Function
+CREATE OR REPLACE FUNCTION public._upgrade_test_fn(n int)
+RETURNS int LANGUAGE sql IMMUTABLE AS $$
+ SELECT n * 2;
+$$;
+
+-- Grant access so PostgREST can read it
+GRANT SELECT ON public._upgrade_test TO anon, authenticated;
+EOSQL
+
+pre_count=$(run_sql -A -t -c "SELECT count(*) FROM public._upgrade_test;" | tr -d '[:space:]')
+pre_checksum=$(run_sql -A -t -c "SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test;" | tr -d '[:space:]')
+
+echo " Rows: $pre_count"
+echo " Checksum: $pre_checksum"
+
+# --- Run upgrade -----------------------------------------------------------
+
+echo ""
+echo "Running upgrade script..."
+echo ""
+
+bash utils/upgrade-pg17.sh --yes
+
+echo ""
+
+# --- Verify with pgTAP ----------------------------------------------------
+
+echo "Running pgTAP verification..."
+echo ""
+
+# Use a non-quoted heredoc so $pre_count and $pre_checksum are interpolated
+run_sql </dev/null) || rest_status="000"
+ check "PostgREST connectivity" "200" "$rest_status"
+fi
+
+# Auth health (needs apikey header through Kong)
+if [ -n "$anon_key" ]; then
+ auth_status=$(curl -s -o /dev/null -w "%{http_code}" \
+ -H "apikey: $anon_key" \
+ "http://localhost:8000/auth/v1/health" 2>/dev/null) || auth_status="000"
+ check "Auth service health" "200" "$auth_status"
+fi
+
+echo ""
+echo " Services: $pass passed, $fail failed"
+
+# --- Clean up test artifacts ----------------------------------------------
+
+echo ""
+echo "Cleaning up test artifacts..."
+
+run_sql <<'EOSQL' || true
+DROP FUNCTION IF EXISTS public._upgrade_test_fn(int);
+DROP TABLE IF EXISTS public._upgrade_test;
+DROP EXTENSION IF EXISTS pgtap;
+EOSQL
+
+# --- Summary --------------------------------------------------------------
+
+echo ""
+if [ "$fail" -gt 0 ]; then
+ echo "=== SOME TESTS FAILED ==="
+ exit 1
+fi
+
+echo "=== Upgrade test passed ==="
+echo ""
+echo "To reclaim disk space:"
+echo " rm -rf ./volumes/db/data.bak.pg15 ./volumes/db/pg17_upgrade_bin_*.tar.gz"
+echo ""
diff --git a/docker/utils/upgrade-pg17.sh b/docker/utils/upgrade-pg17.sh
new file mode 100644
index 00000000000..a76da889959
--- /dev/null
+++ b/docker/utils/upgrade-pg17.sh
@@ -0,0 +1,731 @@
+#!/usr/bin/env bash
+#
+# Requires bash (not sh) for pipefail, which ensures failures in piped
+# commands are caught during the upgrade.
+#
+# Upgrade self-hosted Supabase Postgres from 15 to 17.
+#
+# Uses Supabase's pg_upgrade scripts (initiate.sh + complete.sh) inside a
+# temporary PG15 container, then swaps data directories and starts Postgres 17.
+#
+# Usage (must be run as root or with sudo):
+# cd docker/
+# sudo bash utils/upgrade-pg17.sh # Interactive (prompts for confirmation)
+# sudo bash utils/upgrade-pg17.sh --yes # Non-interactive (skip all prompts)
+#
+# Requirements:
+# - Docker with Docker Compose (docker compose, not docker-compose)
+# - Running Supabase self-hosted setup with Postgres 15
+# - At least 2x current database size + 5 GB free disk space
+#
+# Backup:
+# The original Postgres 15 data directory is preserved as
+# ./volumes/db/data.bak.pg15 during the upgrade.
+# DO NOT DELETE it until you have verified the upgrade was successful.
+#
+# Rollback (if the upgrade fails or you want to revert):
+# 1. docker compose down
+# 2. rm -rf ./volumes/db/data
+# 3. mv ./volumes/db/data.bak.pg15 ./volumes/db/data
+# 4. docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/
+# 5. docker compose up -d
+#
+
+# Ensure we're running under bash (not sh/zsh/dash).
+# Check that $BASH ends with /bash (not /sh, /zsh, etc.).
+case "${BASH:-}" in
+ */bash) ;;
+ *) echo "Error: This script requires bash. Run it with: sudo bash $0" >&2; exit 1 ;;
+esac
+
+set -euo pipefail
+
+AUTO_CONFIRM=false
+for arg in "$@"; do
+ case "$arg" in
+ --yes|-y) AUTO_CONFIRM=true ;;
+ esac
+done
+
+# --- Configuration ----------------------------------------------------------
+
+# Image used for the upgrade tarball + complete.sh container.
+# Must share glibc with PG15 (the extracted ELF binaries run inside PG15).
+PG17_UPGRADE_IMAGE="supabase/postgres:17.6.1.063"
+# Tag in supabase/postgres repo matching the upgrade image (for downloading scripts)
+PG17_SCRIPTS_REF="17.6.1.063"
+DB_CONTAINER="supabase-db"
+UPGRADE_CONTAINER="supabase-pg-upgrade"
+COMPLETE_CONTAINER="supabase-pg-complete"
+
+DATA_DIR="./volumes/db/data"
+BACKUP_DIR="./volumes/db/data.bak.pg15"
+# Include image tag in cache filename so changing PG17_UPGRADE_IMAGE invalidates it
+PG17_TAG="${PG17_UPGRADE_IMAGE##*:}"
+TARBALL_CACHE="./volumes/db/pg17_upgrade_bin_${PG17_TAG}.tar.gz"
+# initiate.sh writes pg_upgrade output here: pgdata/, conf/, sql/
+MIGRATION_DIR="./volumes/db/data_migration"
+
+# --- Helpers ----------------------------------------------------------------
+
+die() { printf 'Error: %s\n' "$*" >&2; exit 1; }
+info() { printf '\n==> %s\n' "$*"; }
+warn() { printf 'Warning: %s\n' "$*" >&2; }
+
+# Temp dir on host for tarball + scripts (mounted into containers)
+staging_dir=""
+pg_password=""
+current_image=""
+drop_extensions=""
+db_config_vol=""
+
+# Remove leftover containers and staging dir on exit.
+# Uses an alpine container for rm because the tarball build runs as root
+# inside Docker - the resulting files are root-owned and can't be deleted
+# by the host user on macOS.
+cleanup() {
+ docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
+ docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
+ if [ -n "$staging_dir" ] && [ -d "$staging_dir" ]; then
+ docker run --rm -v "$staging_dir:/cleanup" alpine rm -rf /cleanup 2>/dev/null || true
+ rm -rf "$staging_dir" 2>/dev/null || true
+ fi
+}
+trap cleanup EXIT
+
+on_interrupt() {
+ echo ""
+ warn "Interrupted. Cleaning up..."
+ # If db-config was chowned to PG17, restore for PG15 rollback
+ if [ -n "$db_config_vol" ] && [ -n "$current_image" ]; then
+ docker run --rm -v "${db_config_vol}:/vol" "$current_image" \
+ chown -R postgres:postgres /vol/ 2>/dev/null || true
+ fi
+ die "Interrupted."
+}
+trap on_interrupt INT
+
+confirm() {
+ if [ "$AUTO_CONFIRM" = true ]; then return 0; fi
+ if ! test -t 0; then
+ die "This script must be run interactively, or use --yes to skip prompts."
+ fi
+ printf '%s (y/N) ' "$1"
+ read -r reply
+ case "$reply" in
+ [Yy]*) return 0 ;;
+ *) echo "Aborted."; exit 0 ;;
+ esac
+}
+
+run_sql_on() {
+ local container=$1; shift
+ docker exec -i \
+ -e PGPASSWORD="$pg_password" \
+ "$container" \
+ psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@"
+}
+
+wait_for_healthy() {
+ local container=$1 retries=30
+ while [ $retries -gt 0 ]; do
+ if docker exec "$container" pg_isready -U postgres -h localhost >/dev/null 2>&1; then
+ return 0
+ fi
+ retries=$((retries - 1))
+ sleep 1
+ done
+ die "Postgres in '$container' did not become ready in 30 seconds."
+}
+
+# --- Pre-flight checks -----------------------------------------------------
+
+preflight() {
+ info "Running pre-flight checks"
+
+ if [ "$(id -u)" -ne 0 ]; then
+ die "This script must be run as root (e.g. sudo bash $0)."
+ fi
+
+ docker compose version >/dev/null 2>&1 || die "Docker Compose not found."
+ command -v curl >/dev/null 2>&1 || die "curl is required (for downloading upgrade scripts)."
+ [ -f docker-compose.yml ] || die "Run this script from the docker/ directory."
+ [ -f docker-compose.pg17.yml ] || die "Missing docker-compose.pg17.yml."
+ [ -f .env ] || die "Missing .env file."
+
+ # Resolve db-config volume (exact match on _db-config suffix or bare db-config)
+ db_config_vol=$(docker volume ls --filter "name=db-config" --format '{{.Name}}' \
+ | grep -E '^db-config$|_db-config$' | head -n 1)
+ [ -n "$db_config_vol" ] || die "Could not find db-config volume. Is Supabase running?"
+
+ # Read the target PG17 image from the compose overlay (what the user will run)
+ PG17_TARGET_IMAGE=$(grep 'image:.*postgres' docker-compose.pg17.yml | awk '{print $2}' | head -n 1)
+ [ -n "$PG17_TARGET_IMAGE" ] || die "Could not read image from docker-compose.pg17.yml."
+
+ pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2- | sed "s/^['\"]//;s/['\"]$//" | head -n 1)
+ [ -n "$pg_password" ] || die "POSTGRES_PASSWORD not set in .env."
+
+ docker inspect "$DB_CONTAINER" >/dev/null 2>&1 \
+ || die "Container '$DB_CONTAINER' not found. Is Supabase running?"
+
+ current_image=$(docker inspect "$DB_CONTAINER" --format '{{.Config.Image}}')
+ case "$current_image" in
+ supabase/postgres:15.*|supabase.postgres:15.*) ;;
+ supabase/postgres:17.*|supabase.postgres:17.*) die "Already running Postgres 17 ($current_image)." ;;
+ *) die "Unexpected database image: $current_image" ;;
+ esac
+
+ local status
+ status=$(docker inspect "$DB_CONTAINER" --format '{{.State.Status}}')
+ [ "$status" = "running" ] || die "'$DB_CONTAINER' is not running (status: $status)."
+ [ -d "$DATA_DIR" ] || die "Data directory not found: $DATA_DIR"
+
+ if [ -d "$BACKUP_DIR" ]; then
+ warn "Backup directory already exists: $BACKUP_DIR"
+ warn "This is likely from a previous upgrade attempt."
+ warn "If you haven't verified that previous upgrade, roll back first:"
+ warn " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down"
+ warn " 2. rm -rf $DATA_DIR"
+ warn " 3. mv $BACKUP_DIR $DATA_DIR"
+ warn " 4. docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/"
+ warn " 5. docker compose up -d"
+ echo ""
+ warn "Continuing will DELETE the existing backup permanently."
+ confirm "Delete $BACKUP_DIR and start a fresh upgrade?"
+ rm -rf "$BACKUP_DIR"
+ fi
+ if [ -d "$MIGRATION_DIR" ]; then
+ rm -rf "$MIGRATION_DIR"
+ fi
+
+ # Disk space
+ local data_size_kb data_size_mb avail_kb avail_mb needed_mb
+ data_size_kb=$(du -sk "$DATA_DIR" 2>/dev/null | cut -f1)
+ [ -n "$data_size_kb" ] || die "Could not calculate data size for $DATA_DIR"
+ data_size_mb=$((data_size_kb / 1024))
+ avail_kb=$(df -k "$(dirname "$DATA_DIR")" | awk 'NR==2 { print $4 }')
+ [ -n "$avail_kb" ] || die "Could not calculate available disk space for $(dirname "$DATA_DIR")"
+ avail_mb=$((avail_kb / 1024))
+ needed_mb=$((data_size_mb * 2 + 5000))
+ echo " Data size: ${data_size_mb} MB"
+ echo " Available space: ${avail_mb} MB"
+ echo " Estimated need: ${needed_mb} MB"
+ if [ "$avail_mb" -lt "$needed_mb" ]; then
+ warn "Disk space may be insufficient."
+ warn "pg_upgrade copies data; need ~2x data size + ~5 GB for the upgrade tarball."
+ confirm "Continue anyway?"
+ fi
+
+ # Incompatible extensions
+ info "Checking for incompatible extensions"
+ local incompatible
+ incompatible=$(run_sql_on "$DB_CONTAINER" -A -t -c "
+ SELECT string_agg(extname, ', ')
+ FROM pg_extension
+ WHERE extname IN ('timescaledb', 'plv8', 'plcoffee', 'plls');
+ " 2>/dev/null | tr -d '[:space:]') || true
+
+ if [ -n "$incompatible" ]; then
+ warn "Incompatible extensions found: $incompatible"
+ warn "These do not exist in Postgres 17 and must be dropped before upgrading."
+ warn "If you proceed, they will be dropped automatically."
+ warn "The original data is preserved as a backup so you can roll back."
+ confirm "Drop these extensions and continue with the upgrade?"
+ drop_extensions="$incompatible"
+ fi
+
+ echo ""
+ echo "This script will:"
+ echo " 1. Pull the Postgres 17 image"
+ echo " 2. Build an upgrade tarball from the image (~1.2 GB compressed, temporary)"
+ echo " 3. Stop all Supabase services"
+ echo " 4. Run pg_upgrade (Postgres 15 -> 17)"
+ echo " 5. Apply post-upgrade patches"
+ echo " 6. Start Supabase with Postgres 17"
+ echo " 7. Apply additional migrations"
+ echo ""
+ echo " Current image: $current_image"
+ echo " Target image: $PG17_TARGET_IMAGE"
+ echo " Upgrade image: $PG17_UPGRADE_IMAGE"
+ echo " Data directory: $DATA_DIR"
+ echo " Backup location: $BACKUP_DIR"
+ echo ""
+ confirm "Proceed with the upgrade?"
+}
+
+# --- Step 1: Pull Postgres 17 image ----------------------------------------
+
+pull_image() {
+ info "Pulling Postgres 17 images"
+ docker pull "$PG17_UPGRADE_IMAGE"
+ if [ "$PG17_TARGET_IMAGE" != "$PG17_UPGRADE_IMAGE" ]; then
+ docker pull "$PG17_TARGET_IMAGE"
+ fi
+}
+
+# --- Step 2: Build upgrade tarball -----------------------------------------
+#
+# Extracts PG17 binaries, libraries, share data, and upgrade scripts from
+# the PG17 Docker image into a tarball that initiate.sh can consume.
+#
+# The tarball uses the "non-nix" layout (17/bin, 17/lib, 17/share - no
+# nix_flake_version file), so initiate.sh sets LD_LIBRARY_PATH to find
+# the bundled libraries.
+
+build_tarball() {
+ local tmpbase="${TMPDIR:-/tmp}"
+ staging_dir=$(mktemp -d "${tmpbase%/}/supabase-pg17-upgrade.XXXXXX")
+ # World-writable so Docker containers can write to bind mounts on macOS,
+ # where the VM's root user has no special access to host directories.
+ chmod 777 "$staging_dir"
+ echo " Staging directory: $staging_dir"
+
+ # Download upgrade scripts from the supabase/postgres repo (pinned to PG17_SCRIPTS_REF).
+ # These are no longer bundled in the latest PG17 Docker images.
+ info "Downloading upgrade scripts (ref: $PG17_SCRIPTS_REF)"
+ local scripts_base="https://raw.githubusercontent.com/supabase/postgres/${PG17_SCRIPTS_REF}/ansible/files/admin_api_scripts/pg_upgrade_scripts"
+ mkdir -p "$staging_dir/scripts"
+ for script in initiate.sh complete.sh common.sh pgsodium_getkey.sh check.sh prepare.sh; do
+ curl -fsSL "$scripts_base/$script" -o "$staging_dir/scripts/$script" \
+ || die "Failed to download $script from GitHub"
+ done
+
+ if [ -f "$TARBALL_CACHE" ]; then
+ info "Using cached upgrade tarball: $TARBALL_CACHE"
+ cp "$TARBALL_CACHE" "$staging_dir/pg_upgrade_bin.tar.gz"
+ return
+ fi
+
+ info "Building upgrade tarball from Postgres 17 image (first run)"
+ docker run --rm --user root --entrypoint bash \
+ -v "$staging_dir:/export" \
+ "$PG17_UPGRADE_IMAGE" \
+ -c '
+ set -euo pipefail
+ mkdir -p /export/17/bin /export/17/lib /export/17/share
+
+ echo " Copying binaries..."
+ # Binaries in the nix profile are either ELF binaries or shell
+ # wrappers that exec a .xxx-wrapped ELF from the nix store.
+ # Extract the actual ELF binaries so they work outside nix.
+ BIN_DIR=$(dirname $(readlink -f /usr/lib/postgresql/bin/postgres))
+ for f in "$BIN_DIR"/*; do
+ name=$(basename "$f")
+
+ # Skip nix wrapper-internal files
+ case "$name" in .*-wrapped) continue ;; esac
+
+ # Check for ELF
+ if [ -x "$f" ] && file -b "$f" | grep -q "ELF .* executable"; then
+ cp "$f" /export/17/bin/"$name"
+ else
+ # Shell wrapper - extract the real .xxx-wrapped ELF path
+ wrapped=$(grep -o "/nix/store/[^ \"]*-wrapped" "$f" 2>/dev/null | head -n 1 || true)
+ if [ -n "$wrapped" ] && [ -f "$wrapped" ]; then
+ cp "$wrapped" /export/17/bin/"$name"
+ else
+ cp "$f" /export/17/bin/"$name"
+ fi
+ fi
+ done
+
+ echo " Copying libraries..."
+ PKGLIBDIR=$(pg_config --pkglibdir)
+ LIBDIR=$(pg_config --libdir)
+ cp -L "$PKGLIBDIR"/*.so /export/17/lib/ 2>/dev/null || true
+ cp -L "$LIBDIR"/*.so* /export/17/lib/ 2>/dev/null || true
+ cp -L /nix/var/nix/profiles/default/lib/*.so* /export/17/lib/ 2>/dev/null || true
+
+ echo " Copying share data..."
+ # Nix-built binaries resolve share dir relative to their location:
+ # /../share/postgresql/
+ # so we need share/postgresql/ not just share/
+ mkdir -p /export/17/share/postgresql
+ cp -rL /usr/share/postgresql/* /export/17/share/postgresql/ 2>/dev/null || true
+
+ # initiate.sh copies .control/.sql from PGLIBNEW to PGSHARENEW/extension/
+ echo " Copying extension definitions to lib..."
+ SHAREDIR=$(pg_config --sharedir)
+ cp "$SHAREDIR"/extension/*.control /export/17/lib/ 2>/dev/null || true
+ cp "$SHAREDIR"/extension/*.sql /export/17/lib/ 2>/dev/null || true
+
+ echo " Creating tarball (this may take several minutes)..."
+ cd /export && tar czf pg_upgrade_bin.tar.gz 17/
+
+ echo " Tarball: $(du -sh /export/pg_upgrade_bin.tar.gz | cut -f1)"
+ '
+
+ # Cache for next run
+ cp "$staging_dir/pg_upgrade_bin.tar.gz" "$TARBALL_CACHE"
+ info "Tarball cached at $TARBALL_CACHE"
+}
+
+# --- Step 3: Drop incompatible extensions ----------------------------------
+
+drop_incompatible_extensions() {
+ if [ -z "$drop_extensions" ]; then
+ return
+ fi
+ info "Dropping incompatible extensions"
+
+ local ext
+ echo "$drop_extensions" | tr ',' '\n' | while read -r ext; do
+ ext=$(echo "$ext" | tr -d '[:space:]')
+ [ -z "$ext" ] && continue
+ echo " DROP EXTENSION $ext CASCADE"
+ run_sql_on "$DB_CONTAINER" -c "DROP EXTENSION IF EXISTS \"$ext\" CASCADE;"
+ done
+}
+
+# --- Step 4: Stop services and back up -------------------------------------
+
+stop_and_backup() {
+ info "Backing up pgsodium root key"
+ local key_backup="./volumes/db/pgsodium_root.key.bak.pg15"
+ docker run --rm -v "${db_config_vol}:/src:ro" -v "$(pwd)/volumes/db:/dst" \
+ alpine cp /src/pgsodium_root.key /dst/pgsodium_root.key.bak.pg15 \
+ || die "Failed to back up pgsodium root key from db-config volume."
+ echo " Saved to: $key_backup"
+
+ info "Stopping all Supabase services"
+ docker compose down
+
+ echo " Original data will be preserved as: $BACKUP_DIR"
+}
+
+# --- Step 5: Run pg_upgrade via initiate.sh + complete.sh ------------------
+#
+# Host directories are mounted at non-standard paths (/mnt/host-*) with
+# symlinks at the paths the upgrade scripts expect. This lets complete.sh's
+# CI wrapper (which does rm/mv/ln on /var/lib/postgresql/data and
+# /data_migration) operate on symlinks rather than bind mounts.
+
+run_upgrade() {
+ local abs_data_dir abs_migration_dir
+
+ mkdir -p "$MIGRATION_DIR"
+ # World-writable for macOS Docker bind mount compatibility (see build_tarball)
+ chmod 777 "$MIGRATION_DIR"
+ abs_data_dir=$(cd "$DATA_DIR" && pwd)
+ abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd)
+
+ info "Starting upgrade container"
+ docker run -d --name "$UPGRADE_CONTAINER" \
+ --entrypoint sleep \
+ -v "${abs_data_dir}:/mnt/host-pgdata" \
+ -v "${abs_migration_dir}:/mnt/host-migration" \
+ -v "${db_config_vol}:/etc/postgresql-custom" \
+ -v "${staging_dir}:/tmp/staging:ro" \
+ -e PGPASSWORD="$pg_password" \
+ "$current_image" \
+ infinity
+
+ info "Preparing upgrade environment"
+ docker exec "$UPGRADE_CONTAINER" bash -c '
+ # Symlink bind mounts to the paths the upgrade scripts expect
+ rm -rf /var/lib/postgresql/data
+ ln -s /mnt/host-pgdata /var/lib/postgresql/data
+ ln -s /mnt/host-migration /data_migration
+
+ mkdir -p /tmp/persistent /tmp/upgrade /tmp/pg_upgrade
+ cp /tmp/staging/pg_upgrade_bin.tar.gz /tmp/persistent/
+ cp /tmp/staging/scripts/*.sh /tmp/upgrade/
+ chmod +x /tmp/upgrade/*.sh
+
+ # Patch CI_start_postgres to use "restart" instead of "start" so it
+ # is idempotent (initiate.sh starts postgres for top-level queries,
+ # then handle_extensions calls CI_start_postgres again)
+ sed -i "s/pg_ctl start -o/pg_ctl restart -o/g" /tmp/upgrade/common.sh
+
+ # Patch PGSHARENEW to match nix binary expectations (share/postgresql/)
+ sed -i "s|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share\"|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share/postgresql\"|" /tmp/upgrade/initiate.sh
+ '
+
+ info "Starting Postgres 15 in upgrade container"
+ docker exec "$UPGRADE_CONTAINER" bash -c '
+ su postgres -c "pg_ctl start -o \"-c config_file=/etc/postgresql/postgresql.conf\" -l /tmp/postgres.log"
+ '
+ wait_for_healthy "$UPGRADE_CONTAINER"
+
+ # initiate.sh expects the PG17 binaries tarball at /tmp/persistent/pg_upgrade_bin.tar.gz
+ # (hardcoded path - copied there during container setup above).
+ #
+ # Env vars for the unwrapped nix ELF binaries in the tarball:
+ # LD_LIBRARY_PATH - find libpq, libssl, etc. (RUNPATH points to absent nix store paths)
+ # NIX_PGLIBDIR - postgres uses this to find extension .so files
+
+ info "Running initiate.sh (pg_upgrade: Postgres 15 -> 17)"
+ echo " This may take several minutes depending on database size..."
+ echo ""
+ if ! docker exec \
+ -e IS_CI=true \
+ -e PG_MAJOR_VERSION=17 \
+ -e PGPASSWORD="$pg_password" \
+ -e LD_LIBRARY_PATH=/tmp/pg_upgrade_bin/17/lib \
+ -e NIX_PGLIBDIR=/tmp/pg_upgrade_bin/17/lib \
+ "$UPGRADE_CONTAINER" \
+ /tmp/upgrade/initiate.sh 17; then
+ echo ""
+ warn "initiate.sh failed. Its cleanup may have restored the original state"
+ warn "(re-enabled extensions, revoked superuser). Your data directory is"
+ warn "unchanged - no data was moved or deleted."
+ warn ""
+ warn "Check the output above for the root cause, fix it, and re-run."
+ docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
+ die "initiate.sh failed"
+ fi
+
+ info "initiate.sh completed successfully"
+ docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
+}
+
+# --- Step 6: Run complete.sh in a native PG17 container -------------------
+#
+# complete.sh applies post-upgrade patches (pg_net grants, vault re-encryption,
+# pg_cron, predefined roles, vacuumdb, etc.). We run it in a PG17 container
+# where the binaries are native - no nix extraction or LD_LIBRARY_PATH needed.
+
+run_complete() {
+ local abs_migration_dir
+
+ abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd)
+
+ info "Starting PG17 container for complete.sh"
+ docker run -d --name "$COMPLETE_CONTAINER" \
+ --entrypoint sleep \
+ -v "${abs_migration_dir}:/mnt/host-migration" \
+ -v "${db_config_vol}:/etc/postgresql-custom" \
+ -v "${staging_dir}:/tmp/staging:ro" \
+ -e PGPASSWORD="$pg_password" \
+ "$PG17_UPGRADE_IMAGE" \
+ infinity
+
+ info "Preparing complete.sh environment"
+ # Save original db-config ownership so we can restore it if complete.sh fails.
+ # complete.sh needs PG17 ownership to start postgres, but if it fails the
+ # user needs to fall back to PG15 which uses a different uid.
+ docker exec "$COMPLETE_CONTAINER" bash -c '
+ stat -c "%u:%g" /etc/postgresql-custom/pgsodium_root.key 2>/dev/null > /tmp/dbconfig_owner || true
+ '
+
+ docker exec "$COMPLETE_CONTAINER" bash -c '
+ # Symlink bind mount so complete.sh CI wrapper can mv/rm/ln
+ ln -s /mnt/host-migration /data_migration
+
+ # Remove the image default data dir (complete.sh creates a symlink here)
+ rm -rf /var/lib/postgresql/data
+
+ # Fix ownership on db-config volume (PG15 uid differs from PG17)
+ chown -R postgres:postgres /etc/postgresql-custom/
+
+ # PG17 config includes this directory; may not exist from PG15
+ mkdir -p /etc/postgresql-custom/conf.d
+
+ mkdir -p /tmp/upgrade
+
+ # Copy upgrade scripts
+ cp /tmp/staging/scripts/*.sh /tmp/upgrade/
+ chmod +x /tmp/upgrade/*.sh
+
+ # Patch --new-bin to use native bindir (we are in a PG17 container,
+ # no need for /tmp/pg_upgrade_bin/ paths)
+ sed -i "s|BINDIR=\"/tmp/pg_upgrade_bin/\$PG_MAJOR_VERSION/bin\"|BINDIR=\$(pg_config --bindir)|g" /tmp/upgrade/common.sh
+ '
+
+ info "Running complete.sh (post-upgrade patches, vacuum analyze)"
+ docker exec \
+ -e IS_CI=true \
+ -e PG_MAJOR_VERSION=17 \
+ -e PGPASSWORD="$pg_password" \
+ "$COMPLETE_CONTAINER" \
+ /tmp/upgrade/complete.sh || true
+
+ # complete.sh's ERR trap exits with 0 in some cases; check status file
+ local status
+ status=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/pg-upgrade-status 2>/dev/null || echo "unknown")
+ if [ "$status" != "complete" ]; then
+ warn "complete.sh failed. Postgres log:"
+ docker exec "$COMPLETE_CONTAINER" cat /tmp/postgres.log 2>/dev/null || true
+ echo ""
+ # Restore db-config ownership so PG15 can start for rollback
+ warn "Restoring db-config ownership for PG15..."
+ local orig_owner
+ orig_owner=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/dbconfig_owner 2>/dev/null || true)
+ if [ -n "$orig_owner" ]; then
+ docker exec "$COMPLETE_CONTAINER" chown -R "$orig_owner" /etc/postgresql-custom/ 2>/dev/null || true
+ fi
+ docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
+ echo ""
+ echo " Your Postgres 15 data is unchanged (data swap has not happened yet)."
+ echo " To restart Postgres 15:"
+ echo " rm -rf $MIGRATION_DIR"
+ echo " docker compose up -d"
+ echo ""
+ die "complete.sh failed (status: $status)"
+ fi
+
+ info "complete.sh finished successfully"
+ docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
+}
+
+# --- Step 7: Swap data directories -----------------------------------------
+
+swap_data() {
+ info "Swapping data directories"
+
+ echo " $DATA_DIR -> $BACKUP_DIR"
+ mv "$DATA_DIR" "$BACKUP_DIR"
+
+ echo " $MIGRATION_DIR/pgdata -> $DATA_DIR"
+ mv "$MIGRATION_DIR/pgdata" "$DATA_DIR"
+ rm -rf "$MIGRATION_DIR"
+}
+
+# --- Step 8: Start Postgres 17 ---------------------------------------------
+
+start_pg17() {
+ info "Starting Supabase with Postgres 17"
+
+ # Ensure db-config volume has correct ownership and structure for PG17.
+ # complete.sh does this too, but just in case of partial
+ # failures from previous runs.
+ docker run --rm -v "${db_config_vol}:/vol" "$PG17_TARGET_IMAGE" sh -c '
+ mkdir -p /vol/conf.d
+ chown -R postgres:postgres /vol/
+ '
+
+ docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
+
+ echo " Waiting for Postgres 17 to be ready..."
+ local retries=60
+ while [ $retries -gt 0 ]; do
+ if docker exec "$DB_CONTAINER" pg_isready -U postgres -h localhost >/dev/null 2>&1; then
+ break
+ fi
+ retries=$((retries - 1))
+ sleep 2
+ done
+ [ $retries -gt 0 ] || die "Postgres 17 did not start within 120 seconds."
+
+ local new_version
+ new_version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SHOW server_version;" 2>/dev/null | head -n 1)
+ echo " Postgres version: $new_version"
+ case "$new_version" in
+ 17.*) ;;
+ *) die "Expected Postgres 17.x, got: $new_version" ;;
+ esac
+}
+
+# --- Step 9: Apply migrations not covered by complete.sh -------------------
+#
+# These PG17 migrations run on fresh installs via initdb but not after
+# pg_upgrade (init scripts don't rerun when PG_VERSION already exists).
+# complete.sh doesn't cover them either.
+#
+# Source: postgres/migrations/db/migrations/
+# - 20250710151649_supabase_read_only_user_default_transaction_read_only.sql
+# - 20251001204436_predefined_role_grants.sql (supabase_etl_admin + pg_monitor)
+# - 20251105172723_grant_pg_reload_conf_to_postgres.sql
+# - 20251121132723_correct_search_path_pgbouncer.sql
+
+apply_role_migrations() {
+ info "Applying Postgres 17 migrations"
+
+ # Fix collation version mismatch first (upgrade used glibc 2.39, target
+ # image may use glibc 2.40). Do this before any other SQL to suppress
+ # the noisy warnings on every subsequent command.
+ for db in postgres template1 _supabase; do
+ docker exec -i -e PGPASSWORD="$pg_password" "$DB_CONTAINER" \
+ psql -h localhost -U supabase_admin -d "$db" \
+ -c "ALTER DATABASE \"$db\" REFRESH COLLATION VERSION;" || true
+ done
+
+ # Create supabase_etl_admin role (doesn't exist in PG15 images).
+ # Must be created before running predefined_role_grants.sql which
+ # assumes it exists.
+ run_sql_on "$DB_CONTAINER" -c "
+ DO \$\$
+ BEGIN
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_etl_admin') THEN
+ CREATE USER supabase_etl_admin WITH LOGIN REPLICATION;
+ GRANT pg_read_all_data TO supabase_etl_admin;
+ GRANT CREATE ON DATABASE postgres TO supabase_etl_admin;
+ END IF;
+ END
+ \$\$;" || true
+
+ # Run the migration files directly from the PG17 container image.
+ # They're idempotent (IF EXISTS / IF NOT EXISTS guards).
+ local migration_dir="/docker-entrypoint-initdb.d/migrations"
+ local migrations="
+ 20250710151649_supabase_read_only_user_default_transaction_read_only.sql
+ 20251001204436_predefined_role_grants.sql
+ 20251105172723_grant_pg_reload_conf_to_postgres.sql
+ 20251121132723_correct_search_path_pgbouncer.sql
+ "
+
+ for m in $migrations; do
+ echo " Running: $m"
+ docker exec -i \
+ -e PGPASSWORD="$pg_password" \
+ "$DB_CONTAINER" \
+ psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 \
+ -f "${migration_dir}/${m}" || warn " $m failed (non-fatal)"
+ done
+}
+
+# --- Step 10: Verify ------------------------------------------------------
+
+verify() {
+ info "Verification"
+
+ local version
+ version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SELECT version();" 2>/dev/null | head -n 1)
+ echo " $version"
+
+ echo ""
+ echo " Extensions:"
+ run_sql_on "$DB_CONTAINER" -c \
+ "SELECT extname, extversion FROM pg_extension ORDER BY extname;"
+
+ echo ""
+ info "Upgrade complete!"
+ echo ""
+ echo " To use Postgres 17 going forward, always include the override:"
+ echo " docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d"
+ echo ""
+ echo " Postgres 15 backup: $BACKUP_DIR"
+ echo " pgsodium key backup: ./volumes/db/pgsodium_root.key.bak.pg15"
+ echo " Once satisfied, you can reclaim space:"
+ echo " rm -rf $BACKUP_DIR ./volumes/db/pg17_upgrade_bin_*.tar.gz"
+ echo ""
+ echo " Rollback (if needed):"
+ echo " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down"
+ echo " 2. rm -rf $DATA_DIR"
+ echo " 3. mv $BACKUP_DIR $DATA_DIR"
+ echo " 4. docker compose run --rm db chown -R postgres:postgres /etc/postgresql-custom/"
+ echo " 5. docker compose up -d"
+ echo ""
+}
+
+# --- Main -------------------------------------------------------------------
+
+main() {
+ echo ""
+ echo "Supabase Self-Hosted: Postgres 15 -> 17 Upgrade"
+ echo "================================================"
+
+ preflight
+ pull_image
+ build_tarball
+ drop_incompatible_extensions
+ stop_and_backup
+ run_upgrade
+ run_complete
+ swap_data
+ start_pg17
+ apply_role_migrations
+ verify
+}
+
+main "$@"