Files
HealthLog/docs/ops/postgres-major-upgrade.md
T
Marc-André Bombeck ffd06ce289 docs(ops): describe the move to a newer Postgres major
The compose file stays on 16. Changing only the image line stops the stack:
the 18 image refuses a data directory from an older major and expects its
volume one level up. The runbook walks the dump and restore into a fresh
volume, keeps the old one as the way back, and is linked from the update
section and the compose pin.
2026-10-25 03:06:37 +01:00

3.1 KiB

Moving the database to a newer Postgres major

The compose file ships Postgres 16 and stays there for now, because a major version change is the one database update that cannot happen by itself on docker compose pull. HealthLog runs on Postgres 18 as well: the full integration suite runs against 18 (INTEGRATION_PG_IMAGE=postgres:18-alpine pnpm test:integration), and the TrueNAS app starts new installs on 18.

Why changing the image line is not enough

Minor releases (16.14 to 16.15) share the on-disk format. Majors do not, and the official image refuses to start a data directory written by an older major. From 18 on, the image also keeps its data one level deeper, under /var/lib/postgresql/18/docker, and expects the volume mounted at /var/lib/postgresql instead of /var/lib/postgresql/data. With the old volume and only the image line changed, the db container exits at once with "there appears to be PostgreSQL data in: /var/lib/postgresql/data". Nothing is lost, but the stack stays down until the image line goes back to 16.

So the move is a dump from 16 and a restore into a fresh 18 volume.

Steps

Take the encryption key's copy out of its safe place first: the dump is unreadable without it, and this is the moment you would find out.

# 1. Stop the app so nothing writes while the dump runs.
docker compose stop app

# 2. Dump the database in Postgres' custom format.
docker compose exec -T db pg_dump -U healthlog -Fc healthlog \
  > healthlog-pg16-$(date +%F).dump

# 3. Stop the old database. Its volume stays untouched.
docker compose stop db
  1. In docker-compose.yml, change the db service:

    image: postgres:18.6-alpine # or the current 18 minor
    volumes:
      - pgdata18:/var/lib/postgresql
    

    and add the new volume next to pgdata in the top-level volumes: block:

    volumes:
      pgdata:
      pgdata18:
    
# 5. Start the new database and wait until it reports healthy.
docker compose up -d db
docker compose ps db

# 6. Restore the dump into it.
docker compose exec -T db pg_restore -U healthlog -d healthlog --no-owner \
  < healthlog-pg16-<date>.dump

# 7. Start the app and confirm it.
docker compose up -d app
curl -s http://localhost:3000/api/version

The start log should say there are no pending migrations: the dump carried the migration history with it. Sign in and open a few pages before you go on.

Afterwards

Keep the old pgdata volume and the dump until the new database has run for a few days. Going back means restoring the old image: and volumes: lines; the old volume still holds the database as it was before step 2. When you are sure, remove it (docker volume ls shows its full name, prefixed with the compose project):

docker volume rm <project>_pgdata

If you had run CREATE EXTENSION pg_stat_statements; for query statistics, the dump carries it and the compose command: still loads the library, so nothing needs doing there.

pg_upgrade --link is faster on a very large database, but it needs both majors' binaries in one container; for a personal health record the dump is seconds to minutes and has nothing to set up.