Installation
Migrating data to PostgreSQL 18
One-time, data-safe procedure to move an existing PostgreSQL 16 deployment to PostgreSQL 18 without losing data.
Migrating data to PostgreSQL 18
Who needs this: only deployments that already run on PostgreSQL 16 and want to move to a QuoteNode build that requires PostgreSQL 18. Fresh installs need nothing — they start on PostgreSQL 18 directly.
A PostgreSQL 18 server cannot start on a PostgreSQL 16 data directory. The app refuses to start on an incompatible directory and never rewrites it automatically, so an existing install must move its data forward once. This is a deliberate, one-time operator procedure.
The migration is data-safe by design: your existing data volume (call it A) is never deleted, recreated, or written over. The data is copied into a new PostgreSQL 18 volume (B); A stays intact as an instant rollback until you confirm B works.
There are three paths (Path C is a discouraged last resort). Pick by whether you have a host shell:
| Path | Use when | Mechanism |
|---|---|---|
| A — host CLI | You can run docker on the host (Ubuntu VPS, LAN Linux, plain Compose) | scripts/migrate-postgres-major.sh |
| B — UI-only stack | You only have a UI (Coolify, Portainer, QNAP, Synology) | infra/docker-compose.upgrade.yml |
| C — in-place auto-upgrade ⚠️ | Last resort only; neither A nor B works and you accept in-place risk | pgautoupgrade image (not recommended) |
Paths A and B are data-safe: they copy into a new volume and keep your original intact. Path C upgrades in place — there is no untouched rollback, only your off-host backup. Prefer A or B.
Before you start (all paths)
- Back up
.env. It holds secrets that cannot be regenerated, especiallyDB_ENCRYPTION_KEY. - Take a database backup (in addition to the automatic safety copy the migration makes):
docker compose exec postgres pg_dump -U quotenode quotenode > backup-before-pg18.sql - Pause auto-deploy if your platform redeploys on a moving tag (e.g. Coolify auto-deploy,
Portainer
:latest). See Release order — a PG18 image must not land before the migration runs. - Stop the application stack (without deleting volumes). Never run
docker compose down -v.
Path A — host CLI (recommended when you have a shell)
This is the simplest path for Ubuntu VPS, LAN Linux, and any plain Docker Compose host.
- Find the data volume name:
docker volume ls | grep postgres - Stop the stack (keep volumes):
docker compose down - Run the migration. It captures two safety artifacts (a logical dump and a raw tarball of the
volume) and verifies both before touching anything, runs a fail-closed preflight on the live
cluster, then recreates the volume on PostgreSQL 18 and restores:
The script refuses to do anything destructive unlessDB_NAME=quotenode DB_USERNAME=quotenode DB_PASSWORD='...' \ POSTGRES_VOLUME=<your_postgres_volume> \ DB_MAJOR_MIGRATE_CONFIRM=yes \ ./scripts/migrate-postgres-major.shDB_MAJOR_MIGRATE_CONFIRM=yes, and aborts (leaving your data untouched) if it finds unexpected roles/extensions or not enough disk space. - Start the stack normally. It will come up on PostgreSQL 18 and apply any pending migrations:
docker compose up -d - Verify, then delete the safety artifacts printed by the script once you are confident.
Non-default locale: if your source database uses a locale other than the image default, the script fail-closes on the encoding/collation check (a silent collation change can corrupt text indexes). Re-run with a matching
POSTGRES_INITDB_ARGS(e.g.--locale=...).
Notes for Coolify (managed hosts)
Path A run over SSH on the Coolify server is the simplest route on a Coolify host. A few Coolify specifics, confirmed in practice:
- A “Failed” deployment before you migrate is expected. When Coolify deploys a build that
requires PostgreSQL 18 onto an existing PostgreSQL 16 volume, the
postgrescontainer crash-loops (FATAL: database files are incompatible with server) and Coolify marks the deployment Failed. Your data is not touched — PostgreSQL is simply refusing to start on an older data directory. - You must remove the
postgrescontainer to migrate. Path A recreates the data volume, and Docker refuses to remove a volume while any container — even a stopped one — still references it. So the procedure removes the Coolifypostgrescontainer first; stopping it is not enough. - After the database is migrated, bring the stack back with “Redeploy” — not “Restart”. Because
the
postgrescontainer was removed, Coolify has to recreate it. Click Redeploy on the resource; Coolify then startspostgres:18-alpineon the migrated volume and brings the app back up. - Coolify reuses the migrated volume. Recreating the volume drops Coolify’s compose labels, but Docker Compose (and therefore Coolify’s redeploy) matches volumes by name and reuses the existing one — your data stays. Keep the safety artifacts until you have verified the redeployed app.
- Find the real volume name in the resource’s Storages tab; it is UUID-prefixed, e.g.
<uuid>_postgres-prod-data(note the hyphens). If you run several stacks (e.g. a dev and a main install) on one host, migrate each by its own volume name and don’t touch the other.
Path B — UI-only migration stack (Coolify, Portainer, NAS)
When you have no host shell, use the dedicated, temporary migration stack
infra/docker-compose.upgrade.yml. It is not part of any normal deployment — bring it up only
for the migration and remove it afterwards. It uses stock PostgreSQL images, no Docker socket, and is
driven entirely by container entrypoints.
It runs these phases automatically and stops at a verified report — it never promotes by itself:
- cold-backup — an immutable tar of volume A (rollback image), taken before anything reads A.
- dump — a fail-closed preflight of the live cluster + a logical dump.
- restore — restore into a fresh PostgreSQL 18 volume B.
- verify — checks major version, schema, extensions, and locale parity, and writes
REPORT.txtto the artifacts volume.
Steps
-
Stop the application stack in your platform UI (do not delete volumes).
-
Find the existing data volume name (this is A, used as
SOURCE_VOLUME_NAME):- Coolify: the volume is prefixed with the resource UUID, e.g.
<uuid>_postgres_prod_data. - Portainer / QNAP / Synology: the stack name is the prefix, e.g.
<stack>_postgres_data. - Compose default:
<project>_postgres_prod_data.
- Coolify: the volume is prefixed with the resource UUID, e.g.
-
Deploy the migration stack with these environment variables:
Variable Value SOURCE_VOLUME_NAMEthe existing volume A from step 2 DB_NAMEDB_USERNAMEDB_PASSWORDyour existing database credentials TARGET_VOLUME_NAMEoptional; defaults to quotenode_postgres_upgraded(this is B)# plain Docker equivalent of what the UI runs: docker compose -f infra/docker-compose.upgrade.yml up -
Read the report. When the
verifyphase finishes, readREPORT.txtfrom the artifacts volume (quotenode_pg_upgrade_artifacts). It must sayPASS. If any phase fails, the stack stops safely; A is untouched — fix the cause and retry (delete B first, see Recovery). -
Remove the migration stack (keep its volumes), then promote B.
Platform volume visibility (verify on your platform): Path B assumes the migration stack can mount the application’s existing volume A as an
externalvolume by name. Most Docker hosts allow this. If your platform isolates volumes per stack/resource and the migration stack cannot see A, use the in-place variant: temporarily replace the application’s compose withdocker-compose.upgrade.ymlinside the same resource/project so it inherits the same volume namespace. Confirm this on your specific platform before relying on it in production.
Path C — in-place auto-upgrade image (simplest-looking, but risky — not recommended) ⚠️
Last resort only. Use this only when neither Path A nor Path B is workable and you accept the risks below. Unlike A and B, this path upgrades in place on your existing volume A, so there is no untouched rollback volume — your off-host backup is the only safety net.
The idea looks the simplest: let an auto-upgrading image
(pgautoupgrade) run pg_upgrade once on
your existing volume, then start the normal postgres:18-alpine on it. But it is not a one-line
tag change in your normal compose — the postgres service there has a command: override (which
suppresses the upgrade entrypoint entirely), restart: unless-stopped (which would loop the
finished one-shot), and a healthcheck (which flags the upgrading server as unhealthy). All three
break an in-place upgrade.
The repo ships a ready, standalone one-shot file that avoids all of them:
infra/docker-compose.pgautoupgrade.unsafe.yml (the .unsafe is deliberate). Key facts:
pg_upgradeis destructive in place — on success the old cluster is gone, and a mid-way failure can leave the data directory half-converted. Your off-host backup is the only rollback.- The file keeps
PGAUTO_ONESHOT=yes, the legacyPGDATA=/var/lib/postgresql/data, no healthcheck, andrestart: no, and pins the image by digest.
Steps
- Stop the stack and take a raw, off-host copy of volume A — this is your only rollback:
Copydocker run --rm -v <your_postgres_volume>:/d:ro -v "$PWD:/out" alpine:3.23 \ tar czf /out/pg16-volume-backup.tar.gz -C /d .pg16-volume-backup.tar.gzoff the host. - Run the ready one-shot file against your live volume (
SOURCE_VOLUME_NAME= volume A):
Wait for the container to exit 0.SOURCE_VOLUME_NAME=<your_postgres_volume> \ DB_NAME=quotenode DB_USERNAME=quotenode DB_PASSWORD='...' \ docker compose -f infra/docker-compose.pgautoupgrade.unsafe.yml up - Verify the layout — the volume must now be PostgreSQL 18 on the legacy path:
docker run --rm -v <your_postgres_volume>:/d:ro alpine:3.23 cat /d/PG_VERSION # -> 18 - Start your normal stack (
postgres:18-alpine). It uses the same volume, now upgraded — no promotion step is needed (the upgrade was in place). - Verify. If anything is wrong, restore A from
pg16-volume-backup.tar.gz.
UI-only platforms (Coolify/Portainer): paste the contents of
infra/docker-compose.pgautoupgrade.unsafe.ymlinto a new temporary stack, set the env vars above, deploy once, confirm it exited, delete that stack, then point your normal stack at the same volume.
Promote B
After verification, point the main stack at the migrated volume B. The base compose files are never edited (so fresh installs are unaffected); promotion is an override.
- Plain Docker / Compose: add the promotion override and set
POSTGRES_VOLUME_NAMEto B:
(usePOSTGRES_VOLUME_NAME=quotenode_postgres_upgraded \ docker compose -f infra/docker-compose.coolify.yml \ -f infra/docker-compose.promote.yml up -ddocker-compose.prod.ymlinstead ofcoolify.ymlfor the prod file). To roll back, drop the-f docker-compose.promote.ymland start again — you are back on A. - Coolify / Portainer UI: there is no
-fchaining in the UI. Instead, remap thepostgresservice storage to volume B in the platform’s storage settings, or addexternal: true+name: <B>for the postgres volume in the stack’s compose editor.
Verify
After the main stack is running on B:
- Open
/health— should returnUP. - Log in and confirm your existing data (offers, customers, settings) is present.
- Create a test offer and generate a PDF.
- Open a public link to confirm routing.
Only after this should you delete volume A and the migration artifacts.
Release order (important)
If your platform auto-deploys on a moving tag, a PG18-requiring image could land before you migrate, breaking the stack. Always:
- Pause auto-deploy / pin the current image tag.
- Run the migration (Path A or B) and promote B.
- Then upgrade the application to the PostgreSQL 18 build and re-enable auto-deploy.
Recovery
The source volume A is never modified, so recovery is simple:
- Migration interrupted before promotion (timeout, redeploy, stop): delete volume B and the artifacts volume, then start over. There is no partial state to resume.
- Problem after promotion: revert the promotion (drop the override / unset
POSTGRES_VOLUME_NAMEor re-point storage to A) and start the stack — you are back on PostgreSQL 16 with all data. - Last resort: restore from
backup-before-pg18.sql(see Upgrading QuoteNode → Rolling back).
Never run docker compose down -v during this procedure — it deletes volumes.