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 "$@"