You can't upgrade Postgres to a new major version in Docker by changing the image tag. Each major version (16, 17, 18) stores data on disk in its own format, so postgres:18 refuses to start on a volume that postgres:16 created. This guide moves your data across the safe way: a new container and a new volume, a full copy with pg_dumpall, a check that every row arrived, and an old volume you can roll back to in one line. For a typical app database it takes a few minutes, and your app is down only while the copy runs.
Try it on Hubfly space: run Postgres in a container with a persistent volume.
Versions used: PostgreSQL 16.15 → 18.6 with the official postgres images, and Docker Compose v2 syntax. The same steps work for any jump, such as 14 → 17 or 15 → 18.
Why you can't just change the image tag
If you edit image: postgres:16 to image: postgres:18 and restart, one of two things happens.
With Postgres 17 or older on the new side, you get:
FATAL: database files are incompatible with server With Postgres 18 there's a second surprise. The official image moved its data directory. Up to 17, you mount the volume at /var/lib/postgresql/data. From 18, the image stores data in /var/lib/postgresql/18/docker and expects the volume at /var/lib/postgresql. If you keep the old mount, the container exits with a long message that starts like this:
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). ... Counter to that, there appears to be PostgreSQL data in: /var/lib/postgresql/data (unused mount/volume) Some platforms keep reporting the service as started while it restarts in a loop, so check the logs, not just the status.
Both errors protect your data. Nothing is lost yet. Don't delete the volume, and don't point a fresh Postgres 18 at an empty mount path and carry on: your app would connect to a blank database.
The plan to upgrade a Postgres major version in Docker
- Run the new version next to the old one, with its own volume.
- Stop the app so nothing writes during the copy.
- Copy everything (roles, passwords, databases, extensions) with
pg_dumpall. - Check row counts and rebuild planner statistics.
- Point the app at the new database. Keep the old volume until you're sure.
This is a dump and restore. It's the simplest method and the one with the clearest rollback. For databases of hundreds of gigabytes, pg_upgrade is faster; see the FAQ.
Step 1: Note what you have
Start from a typical Compose file:
services: db: image: postgres:16 environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: orders volumes: - pgdata:/var/lib/postgresql/data web: image: your-app:latest environment: DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/orders depends_on: - db volumes: pgdata: Record the version, the database size, the row counts of your biggest tables, and a checksum-style value you can compare later:
docker compose exec db psql -U postgres -c "select version();" docker compose exec db psql -U postgres -d orders -c "select pg_size_pretty(pg_database_size('orders'));" docker compose exec db psql -U postgres -d orders -c "select 'orders', count(*) from orders union all select 'customers', count(*) from customers;" docker compose exec db psql -U postgres -d orders -Atc "select sum(total_cents), max(id) from orders;" Also list the extensions you use. The new image must ship them too (the official image includes the standard contrib extensions like citext, pgcrypto and pg_trgm; PostGIS or pgvector need their own images).
docker compose exec db psql -U postgres -d orders -c "select extname, extversion from pg_extension;" pg_dumpall copies roles and data, not server settings. Save anything you changed in postgresql.conf, pg_hba.conf or with ALTER SYSTEM, so you can apply it to the new server:
docker compose exec db psql -U postgres -c "select name, setting, source from pg_settings where source not in ('default', 'override', 'client');" Apply them on the new server with ALTER SYSTEM SET … after Step 4.
If you set POSTGRES_USER, use that name wherever this guide says -U postgres. The expected error in Step 4 then names that role instead.
Step 2: Take a file backup first
Before anything else, write a full backup to your machine. This is your safety net if you mistype something later.
docker compose exec -T db pg_dumpall -U postgres > backup-pg16.sql The -T flag matters. It turns off Docker's pseudo-terminal, which otherwise can mangle the output when you redirect it to a file. Many guides use docker exec -it here; drop the t.
The file holds all your data and every password hash. Run chmod 600 backup-pg16.sql, keep it out of git, and delete it once the upgrade is done.
For a sense of scale: an 86 MB database with a million rows produced a 47 MB SQL file in 3.6 seconds on a small server with half a CPU core. Plan for seconds per gigabyte on a fast machine and longer on a busy one.
Step 3: Add the new database next to the old one
Add a second service with the new image and a new volume, mounted at the Postgres 18 path:
db18: image: postgres:18 environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - pgdata18:/var/lib/postgresql volumes: pgdata: pgdata18: Use the same POSTGRES_USER as the old service if you set one. Don't set POSTGRES_DB here: the dump creates your databases, and a pre-created one only adds an "already exists" error.
Start only the new database:
docker compose up -d db18 docker compose logs db18 | tail -5 Wait for database system is ready to accept connections.
Step 4: Stop the app and copy the data
Stop everything that writes to the database. Writes that land after the copy starts won't be in the new database.
docker compose stop web Now stream the dump from the old container straight into the new one, with no file in between. Run it inside the new container so the newer Postgres 18 client does the reading and writing:
docker compose exec -T db18 sh -c \ 'PGPASSWORD="$POSTGRES_PASSWORD" pg_dumpall -h db -U postgres | psql -U postgres -X -q -o /dev/null' The single quotes matter: $POSTGRES_PASSWORD is read inside the new container, which has the same password as the old one, so the secret never passes through your shell.
You'll see one error, and it's fine:
ERROR: role "postgres" already exists The dump recreates every role, including the superuser that the new container already made. Anything else in the output deserves a look.
Don't add -v ON_ERROR_STOP=1 to this command. It sounds safer, but it stops at that harmless first error, before any of your data is copied, and pg_dumpall then exits with pg_dump failed on database "template1".
The 86 MB database took about 7 seconds to stream from Postgres 16 into Postgres 18 between two small containers. That's your downtime, plus a restart.
Step 5: Check the copy and rebuild statistics
Run the same counts and the same sum against the new database:
docker compose exec db18 psql -U postgres -d orders -c "select 'orders', count(*) from orders union all select 'customers', count(*) from customers;" docker compose exec db18 psql -U postgres -d orders -Atc "select sum(total_cents), max(id) from orders;" Both must match what you wrote down in Step 1. Sequences come across too, so new rows won't collide with old IDs.
Check the extensions again:
docker compose exec db18 psql -U postgres -d orders -c "select extname, extversion from pg_extension;" Don't be surprised if the versions changed. The dump says "create this extension", not "create version 1.6", so the new server installs its own default. citext went from 1.6 on Postgres 16 to 1.8 on Postgres 18. That's normal, but if your app pins an extension version, read its changelog.
Next, rebuild the planner statistics. pg_dumpall doesn't copy the statistics Postgres uses to pick query plans (Postgres 18 added a --statistics option, but it's off by default), so the new database starts without them. Autovacuum usually analyzes fresh tables within a minute or so, but on a big or busy database it can lag, and until then queries can pick bad plans. Don't wait for it:
docker compose exec db18 vacuumdb -U postgres --all --analyze-in-stages --analyze-in-stages produces rough statistics fast and then refines them, so you can bring the app back sooner. It took under 3 seconds for the 86 MB database.
Step 6: Switch the app over
The cleanest switch is to make db the new database, so the hostname in DATABASE_URL stays the same. Edit the db service:
db: image: postgres:18 environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - pgdata18:/var/lib/postgresql Delete the db18 block from the file in the same edit (keep both pgdata and pgdata18 under volumes:). Then stop the temporary service, recreate db, and start the app:
docker compose stop db18 docker compose rm -f db18 docker compose up -d db docker compose up -d web docker compose exec db psql -U postgres -c "select version();" Never start db18 again: it uses the same volume as the new db, and two servers must not share one data directory.
To roll back, put db back to image: postgres:16 with pgdata:/var/lib/postgresql/data and run docker compose up -d db. The old volume was never touched. Anything written after the switch is only in the new database, so roll back early or not at all.
After a week of normal traffic, remove the old volume. docker volume ls shows its full name with the project prefix:
docker volume ls docker volume rm yourproject_pgdata Never run docker compose down -v during an upgrade. The -v deletes every named volume in the file, old and new.
Do it on Hubfly space
The same plan works on a Hubfly space Cell project, with no Docker install of your own.
- In your project, create a second container from the
postgres:18image with the samePOSTGRES_PASSWORD, and add a new volume mounted at/var/lib/postgresql. A Compose file with both services works too: paste it into Create → Compose Stack. - Open the old database container's Network tab and copy its Private network alias. It looks like
db-01f8845a.eunorth.hubfly.internal. - Open the new container's Console tab and run the Step 4 copy with that alias in place of
db:
PGPASSWORD="$POSTGRES_PASSWORD" pg_dumpall -h db-01f8845a.eunorth.hubfly.internal -U postgres | psql -U postgres -X -q -o /dev/null - In the same Console, run the Step 5 checks and
vacuumdb -U postgres --all --analyze-in-stages. - Point your app's
DATABASE_URLat the new container's alias and redeploy the app.
Volumes on Hubfly space keep their data across redeploys, but there are no volume snapshots, so the old volume is your rollback. Keep it until you're sure. A Standard volume costs $0.00001536 per GB-hour (about $0.011 per GB a month); Balanced costs 1.5× that and High Performance 2×. Keeping a 10 GB Balanced volume for a week costs about 4 cents. A second small database container (0.5 vCPU, 512 MB, Dedicated) costs about $0.003 an hour while it runs, so an upgrade afternoon costs pennies. Read how volumes work.
What can go wrong
FATAL: database files are incompatible with server. You started a newer Postgres on an old data directory. Change the image back, then follow the steps above.
there appears to be PostgreSQL data in: /var/lib/postgresql/data (unused mount/volume). You're on postgres:18 with the old mount path. For a fresh 18 volume, mount it at /var/lib/postgresql. Don't "fix" it by setting PGDATA to point 18 at your 16 data: the files are still in the 16 format.
The restore stops right away with role "postgres" already exists. You added ON_ERROR_STOP. Remove it; that error is expected.
invalid command \restrict. Dumps from recent Postgres patch releases (16.15's dump starts with one) contain \restrict lines that older psql clients don't understand. Restore with the psql inside the new container, which is new enough to read it.
password authentication failed for one app user after the upgrade. That role probably still has an old MD5 password hash, common when the database first ran on Postgres 13 or older. The dump copies the hash as it is. The official images use scram-sha-256 for network logins, which can't check MD5 hashes, and Postgres 18 deprecates MD5 passwords. Find those roles and set each password again from psql, which keeps it out of your shell history:
select rolname from pg_authid where rolpassword like 'md5%'; \password legacy_report The app is slow right after the switch. You skipped Step 5's vacuumdb --analyze-in-stages. Run it now.
An extension shows a different version. Expected: the new server installs its own default version (citext 1.6 → 1.8 in this upgrade). Check the extension's changelog if your app depends on a specific version.
extension "vector" is not available. The plain postgres image doesn't include that extension. Use an image that has it, such as pgvector/pgvector:pg18, for the new service.
Disk fills up during the copy. You need room for both volumes at once. The backup file from Step 2 needs space too, so write it to a different disk if space is tight.
FAQ
How do I upgrade Postgres in Docker without losing data?
Run the new version in a second container with its own volume, copy everything with pg_dumpall piped into psql, check row counts, then switch the app. The old volume stays untouched until you delete it.
Can I just change the image tag from postgres:16 to postgres:18?
No. Major versions use different on-disk formats, so the new server refuses to start. Minor updates within one major version, like 16.10 to 16.15, are safe in almost all cases: change the tag and restart. Read the release notes for any REINDEX steps, and pin the Debian variant you already run (today's postgres:16 is built on Debian trixie, so postgres:16-trixie) so a base-image change doesn't change collations under you.
Should I mount /var/lib/postgresql or /var/lib/postgresql/data?
For postgres:18 and newer, mount /var/lib/postgresql. For 17 and older, mount /var/lib/postgresql/data.
Is pg_upgrade faster than pg_dumpall in Docker?
Yes, for big databases. pg_upgrade --link reuses the data files instead of copying rows, but it needs both versions' binaries in one container, and both clusters must have the same data-checksum setting. Postgres 18 turns checksums on by default for new clusters, so if the old one has none, create the new one with --no-data-checksums. For databases under a few tens of gigabytes, dump and restore is simpler and the downtime is short.
How long does the upgrade take?
Mostly the time to copy your data. An 86 MB database streamed from 16 into 18 in about 7 seconds on two small containers; plan for minutes per 10 GB on typical cloud disks, and time a test run on a copy if downtime matters.
Can I upgrade from 14 straight to 18?
Yes. pg_dumpall output from an older version restores into a newer one, so you can skip versions. Use the newest client, which is the one in the new container.
Can I do this with zero downtime?
Not with a dump. For near-zero downtime, use logical replication from old to new and switch once they're in sync. That takes more setup and is worth it only if a few minutes of downtime is a real problem.
Go further on Hubfly space
- Add a health check so your app waits until the new database accepts connections: Docker health checks that catch real problems.
- If queries feel slower after the switch, find the culprits: How to find and fix slow PostgreSQL queries.
- Don't want to run upgrades yourself? Get a managed PostgreSQL database for about $2 a month, or read how volumes work on Hubfly space.
Try it on Hubfly space →: deploy postgres:18 with a persistent volume in a few clicks.
By Octave · drafted with AI, tested on Hubfly · Last updated 8 November 2026
Originally published on the Hubfly space Journal.





