Run Postgres as a rootless Podman Quadlet, and move the 17 cluster across
Replaces the docker_container playbook, which published 5432 on 0.0.0.0 and kept its data in /var/lib/postgresql. Postgres is now the base Quadlet unit, the same shape as Caddy: it knows nothing about its clients, and a service attaches itself by dropping a file into postgres.container.d/ via postgres/roles/attach. Nothing here changes when a service is added or removed. Pinned to 18, which is two decisions rather than one tag. Postgres 18 moved both the image's VOLUME (/var/lib/postgresql/data -> /var/lib/postgresql) and its default PGDATA (/var/lib/postgresql/data/pgdata -> /var/lib/postgresql/18/docker), and the 18 entrypoint refuses to start when it finds a cluster in the old layout. So postgres_major is the single knob: it picks the image tag and the PGDATA path together, and the mount is now the parent directory. That also settles the migration: a PGDATA copy cannot cross a major version, so monoco's 17.5 cluster comes across as a pg_dumpall. Three things that bit us and are worth keeping: - PGDATA and its version directory are created by the role, not left to the container. The entrypoint runs as container-root, which under keep-id is a subuid on the host, and it only chowns PGDATA and its contents -- so ~/podman-data/postgres/18 would have been stranded subuid-owned, and the host user could not even rm -rf it without podman unshare. - NetworkAlias= applies to every network the container joins, which is what a database hub wants, but it is one fixed value in a shared drop-in rather than per-service: two services writing their own would make Quadlet pass --network-alias twice. It is not in postgres.container itself because on a fresh host that unit has no Network= at all. - The credentials live in a 0600 file read with EnvironmentFile=, not in Environment= lines: the unit is world-readable in ~/.config. The play no longer uses become, and does not apply the rootless-Podman prerequisites -- those are the shared task file at caddy/roles/podman, and the three variables they need are duplicated at the top of vars/default.yml. migrate-from-monoco.md records the one-time 17.5 -> 18.6 move: the dump, transfer and restore commands, the checks that prove the restore is complete (per-database table counts against the source), and the verified result. The dead nginx/php-fpm templates in postgres/files/ are removed.
This commit is contained in:
1 parent
060173db5e
commit
4297e5a71e
11 files changed
+1079
-88
No files matched your search
@@ -0,0 +1,315 @@
|
||||
# Moving the cluster from monoco (Postgres 17 → 18)
|
||||
|
||||
> **Status: done — do not re-run against the live cluster.** Executed
|
||||
> 2026-10-15, Postgres 17.5 → 18.6. Verified afterwards: 12 databases restored
|
||||
> with per-database table counts *identical* to monoco, 13 databases and 3 roles
|
||||
> (`postgres`, `oc_tanshu`, `zitadel`), `btree_gist`/`pg_trgm`/`unaccent` present,
|
||||
> no listener on host `5432`, and every directory in the data root owned by
|
||||
> `tanshu` rather than a subuid.
|
||||
>
|
||||
> The steps below are kept as the record of how it was done, and for the next
|
||||
> major-version move. Running them again would now fail on the first
|
||||
> `CREATE DATABASE` and leave a half-loaded cluster — and the "start over" block
|
||||
> in step 5 *destroys* `~/podman-data/postgres/18`.
|
||||
|
||||
One-time move of every database from `monoco`'s Docker Postgres to `emily`'s
|
||||
rootless Quadlet Postgres, across a major-version upgrade. Run the commands from
|
||||
the machine that holds the `monoco` and `emily` SSH aliases.
|
||||
|
||||
## Why this is a dump and not a copy
|
||||
|
||||
Two independent reasons, either of which is decisive:
|
||||
|
||||
- **17 data files cannot be read by an 18 server.** A copy of `PGDATA` produces a
|
||||
cluster that 18 refuses to open — it would have to go through `pg_upgrade` with
|
||||
both versions present.
|
||||
- **The 18 image moved PGDATA.** In 17 it was `/var/lib/postgresql/data/pgdata`; in
|
||||
18 it is `/var/lib/postgresql/18/docker`, and the entrypoint deliberately
|
||||
refuses to start when it finds a cluster in the old location, to stop an image
|
||||
bump from silently looking like data loss.
|
||||
|
||||
A logical dump sidesteps both, and at this size it is also the fastest thing to
|
||||
get right. `pg_upgrade` would avoid re-importing, but it needs the 17 data
|
||||
directory copied to `emily` first *and* a purpose-built image with both versions
|
||||
(`tianon/postgres-upgrade:17-to-18`), for a cluster of well under a gigabyte. Not
|
||||
worth the extra failure modes.
|
||||
|
||||
## What is being moved (measured on monoco)
|
||||
|
||||
Source: Docker container `postgres`, `postgres:17-alpine`, server **17.5**,
|
||||
`PGDATA=/var/lib/postgresql/data/pgdata`, ~995 MB on disk.
|
||||
|
||||
| Database | Size | Database | Size |
|
||||
|---|---|---|---|
|
||||
| `nextcloud` | 320 MB | `hiwikijs` | 16 MB |
|
||||
| `brewman_acc` | 174 MB | `tandoor` | 16 MB |
|
||||
| `brewman_mhl` | 132 MB | `brewman_exp` | 16 MB |
|
||||
| `brewman_hops` | 122 MB | `affine` | 11 MB |
|
||||
| `brewman_hinchco` | 41 MB | `bitwarden` | 10 MB |
|
||||
| `medusa-criollo` | 21 MB | `zitadel` | 9 MB |
|
||||
|
||||
Roles: `postgres` (superuser), `oc_tanshu` (creates databases), `zitadel`.
|
||||
No custom tablespaces. `template1` is stock; the `postgres` database has no user
|
||||
tables. `medusa-criollo` and `zitadel` have no running container — nothing writes
|
||||
them — so they come along for completeness.
|
||||
|
||||
## Order of operations
|
||||
|
||||
1. Deploy the 18 cluster on `emily` (`postgres/playbook.yml`) and leave it **empty**.
|
||||
2. Dump on `monoco`.
|
||||
3. Transfer.
|
||||
4. Restore into `emily`.
|
||||
5. Verify, then point the app playbooks at it.
|
||||
|
||||
The target must be empty before the restore. `psql` will happily skip a
|
||||
`CREATE DATABASE` for a database that already exists, so a second restore into a
|
||||
populated cluster is not an error but is also not a clean overwrite.
|
||||
|
||||
## 1. Pre-flight
|
||||
|
||||
**The 18 cluster has to exist before any of this.** If it does not, deploy it:
|
||||
|
||||
```bash
|
||||
cd prepare-server && ansible-playbook -u tanshu ../postgres/playbook.yml
|
||||
```
|
||||
|
||||
Then check it is up and empty:
|
||||
|
||||
```bash
|
||||
# The 18 cluster exists, is running, and holds nothing but the defaults
|
||||
ssh emily 'systemctl --user is-active postgres.service
|
||||
podman exec postgres psql -U postgres -tAc "select datname from pg_database order by 1"'
|
||||
# expect: active, then just: postgres, template0, template1
|
||||
|
||||
# Room for the dump plus the restored data (~2 GB)
|
||||
ssh emily 'df -h / | tail -1'
|
||||
ssh monoco 'df -h / | tail -1'
|
||||
```
|
||||
|
||||
If that says `inactive`, or `podman exec` answers `no container with name or ID
|
||||
"postgres" found`, the unit has not been deployed on `emily` — stop and do that
|
||||
first. The restore in step 5 would otherwise pipe the whole dump into a
|
||||
non-existent container and leave almost no trace.
|
||||
|
||||
### Optional: quiesce the writers
|
||||
|
||||
`pg_dumpall` takes one snapshot per database, so each database is internally
|
||||
consistent but the cluster is not a single point in time. Every app here owns its
|
||||
own database and there are no cross-database references, so a live dump is fine
|
||||
in practice. If you want zero doubt, stop the writers for the duration:
|
||||
|
||||
```bash
|
||||
WRITERS='nextcloud gitea affine bitwarden hiwikijs tandoor
|
||||
brewman-acc brewman-hops brewman-mhl brewman-exp brewman-hinchco'
|
||||
|
||||
ssh monoco "docker stop $WRITERS" # before the dump
|
||||
ssh monoco "docker start $WRITERS" # after step 3
|
||||
```
|
||||
|
||||
That is real downtime for those sites, so take it or leave it deliberately.
|
||||
|
||||
## 2. Dump on monoco
|
||||
|
||||
```bash
|
||||
ssh monoco 'set -euo pipefail
|
||||
mkdir -p ~/pg-migrate && cd ~/pg-migrate
|
||||
TS=$(date +%Y%m%d-%H%M)
|
||||
docker exec -e PGPASSWORD=123456 postgres pg_dumpall -U postgres \
|
||||
| zstd -T0 -6 -o "pg17-cluster-$TS.sql.zst"
|
||||
zstd -t "pg17-cluster-$TS.sql.zst"
|
||||
sha256sum "pg17-cluster-$TS.sql.zst" | tee "pg17-cluster-$TS.sql.zst.sha256"
|
||||
ls -lh'
|
||||
```
|
||||
|
||||
It prints nothing while it runs — `pg_dump` buffers, so there is no progress bar
|
||||
— and takes a couple of minutes for ~900 MB. `zstd -t` is an integrity check;
|
||||
there is no point transferring a truncated dump.
|
||||
|
||||
`pg_dumpall` is used rather than per-database `pg_dump` because it carries the
|
||||
roles and `template1` in the same stream, and this cluster has three roles that
|
||||
would otherwise have to be recreated by hand.
|
||||
|
||||
## 3. Verify the dump before trusting it
|
||||
|
||||
```bash
|
||||
ssh monoco 'cd ~/pg-migrate && F=$(ls -t pg17-cluster-*.sql.zst | head -1)
|
||||
echo "file: $F"
|
||||
echo "databases connected : $(zstd -dc "$F" | grep -cE "^\\\\connect")" # expect 14
|
||||
echo "databases created : $(zstd -dc "$F" | grep -cE "^CREATE DATABASE")" # expect 12
|
||||
echo "role statements : $(zstd -dc "$F" | grep -cE "^(CREATE|ALTER) ROLE")" # expect 6
|
||||
zstd -dc "$F" | head -5'
|
||||
```
|
||||
|
||||
The counts are the shape of this particular cluster: 12 databases get a
|
||||
`CREATE DATABASE`, and `postgres` + `template1` are dumped in place (14
|
||||
`\connect` lines). The 6 role statements are 3 roles × (`CREATE` + `ALTER`).
|
||||
|
||||
Also check which extensions the dump creates, because step 5 runs with
|
||||
`ON_ERROR_STOP=1` and a missing extension aborts the whole restore:
|
||||
|
||||
```bash
|
||||
ssh emily 'cd ~/pg-migrate && zstd -dc pg17-cluster.sql.zst | grep -E "^CREATE EXTENSION" | sort | uniq -c'
|
||||
```
|
||||
|
||||
This cluster needs `btree_gist` (×5), `pg_trgm` and `unaccent` — all contrib
|
||||
modules, and the official image builds contrib in
|
||||
(`make -C contrib install`), so they are present. An extension that is *not*
|
||||
contrib — `pgvector`, `postgis`, `timescaledb` — would not be, and would have to
|
||||
be installed into the image before the restore can succeed. (`docs/podman-compat-audit.md`
|
||||
notes affine's indexer is disabled, which is why there is no `pgvector` here.)
|
||||
|
||||
## 4. Transfer to emily
|
||||
|
||||
```bash
|
||||
# stream it straight across; no copy on the workstation and no second hop
|
||||
ssh monoco 'cat ~/pg-migrate/pg17-cluster-*.sql.zst' \
|
||||
| ssh emily 'mkdir -p ~/pg-migrate && cat > ~/pg-migrate/pg17-cluster.sql.zst'
|
||||
```
|
||||
|
||||
Then gate on the checksum — a truncated dump restores into a partial cluster that
|
||||
looks fine until an app reads a missing table:
|
||||
|
||||
```bash
|
||||
a=$(ssh monoco 'sha256sum ~/pg-migrate/pg17-cluster-*.sql.zst | cut -d" " -f1')
|
||||
b=$(ssh emily 'sha256sum ~/pg-migrate/pg17-cluster.sql.zst | cut -d" " -f1')
|
||||
[ -n "$a" ] && [ "$a" = "$b" ] && echo "transfer OK: $a" \
|
||||
|| { echo "MISMATCH — do not restore: $a vs $b"; false; }
|
||||
```
|
||||
|
||||
`rsync` cannot copy remote-to-remote directly, so the pipe above is the shortest
|
||||
path. If the link is slow or drops, do it in two hops instead and check the
|
||||
checksum on both sides:
|
||||
|
||||
```bash
|
||||
rsync -avP monoco:~/pg-migrate/ ~/pg-migrate/
|
||||
rsync -avP ~/pg-migrate/ emily:~/pg-migrate/
|
||||
```
|
||||
|
||||
## 5. Restore into the 18 cluster on emily
|
||||
|
||||
```bash
|
||||
ssh emily 'cd ~/pg-migrate
|
||||
# Fail here, loudly, if the unit was never deployed. Without this, the
|
||||
# pipeline below feeds the dump to a podman exec that cannot find a container
|
||||
# and the only trace is a line in restore.err that a grep for ERROR misses.
|
||||
podman exec postgres pg_isready -U postgres -d postgres \
|
||||
|| { echo "FAILED: postgres is not running on emily — deploy it first (step 1)."; exit 1; }
|
||||
|
||||
set -o pipefail
|
||||
zstd -dc pg17-cluster.sql.zst \
|
||||
| grep -v "^CREATE ROLE postgres;$" \
|
||||
| podman exec -i -e PGPASSWORD=123456 postgres \
|
||||
psql -U postgres -d postgres -v ON_ERROR_STOP=1 -q 2> restore.err \
|
||||
|| { echo "FAILED: restore stopped on an error:"; tail -20 restore.err; exit 1; }
|
||||
|
||||
echo "OK — restore clean. Databases now:"
|
||||
podman exec -e PGPASSWORD=123456 postgres psql -U postgres -tAc \
|
||||
"select count(*) from pg_database where datallowconn and not datistemplate"'
|
||||
```
|
||||
|
||||
**Expect `13`** — the `postgres` database plus the 12 restored ones. That count is
|
||||
the positive proof the restore happened; an empty error log on its own proves
|
||||
nothing, which is exactly how a missing container slipped through the first time.
|
||||
|
||||
Three details are doing real work here:
|
||||
|
||||
- **`pg_isready` first.** `podman exec` on a missing container writes
|
||||
`Error: no container with name or ID "postgres" found` and exits 1 — but the
|
||||
pipeline still "completes", and that message is invisible to a case-sensitive
|
||||
`grep ERROR`. This is the check that turns that into an immediate, obvious stop.
|
||||
- **`grep -v '^CREATE ROLE postgres;$'`.** That is the one statement expected to
|
||||
fail: `pg_dumpall` emits it and initdb has already created the role. The
|
||||
`ALTER ROLE postgres ... PASSWORD` that follows is kept and succeeds, so the
|
||||
superuser ends up with monoco's password hash — the same `123456` anyway.
|
||||
Removing that exact line, rather than tolerating the error, is what allows
|
||||
`ON_ERROR_STOP=1`: any *unexpected* error now aborts the restore and is printed
|
||||
instead of being skipped.
|
||||
- **The database count at the end**, as above.
|
||||
|
||||
The dump was made by `pg_dump` 17.5 and is replayed by `psql` 18: forward
|
||||
restores are the supported direction, and source and target are both the
|
||||
Alpine/musl image, so the `en_US.utf8` locale the databases were created with
|
||||
resolves on both sides.
|
||||
|
||||
If the restore does stop, fix the cause and start over — `ON_ERROR_STOP=1` can
|
||||
leave the cluster half-loaded, and re-running on top of that adds "already
|
||||
exists" noise rather than converging.
|
||||
|
||||
```bash
|
||||
# destroys everything in the emily cluster
|
||||
ssh emily 'systemctl --user stop postgres.service
|
||||
rm -rf ~/podman-data/postgres/18'
|
||||
cd prepare-server && ansible-playbook -u tanshu ../postgres/playbook.yml
|
||||
```
|
||||
|
||||
That `rm -rf` needs no `podman unshare` precisely because the role creates the
|
||||
version directory as `tanshu` rather than letting the container strand it as a
|
||||
subuid-owned directory. If it ever fails with permission denied, that is the
|
||||
assumption breaking — check `ls -ln ~/podman-data/postgres/18`.
|
||||
|
||||
## 6. Verify
|
||||
|
||||
```bash
|
||||
# server version, and the databases with their sizes — compare against the table above
|
||||
ssh emily 'podman exec postgres postgres --version'
|
||||
ssh emily 'podman exec -e PGPASSWORD=123456 postgres psql -U postgres -tAc \
|
||||
"select datname, pg_size_pretty(pg_database_size(datname)) from pg_database order by pg_database_size(datname) desc"'
|
||||
|
||||
# all three roles made it
|
||||
ssh emily 'podman exec -e PGPASSWORD=123456 postgres psql -U postgres -c "\du"'
|
||||
```
|
||||
|
||||
Then the check that actually matters — the same per-database table count on both
|
||||
sides. Feed the loop to the remote shell over stdin so the quoting stays sane:
|
||||
|
||||
```bash
|
||||
ssh monoco 'bash -s' <<'EOF'
|
||||
EXEC="docker exec -e PGPASSWORD=123456 postgres"
|
||||
for db in $($EXEC psql -U postgres -tAc "select datname from pg_database where datallowconn and not datistemplate and datname <> 'postgres' order by 1"); do
|
||||
printf '%-16s %s\n' "$db" "$($EXEC psql -U postgres -d "$db" -tAc "select count(*) from information_schema.tables where table_schema not in ('pg_catalog','information_schema')")"
|
||||
done
|
||||
EOF
|
||||
|
||||
ssh emily 'bash -s' <<'EOF'
|
||||
EXEC="podman exec -e PGPASSWORD=123456 postgres"
|
||||
for db in $($EXEC psql -U postgres -tAc "select datname from pg_database where datallowconn and not datistemplate and datname <> 'postgres' order by 1"); do
|
||||
printf '%-16s %s\n' "$db" "$($EXEC psql -U postgres -d "$db" -tAc "select count(*) from information_schema.tables where table_schema not in ('pg_catalog','information_schema')")"
|
||||
done
|
||||
EOF
|
||||
```
|
||||
|
||||
Save both outputs and diff them — identical table counts per database is the
|
||||
signal that the restore was complete. Sizes will differ slightly and that is
|
||||
normal (toast/fillfactor are not byte-reproducible).
|
||||
|
||||
Also confirm ownership is what the playbook promises, because a wrong `keep-id`
|
||||
shows up here and nowhere else:
|
||||
|
||||
```bash
|
||||
ssh emily 'ls -ln ~/podman-data/postgres/18/docker | head'
|
||||
# PGDATA and its contents must be owned by tanshu:tanshu, not a 165536+ subuid
|
||||
```
|
||||
|
||||
## 7. Afterwards
|
||||
|
||||
- **Keep monoco's cluster.** Do not `docker rm postgres` and do not drop
|
||||
databases: a stopped, intact 17 cluster is the fastest rollback there is.
|
||||
Leave the dump in `~/pg-migrate` on both hosts for the same reason.
|
||||
- **Stop the old writers before the new ones start** if both hosts could serve
|
||||
the same app, or you will have two databases diverging behind the same site.
|
||||
- **Apps connect unchanged.** `pg_dumpall` restores each database under its
|
||||
original name with its original role grants, and the app playbooks'
|
||||
`postgres_container: postgres` resolves over the attached app networks — same
|
||||
container name, same `db` alias, same password.
|
||||
- **The dump is not a backup regime.** It is a migration artifact. Per-database
|
||||
`pg_dump` on a schedule is still owed (see the Backups section of
|
||||
[readme.md](readme.md)).
|
||||
|
||||
## Rollback
|
||||
|
||||
There is nothing to reverse on `emily` beyond stopping the new container:
|
||||
`systemctl --user stop postgres.service`. `monoco` was never modified — its
|
||||
container was only read — so rolling back is starting the old stack again.
|
||||
|
||||
The one exception is the quiesce step, if you took it: `docker start` the
|
||||
`WRITERS` list above.
|
||||
Reference in new issue
Block a user