# How to upgrade Postgres to a new major version in Docker

![The old Postgres 16 container streams its data into a new Postgres 18 container with a fresh volume, while the old volume stays untouched for rollback](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/3fe0c3af-c039-4b56-a1cb-9c3e3b364779.png)

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](https://www.hubfly.space/?ref=upgrade-postgres-major-version-docker): 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:

```text
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:

```text
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

1. Run the new version **next to** the old one, with its **own** volume.
2. Stop the app so nothing writes during the copy.
3. Copy everything (roles, passwords, databases, extensions) with `pg_dumpall`.
4. Check row counts and rebuild planner statistics.
5. 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:

```yaml
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:

```bash
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).

```bash
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:

```bash
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.

```bash
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.

![The Postgres 16 server before the upgrade: version 16.15, an 86 MB database with 1,000,000 orders, the citext extension, and a 47 MB backup written in 3.6 seconds](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/6f8eed89-3a50-4668-94da-a6963e71fac0.png)

## 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:

```yaml
  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:

```bash
docker compose up -d db18
docker compose logs db18 | tail -5
```

Wait for `database system is ready to accept connections`.

![The new Postgres 18 container's volume, mounted at /var/lib/postgresql](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/490865c5-622d-4592-bfdb-106ba4ccdde0.png)

## 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.

```bash
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:

```bash
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:

```text
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.

![The copy finished on Postgres 18.6 in 7.4 seconds with the single expected error](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/60f2739a-4af2-4fe6-8bd6-2894efa76c56.png)

## Step 5: Check the copy and rebuild statistics

Run the same counts and the same sum against the new database:

```bash
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:

```bash
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:

```bash
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:

```yaml
  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:

```bash
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:

```bash
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.

![The old and new database containers running side by side in one project](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/a2543a65-5ba5-4140-b739-e971ecee71ce.png)

1. In your project, create a second container from the `postgres:18` image with the same `POSTGRES_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**.
2. Open the old database container's **Network** tab and copy its **Private network alias**. It looks like `db-01f8845a.eunorth.hubfly.internal`.
3. Open the new container's **Console** tab and run the Step 4 copy with that alias in place of `db`:

```bash
PGPASSWORD="$POSTGRES_PASSWORD" pg_dumpall -h db-01f8845a.eunorth.hubfly.internal -U postgres | psql -U postgres -X -q -o /dev/null
```

4. In the same Console, run the Step 5 checks and `vacuumdb -U postgres --all --analyze-in-stages`.
5. Point your app's `DATABASE_URL` at 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](https://www.hubfly.space/docs/volumes/?ref=upgrade-postgres-major-version-docker).

## 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:

```sql
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](https://blog.hubfly.space/docker-health-checks-postgres-redis?utm_source=hashnode&utm_campaign=upgrade-postgres-major-version-docker).
- If queries feel slower after the switch, find the culprits: [How to find and fix slow PostgreSQL queries](https://blog.hubfly.space/find-fix-slow-postgresql-queries?utm_source=hashnode&utm_campaign=upgrade-postgres-major-version-docker).
- Don't want to run upgrades yourself? [Get a managed PostgreSQL database for about $2 a month](https://blog.hubfly.space/managed-postgresql-database-2-dollars-a-month?utm_source=hashnode&utm_campaign=upgrade-postgres-major-version-docker), or read how [volumes work on Hubfly space](https://www.hubfly.space/docs/volumes/?ref=upgrade-postgres-major-version-docker).

[**Try it on Hubfly space →**](https://www.hubfly.space/?ref=upgrade-postgres-major-version-docker): 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](https://blog.hubfly.space/upgrade-postgres-major-version-docker?utm_source=hashnode&utm_campaign=upgrade-postgres-major-version-docker).*

