Why migrate now — PG 15 EOL and the Supabase signal
PostgreSQL 15 officially reaches end of life in November 2026. After that date, the PostgreSQL Global Development Group will no longer publish security fixes or bug patches: any vulnerability discovered after the end-of-life date remains unpatched on your version.
But migration can no longer wait until November for a more immediate reason: Supabase switched its reference image to postgres:17 in June 2026 (public changelog discussion #46080). Stacks that referenced image: supabase/postgres without a version tag received PG 17 on the next docker compose pull, with no visible warning in the startup logs. Some self-hosted deployments discovered the break on restart — an extension compiled for PG 15 refusing to load under PG 17.
If your stack already pins postgres:15, you are protected in the short term. If it points to postgres:latest or a floating Supabase tag, it may have already migrated without your knowledge. The verification takes one command:
docker exec <container> psql -U postgres -c 'SELECT version();'The result shows you the exact version of the running server. Do not assume — verify.
What actually changes between PG 15 and PG 17
search_pathhardened since PG 15.1 (ADV-2022-00007): thepublicschema is no longer in the defaultsearch_pathfor non-superuser roles. Any query that assumedpublic.my_tableinstead of an explicitly qualified name may silently return 0 rows instead of an error.pg_dumpproduces downward-incompatible dumps: a PG 17 dump cannot be restored to PG 15. The reverse is supported, and that is the direction of this migration. Keep PG 15 dumps for at least 30 days after the switch.wal_leveldefault raised tologicalin PG 16+: if yourpostgresql.confforcedwal_level = minimal, behavior changes after migration. Existing logical replication slots may become invalid.- Deprecated function removals:
lo_import,lo_exportand severalpg_catalogfunctions were removed or renamed between PG 15 and PG 17 — check any functions used by your custom extensions. pg_stat_statementschanges query normalization: Grafana/PgHero dashboards that aggregate by query fingerprint will see their series reset to zero after migration.pg_partman(partition management) requires version ≥ 5.x for PG 17 — version 4.x is not compatible.timescaledbrequires version ≥ 2.13 for PG 17; earlier versions refuse to load and block container startup.
Extension inventory: what passes and what breaks
Before any migration, extract the list of active extensions in each database:
docker exec <pg15_container> psql -U postgres -c \
"SELECT datname, extname, extversion FROM pg_extension e JOIN pg_database d ON d.oid = e.extnamespace ORDER BY datname, extname;"Extensions to check first on PG 17:
Compatible without action: pgcrypto, uuid-ossp, hstore, ltree, citext, pg_trgm, unaccent, intarray, tablefunc, earthdistance, fuzzystrmatch. These extensions ship in the standard postgresql-contrib package and have no breaking changes between PG 15 and PG 17.
Requiring an update: pgvector (upgrade to ≥ 0.7.0 for PG 17), pg_stat_statements (built-in, but must be recreated if shared_preload_libraries changes), PostGIS (≥ 3.4 for PG 17), TimescaleDB (≥ 2.13 required), pg_partman (≥ 5.0 required).
Not ported or requiring recompilation: any custom-compiled extension (.so) built against PG 15 headers must be recompiled against PG 17 headers. The binary is not backward-compatible. If your Docker image embeds such a .so, rebuild the image before migration.
Post-migration verification command:
docker exec <pg17_container> psql -U postgres -d mydb \
-c 'SELECT extname, extversion FROM pg_extension ORDER BY extname;'Compare the installed_version column with your PG 15 inventory — a missing extension (NULL) indicates a loading issue to fix before validating the migration.
Zero-downtime migration procedure: pg_dump/pg_restore
Pin the PG 15 image version
Before any operation, pin the current image in your docker-compose.yml with its exact tag. This allows you to return to the original state with one command:
docker inspect <pg15_container> --format '{{.Config.Image}}'
# e.g.: postgres:15.7
# Update the Compose:
# image: postgres:15.7Commit this change to your VCS before proceeding. Migration cannot be done in place: PG 17 refuses to start on a PG 15 PGDATA (incompatible internal format).
Capture the global dump and per-database dumps
Export global objects (roles, tablespaces) first, then each database individually. The --no-owner and --no-acl flags avoid permission errors when restoring under a different superuser:
# Global objects (roles, tablespaces)
docker exec <pg15_container> pg_dumpall \
-U postgres \
--globals-only \
> backup_globals.sql
# Each application database
docker exec <pg15_container> pg_dump \
-U postgres \
--no-owner \
--no-acl \
--format=custom \
--file=/tmp/mydb_pg15.dump \
mydb
docker cp <pg15_container>:/tmp/mydb_pg15.dump ./mydb_pg15.dumpVerify dump integrity before proceeding:
pg_restore --list mydb_pg15.dump | head -20A corrupt or empty dump here means the migration stops — never proceed to the next step without validating the dump.
Start the PG 17 container in parallel on a different port
Add a second service in your docker-compose.yml for PG 17, on a distinct port (e.g. 5433), with a new data volume. The PG 15 service remains active throughout this step — no interruption for your applications:
postgres17:
image: postgres:17
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pg17_data:/var/lib/postgresql/data
ports:
- "5433:5432"
shm_size: 256mb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pg17_data:Start only this service:
docker compose up -d postgres17
docker compose exec postgres17 pg_isreadyRestore to PG 17 and verify extensions
Restore global objects first, then the database:
# Roles and tablespaces
docker exec -i <pg17_container> psql -U postgres < backup_globals.sql
# Database restore
docker cp mydb_pg15.dump <pg17_container>:/tmp/mydb_pg15.dump
docker exec <pg17_container> pg_restore \
-U postgres \
--no-owner \
--no-acl \
-d mydb \
/tmp/mydb_pg15.dumpIf pg_restore reports errors on extensions, fix them before continuing:
# Create the missing extension on PG 17
docker exec <pg17_container> psql -U postgres -d mydb \
-c 'CREATE EXTENSION IF NOT EXISTS pgvector;'
# Verify all expected extensions are present
docker exec <pg17_container> psql -U postgres -d mydb \
-c 'SELECT extname, extversion FROM pg_extension ORDER BY extname;'Errors of the type ERROR: function X does not exist during restore indicate a missing or incompatible extension — do not ignore these messages.
Switch applications and validate
Put your applications into maintenance mode or read-only (depending on your architecture), take a final consistency dump from PG 15, then update the DATABASE_URL (or POSTGRES_HOST/POSTGRES_PORT) variable of each application service to point to the PG 17 container on port 5432. Restart application services:
# Minimal application check
docker compose exec app php artisan db:monitor
# or
curl -sf http://localhost/api/health | jq .databaseIf validation passes, stop the PG 15 container, remap port 5432 to postgres17, and remove the postgres17 service by renaming it as the primary service. Delete the old PG 15 volume after 30 days of retention.
search_path is the most common silent change. Since PG 15.1, the public schema is no longer included in the default search_path for non-superuser roles. If your Flyway, Liquibase, or Artisan seeds fail with relation "X" does not exist after migration, add SET search_path TO public, "$user"; at session start or explicitly qualify your table names. Also check the parameter in postgresql.conf: search_path = 'public' (with single quotes) forces the expected behavior for all roles.
The Supabase case: postgres:17 without warning
Supabase public changelog discussion #46080 (github.com/orgs/supabase/discussions/46080) documents the switch of the reference image to postgres:17 that occurred in June 2026. Self-hosted stacks that referenced image: supabase/postgres without a version tag received PG 17 on the next docker compose pull, without automatic data migration.
Observed behavior: the PG 17 container starts, refuses to read the PG 15 PGDATA (incompatible format), and exits immediately. Docker Compose attempts the configured restarts, then marks the service unhealthy or exited. The application goes down. The cause is not visible in application logs — it is in the postgres container logs:
docker logs <supabase_db_container> 2>&1 | head -20
# FATAL: database files are incompatible with server
# DETAIL: The data directory was initialized by PostgreSQL version 15, which is not compatible with this version 17.The recommended approach for any self-hosted Supabase deployment is to pin the tag to a precise minor version in your docker-compose.yml:
db:
image: supabase/postgres:15.8.1.040
# or the latest 17.x once migration is complete
# image: supabase/postgres:17.4.1.016Check the Supabase GitHub releases page to identify the latest tag for each major branch. A floating tag (latest, 15, 17) delegates the upgrade decision to the publisher — with no control on your side over when it happens.
Migration strategies: pg_dump/restore vs pg_upgrade vs logical replication
| Strategy | Downtime | Complexity |
|---|---|---|
| pg_dump / pg_restore (this guide) | 5–30 min depending on data volume | Low — native tools, reproducible |
| In-place pg_upgrade | 1–5 min (fast binary upgrade) | High — requires both PG 15 and PG 17 binaries simultaneously, difficult in Docker |
| Logical replication (true zero-downtime) | < 1 min (online switchover) | Very high — requires `wal_level = logical`, replication slots, manual synchronization |
Troubleshooting: frequent errors and their causes
The most common errors during a PG 15 → 17 migration in a Docker context:
FATAL: database files are incompatible with server
Cause: the PG 17 container started on the same volume as PG 15. The internal PGDATA format is not backward-compatible. Fix: always use a new volume for PG 17 and restore via pg_restore.
ERROR: extension "timescaledb" is not available (or pg_partman, pg_cron)
Cause: the extension is not compiled for PG 17 in the official postgres:17 image. Fix: use a derived image that includes the required extensions (e.g. timescale/timescaledb:latest-pg17), or compile the extension in your own Dockerfile.
ERROR: role "X" already exists when restoring globals
Cause: the pg_dumpall --globals-only dump includes CREATE ROLE for all roles, and the postgres superuser role already exists in the fresh PG 17 instance. Fix: use --if-not-exists or filter out CREATE ROLE postgres lines from backup_globals.sql before restoring.
ERROR: relation "public.X" does not exist in applications
Cause: default search_path change since PG 15.1. Fix: add options=-csearch_path=public to the connection string, or run ALTER ROLE app_user SET search_path = 'public'; after restore.
pg_restore: error: could not execute query: ERROR: invalid byte sequence for encoding "UTF8"
Cause: data encoded as LATIN1 in PG 15, and the PG 17 instance was initialized with UTF8 (default). Fix: restore to a PG 17 instance initialized with POSTGRES_INITDB_ARGS: --encoding=LATIN1 --lc-collate=fr_FR.UTF-8, or migrate encoding in an intermediate script.
Switchover checklist — validate in order before each step
- Before starting: active extension list extracted (
pg_extension), PG 15 version pinned in Compose, full dump validated (pg_restore --listwithout errors). - After restore to PG 17: all extensions present at the correct version,
search_pathverified via an application role (not superuser), row counts compared on the 5 critical tables between PG 15 and PG 17. - Before application switchover: maintenance window announced, read-only mode activated if possible, final consistency dump captured from PG 15.
- After application switchover:
/healthendpoint returns 200 withdatabase: ok, application logs withoutrelation does not existerrors,pg_stat_activitymetrics on PG 17 confirm active connections. - PG 15 retention: PG 15 volume kept for at least 30 days, dump kept for 90 days, rollback procedure documented (point Compose at PG 15 and restart).
- Post-migration:
ANALYZE VERBOSE;run on all databases to recalculate planner statistics,autovacuumconfirmed active, monitoring updated for PG 17 (pg_stat_statements,pg_stat_bgwriter).
Managing the migration across a client portfolio
For an agency managing multiple client environments, the PG 15 → 17 migration is not a single event — it is a workload to plan across the portfolio.
Inventory first. The following command lists all running PostgreSQL versions across a VPS hosting multiple Compose projects:
docker ps --format '{{.Names}}' | xargs -I{} sh -c \
'docker exec {} psql -U postgres -qtAX -c "SELECT current_setting(\"server_version\")" 2>/dev/null && echo " <- {}"'The result identifies in one pass all containers still on PG 15 across the entire fleet.
Prioritize by risk. Stacks with custom-compiled extensions or timescaledb/pg_partman need a tested derived image before switching — they require more time. Stacks with only pgcrypto, uuid-ossp, and hstore switch in 15 minutes.
Standardize the image. Define a shared image in an internal registry (registry.yourcompany.com/postgres:17-base) that embeds the extensions validated by the agency. All portfolio Compose projects point to this image: updating an extension (e.g. a new version of pgvector) propagates by rebuilding one image.
The daily backups included on ServOrbit Agency plans provide a safety net for each migration: if a client stack shows unexpected behavior in the 24 hours following the switch, restoration starts from a previous day's backup rather than a manually prepared dump created under pressure.