Changing the image tag from postgres:17 to postgres:18 doesn't upgrade anything.
The container refuses to start and your data stays intact.
To upgrade, dump the old server with the 18 client, restore into a new volume mounted at /var/lib/postgresql, verify the result, and keep the old volume for rollback.
I ran this on November 7, 2026, with PostgreSQL 17.11 and 18.6 Debian images on Docker Desktop 29.6.1 under WSL2.
The data was a pgbench shop database at 1.2 GB and 8.2 GB, with pgbench as a stand-in app.
Nothing ran on a real VPS, on bind mounts, or with PostGIS or pgvector.
Why Does Changing the Postgres Image Tag Fail?
A Postgres major version can't read an older data directory.
The 18 image also moved its volume from /var/lib/postgresql/data to /var/lib/postgresql, so its entrypoint looks for old data first and stops if it finds any.
Change only the tag, keep the mount, and start it:
docker compose up -d
docker compose logs --no-log-prefix postgres
The refusal, trimmed:
Error: in 18+, these Docker images are configured to store database data in a
format which is compatible with "pg_ctlcluster" (specifically, using
major-version-specific directory names). This better reflects how
PostgreSQL itself works, and how upgrades are to be performed.
Counter to that, there appears to be PostgreSQL data in:
/var/lib/postgresql/data
docker compose up -d still exited 0, while --wait exited 1.
Moving the mount to /var/lib/postgresql as well gives the same refusal with that path, not an empty database.
Forcing PGDATA: /var/lib/postgresql/data gets past the check and reaches the classic error:
FATAL: database files are incompatible with server
Put image: postgres:17 and the old mount back, and every row returned in my runs.
How Do You Upgrade With Dump and Restore?
Start with this route, because it uses official images and the only change on the old server is a freeze you can undo.
You need disk space for the dump and a second copy of the database.
This moves shop only, so any other database on the server stays behind.
Step 1: Start 18 Next to 17
Add a new service on its own volume, with the old service's password and a copy of its healthcheck:
postgres18:
image: postgres:18
environment:
POSTGRES_PASSWORD: lab-postgres-pw
POSTGRES_DB: shop
volumes:
- pgdata18:/var/lib/postgresql
- backups:/backups
Declare pgdata18 and backups under volumes.
If you set POSTGRES_USER, copy it too and use it in place of -U postgres.
If the old service sets POSTGRES_INITDB_ARGS, copy them before the first start.
Without them, an ICU database restored with exit 0 onto libc, and its ORDER BY results changed.
docker compose up -d --wait postgres18
Step 2: Freeze Writes
First write down who may connect, because the rollback has to restore exactly this.
Empty output means the default, where PUBLIC may connect:
docker compose exec -T postgres psql -U postgres -d postgres -Atc "SELECT datacl FROM pg_database WHERE datname = 'shop';"
Then stop the app, block new sessions, and end the existing ones:
docker compose stop app
docker compose exec -T postgres psql -U postgres -d shop -c "REVOKE CONNECT ON DATABASE shop FROM PUBLIC;" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'shop' AND pid <> pg_backend_pid();"
A role named in datacl, or the database's owner, keeps its own grant and isn't frozen by that, so also run REVOKE CONNECT ON DATABASE shop FROM shop_app; with its name.
A superuser ignores all of this, so if the app logs in as POSTGRES_USER, stopping every writer is your only freeze.
Prove it with probe.sql, run twice some seconds apart, where tuple_changes must stay flat and other_clients must be 0:
SELECT sum(n_tup_ins + n_tup_upd + n_tup_del) AS tuple_changes,
(SELECT count(*) FROM pg_stat_activity
WHERE datname = 'shop' AND pid <> pg_backend_pid()
AND backend_type = 'client backend') AS other_clients
FROM pg_stat_user_tables;
docker compose exec -T postgres psql -U postgres -d shop -f - < probe.sql
docker compose exec -T postgres psql -U postgres -d shop -At -f - < verify.sql > old.txt
The second line saves a report to compare later.
verify.sql reads the public schema, so widen the filter if you use others:
SELECT tablename, tableowner, (xpath('//c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', schemaname, tablename), false, true, '')))[1] FROM pg_tables WHERE schemaname = 'public' ORDER BY 1;
SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' ORDER BY 1;
SELECT conrelid::regclass::text, conname, pg_get_constraintdef(oid) FROM pg_constraint WHERE connamespace = 'public'::regnamespace AND contype <> 'n' ORDER BY 1, 2;
SELECT sequencename, last_value FROM pg_sequences ORDER BY 1;
SELECT extname FROM pg_extension ORDER BY 1;
SELECT rolname, rolcanlogin, rolsuper, rolconfig FROM pg_roles WHERE rolname NOT LIKE 'pg\_%' ORDER BY 1;
SELECT datname, datdba::regrole, datlocprovider, datcollate, datlocale, setconfig FROM pg_database d LEFT JOIN pg_db_role_setting s ON s.setdatabase = d.oid WHERE datname = current_database();
SELECT name, setting FROM pg_file_settings WHERE sourcefile LIKE '%auto.conf' ORDER BY 1;
Step 3: Take the Final Dump
Dump with the 18 client across the Compose network:
docker compose exec -T postgres18 sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" pg_dumpall -h postgres -U postgres --globals-only -f /backups/globals.sql'
docker compose exec -T postgres18 sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h postgres -U postgres -Fc -f /backups/shop.dump shop'
Run my pg_dump checks on the dump before you go on.
globals.sql holds every role's password hash, so treat the backups volume as a secret and delete the file once the upgrade is settled.
Step 4: Restore, Roles First
Restoring before the roles exist stops at the first owner change and leaves a table behind:
pg_restore: error: could not execute query: ERROR: role "shop_app" does not exist
The new server already has its superuser, so filter that line from globals.sql, then restore and refresh statistics:
docker compose exec -T postgres18 sh -c 'grep -v "^CREATE ROLE ${POSTGRES_USER:-postgres};" /backups/globals.sql | psql -v ON_ERROR_STOP=1 -U "${POSTGRES_USER:-postgres}" -d postgres -q'
docker compose exec -T postgres18 pg_restore -U postgres -d shop --exit-on-error /backups/shop.dump
docker compose exec -T postgres18 psql -U postgres -d shop -c "ANALYZE;"
Keep --create off the restore, because it carried the frozen datacl across and locked out the app.
A failed attempt leaves partial roles or tables, so remove only the new service and its volume before you retry.
pgup-shop is my Compose project, so check your name with docker volume ls first, because the other volume holds your data:
docker compose rm -sf postgres18
docker volume rm pgup-shop_pgdata18
docker compose up -d --wait postgres18
Step 5: Verify Before You Switch
Compare the reports:
docker compose exec -T postgres18 psql -U postgres -d shop -At -f - < verify.sql > new.txt
diff old.txt new.txt
On my data:
22,23c22
< shop|postgres|c|en_US.utf8||{work_mem=16MB}
< work_mem|32MB
---
> shop|postgres|c|en_US.utf8||
A plain restore doesn't carry ALTER DATABASE settings, and ALTER SYSTEM settings live in the data directory, so reapply both on 18 by hand.
It also leaves the database owned by postgres: a shop_app that owned it got permission denied for schema public until ALTER DATABASE shop OWNER TO shop_app;.
The datacl you wrote down doesn't travel either, so on a database locked to one role, another role connected to 18 until I ran the same REVOKE and GRANT CONNECT there.
Then log in from the old service with -h postgres18, because inside the 18 container a wrong password also connected.
Make a real write inside a transaction you roll back, and run the queries your application needs.
Step 6: Switch
Edit postgres in place, keeping its other keys, and remove postgres18:
services:
postgres:
image: postgres:18
volumes:
- pgdata18:/var/lib/postgresql
- backups:/backups
volumes:
pgdata:
pgdata18:
backups:
docker compose up -d --wait --remove-orphans
The old volume is your rollback, so keep it out of any docker compose down -v and any docker volume prune -a.
How Do You Roll Back After Dump and Restore?
Put image: postgres:17 and - pgdata:/var/lib/postgresql/data back together, start Postgres, undo the freeze, then start the rest:
docker compose up -d --wait postgres
docker compose exec -T postgres psql -U postgres -d shop -c "GRANT CONNECT ON DATABASE shop TO PUBLIC;"
docker compose up -d --wait
An app started before that GRANT exited with permission denied.
If you stop before the switch, skip the edit and run the same commands.
The GRANT is right only if datacl was empty, because on a database that had already revoked PUBLIC it lets in every role.
There, run GRANT CONNECT ON DATABASE shop TO shop_app; for the roles you wrote down.
Writes made on 18 stay on 18, and I didn't test carrying them back.
Restore the mount too, because postgres:17 at /var/lib/postgresql writes to an anonymous volume.
What Does pg_upgrade Add, and What Does It Cost?
pg_upgrade reuses the data files instead of rewriting them through SQL, so planner statistics and database settings came across.
On 8.2 GB, the data step alone took 156.9 to 212.7 seconds for dump, pg_restore, and ANALYZE, 26.3 to 37.5 for copy mode, and 9.4 to 21.4 for link mode, three runs each.
These are rough sizes, and each copy probably read from page cache.
Official images carry one major version, so this route needs a second image with write access to your old volume. Take a cold copy first:
docker compose stop
docker volume create pgup-shop_pgdata-backup
docker run --rm -v pgup-shop_pgdata:/from:ro -v pgup-shop_pgdata-backup:/to --entrypoint sh postgres:17 -c 'cp -a /from/. /to/'
A mistyped volume name copies nothing and still exits 0, so check it:
docker run --rm -v pgup-shop_pgdata-backup:/b:ro --entrypoint sh postgres:17 -c 'cat /b/PG_VERSION && du -sh /b'
The next command runs tianon/postgres-upgrade, a community image whose README opens by calling it a proof of concept.
The digest pin means you run what you tested, and --check is a dry run to remove for the real run.
This is copy mode:
docker run --rm \
-e POSTGRES_INITDB_ARGS=--no-data-checksums \
-v pgup-shop_pgdata:/var/lib/postgresql/17/data \
-v pgup-shop_pgdata18:/var/lib/postgresql \
tianon/postgres-upgrade@sha256:6b96848027d8ce1d716332e9bee07e1b6d884533e9c1666e0d12b7cb533a4447 --check
My 17 cluster had checksums off, and without that flag it stops with old cluster does not use data checksums but the new one does.
With POSTGRES_USER set, it fails with role "postgres" does not exist until you replace the -e line with -e PGUSER=shopadmin and -e POSTGRES_INITDB_ARGS="--username=shopadmin --no-data-checksums".
After any failed run, including --check, remove the new volume before you retry.
The new pg_hba.conf only trusts local connections, so the app got no pg_hba.conf entry while a login inside the container passed.
Copy the old file, and repeat this line for postgresql.auto.conf, or ALTER SYSTEM settings stay behind:
docker run --rm -v pgup-shop_pgdata:/old:ro -v pgup-shop_pgdata18:/new --entrypoint sh postgres:18 -c 'cp /old/pg_hba.conf /new/18/docker/pg_hba.conf && chown postgres:postgres /new/18/docker/pg_hba.conf'
Start 18 from the Step 6 file, run update_extensions.sql from /var/lib/postgresql, and run the two vacuumdb commands that pg_upgrade prints.
In copy mode, rolling back takes only the old Compose file, and writes on 18 stay behind.
A -bookworm volume also carries its collation version warning into 18, while dump and restore cleared it.
Can You Roll Back After pg_upgrade --link?
Only if 18 has never started.
Link mode hard-links files instead of copying them, which saves one copy of the data but not the tested cold copy.
It needs both clusters on one volume, because two named volumes fail with Invalid cross-device link.
It also renames the old pg_control, so 17 refuses to start until you rename it back.
If 18 never started, that rename brought 17 back with a clean pg_amcheck.
Read postmaster.opts to tell which case you're in:
docker run --rm -v pgup-shop_pgdata:/d:ro --entrypoint cat postgres:18 /d/18/docker/postmaster.opts
"-p" "50432" "-b" in that line means only pg_upgrade ran it.
A bare path or your own flags such as -c mean a real start (my observation on 18.6, not a documented interface).
If you can't tell, restore the cold copy.
After one start of 18, the same rename brings 17 up with a clean log and wrong data.
On first read, a 200,000-row customers table that 18 had grown to 205,000 returned 195000.
The pg_upgrade docs call the old cluster unsafe at that point, so restore the cold copy with the stack stopped, which deletes the upgraded cluster and everything written on 18:
docker run --rm -v pgup-shop_pgdata-backup:/from:ro -v pgup-shop_pgdata:/to --entrypoint sh postgres:17 -c 'test -f /from/PG_VERSION && find /to -mindepth 1 -delete && cp -a /from/. /to/'
The original Compose file then returned the same row count and hash.
Summary
Changing the tag gets you a refusal with your data intact, so start with dump and restore into a new volume, and carry the owner, database settings, ALTER SYSTEM values, and initdb arguments by hand.
pg_upgrade copy mode was faster on my machine, but it gives a proof-of-concept image write access to your volume, so a cold copy and --check come first.
Link mode comes last, because one start of 18 removes the rename as a way back.
These results come from containers under WSL2, so rehearse on a copy of your own stack.
My self-hosting course practices restoring a backup into a temporary database and builds a monthly maintenance routine.
Thanks for reading.
And stay awesome!
Frequently Asked Questions
Why does Postgres refuse to start after I change the Docker tag to 18?
The 18 image finds old data at the previous path and exits with code 1 before touching it. A major version also cannot read an older data directory. Your data stays intact, and putting the 17 tag and the old mount back brought every row back in my tests.
Where does the Postgres 18 Docker image keep its data?
Its data directory is /var/lib/postgresql/18/docker and its volume is /var/lib/postgresql, so mount the volume there. An old mount at /var/lib/postgresql/data makes the image refuse to start, even on an empty volume.
Should I use dump and restore or pg_upgrade in Docker?
Start with dump and restore. It uses official images, you can verify the new server while the old one stays up, and its rollback worked when I rehearsed it. pg_upgrade moved the data faster in my lab and keeps planner statistics, but it needs a community image that its own README calls a proof of concept.
Can I roll back after pg_upgrade --link?
Only if 18 has never started. Then renaming one file brought 17 back intact. After one start of 18, the same rename brought 17 up with a clean log and wrong rows, so restore a cold copy taken before the upgrade instead.
What does a plain Postgres dump and restore lose?
In my test it lost a database-level setting, everything set with ALTER SYSTEM, the database owner, and its connect grants. A locale set through POSTGRES_INITDB_ARGS also changes unless you carry it to the new service, and the restore still exits 0. Planner statistics stay empty until ANALYZE or autovacuum reaches them.

