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:
tanshu committed 2026-10-16 06:52:27 +05:30
1 parent 060173db5e
commit 4297e5a71e
11 files changed
+1079 -88

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
<?php
phpinfo();
-19
View File
@@ -1,19 +0,0 @@
server {
listen {{ http_port }};
root /var/www/html;
index index.php index.html index.htm index.nginx-debian.html;
server_name {{ http_host }};
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
}
location ~ /\.ht {
deny all;
}
}
+315
View File
@@ -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.
+28 -33
View File
@@ -1,37 +1,32 @@
#################################################
# DO Community Playbooks: Docker
#################################################
--- ---
- hosts: all # Deploys PostgreSQL as a rootless Podman Quadlet unit.
become: true #
# Connects as the container user and runs everything as that user: the Quadlet
# units belong to it, so the connection's PAM session is what makes
# `systemctl --user` work without any environment juggling.
#
# gather_facts is required: podman_user and the home directory come from the
# user_id / user_dir facts.
#
# The rootless-Podman prerequisites (installing podman, enabling lingering, the
# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied
# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and
# a host that has already run caddy/playbook.yml has them:
#
# - name: Rootless Podman prerequisites
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml"
#
# The variables those tasks need are duplicated at the top of vars/default.yml,
# so this play stands on its own.
#
# Run with: ansible-playbook -u tanshu postgres/playbook.yml
- name: Deploy PostgreSQL as a rootless Podman Quadlet
hosts: emily
gather_facts: true
vars_files: vars_files:
- vars/default.yml - vars/default.yml
tasks: roles:
- name: Pull default Postgresql image # The base postgres unit. It does NOT know which app networks it serves.
docker_image: # Each service attaches itself via postgres/roles/attach.
name: "{{ postgres_image }}" - postgres
source: pull
force_source: yes
- name: Ensure Nextcloud Directory exists
file:
path: "/var/lib/{{ host_directory }}"
state: directory
mode: 0755
- name: Create postgres container
docker_container:
name: "{{ postgres_name }}"
image: "{{ postgres_image }}"
state: started
restart_policy: "unless-stopped"
env:
POSTGRES_USER: "{{ pg_user }}"
POSTGRES_PASSWORD: "{{ pg_password }}"
POSTGRES_DB: 'postgres'
PGDATA: "{{ data_location }}/pgdata"
published_ports:
- 5432:5432
volumes:
- "/var/lib/{{ host_directory }}/data:{{ data_location }}"
+307 -30
View File
@@ -1,46 +1,323 @@
# Docker on Ubuntu 18.04 # PostgreSQL (rootless Podman + Quadlet)
This playbook will install Docker an Ubuntu 18.04 machine, as explained in the guide on Runs PostgreSQL as a **rootless Podman** container managed by **systemd user
[How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). units** (Quadlet). Replaces the `docker_container` playbook, which published
A number of containers will be created with the options specified in the `vars/default.yml` variable file. `5432` on `0.0.0.0` and kept its data in `/var/lib/postgresql`.
**Postgres is the base unit and knows nothing about its clients.** It does not
list any app network and does not need editing when a service is added or
removed. Each service attaches *itself*. That mirrors [caddy](../caddy/readme.md),
which is the reverse proxy end of the same idea.
## Layout
```
postgres/
├── playbook.yml
├── vars/default.yml
└── roles/
├── postgres/ # the base postgres.container unit, env file, handlers
└── attach/ # includable task file: a service attaches itself to Postgres
```
Everything is installed under `{{ quadlet_dir }}`
(`~/.config/containers/systemd/`). State lives under `{{ host_directory }}`
(`~/podman-data/postgres/`).
## How services attach themselves
Postgres joins the app network of every service that needs a database. A service
joins by writing one Quadlet *drop-in* next to `postgres.container`:
```
~/.config/containers/systemd/
├── postgres.container # base unit, no app networks
├── postgres.container.d/ # ← drop-ins, one per client service
│ ├── 10-network-aliases.conf # [Container] NetworkAlias=db (shared)
│ ├── 50-nextcloud.conf # [Container] Network=nextcloud.network
│ ├── 50-gitea.conf
│ └── 50-tandoor.conf
├── nextcloud.network
├── gitea.network
└── tandoor.network
```
A service playbook does both halves — its own network, then the attach:
```yaml
- name: Register this service with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
vars:
network_name: "{{ container_name }}"
- name: Attach this service to Postgres
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
vars:
network_name: "{{ container_name }}"
```
The play must also run the `podman` role (for `podman_user` and `quadlet_dir`)
and define a `Restart Postgres` handler — see
[roles/postgres/handlers/main.yaml](roles/postgres/handlers/main.yaml) for the
exact snippet.
Order matters in one direction only: the `attach` include needs
`<name>.network` to already exist, which is why Caddy's `register` comes first.
It fails with a readable message if it does not, because a drop-in naming a
missing `.network` file makes Quadlet reject the *whole* postgres unit with an
error that never mentions the drop-in.
### Names: `postgres` and `db`
Both resolve on every attached network. `postgres` comes from
`ContainerName=postgres`; `db` is added by `10-network-aliases.conf`. The alias
exists because the Docker-era playbooks are not consistent — nextcloud, gitea,
tandoor and outline connect to `db`, the rest to `postgres`.
`NetworkAlias=` applies to *every* network the container joins, which is exactly
what a hub wants. It lives in one shared drop-in with a fixed value rather than
in each service's `50-*.conf`, for two reasons:
- it is not per-service — if one service could set a different value, the shared
file would be rewritten on every run, and every service writes the same bytes;
- with the alias in each service's own file, two attached services would make
Quadlet pass `--network-alias db` twice.
It is *not* in `postgres.container` itself for a duller reason: on a fresh host,
before anything has attached, the unit has no `Network=` at all, and asking
Podman for an alias on the default network is not a configuration worth
depending on.
### One way to attach, and one rule
[roles/attach](roles/attach/tasks/main.yaml) is the shared include, and is what
every service uses. A service may instead write the same two files from its own
role, but then it **must write identical bytes** on purpose: the drop-ins live in
one directory, so anything less means whichever play runs last rewrites the
other's file on every run. If you add a second form, make it match.
Whichever form you use, **flush handlers before the app starts on a first
attach.** The restart that joins Postgres to the new network is a handler, so by
default it runs at the end of the play — after the app has already come up and
failed to resolve `db`. `tandoor/roles/postgres` is the working example.
### Why drop-ins rather than listing the networks
Listing app networks in `postgres.container` would mean editing this playbook
every time a service is added, and again on removal. Drop-ins survive
re-templating of the base unit, are the mechanism Quadlet documents for this,
and make removal trivial: delete `50-tandoor.conf`, `daemon-reload`, done.
### The trade-off
A new drop-in changes the generated unit, so Postgres is **recreated** to join
the network — a few seconds of downtime for *every* connected app, not just the
new one. Quadlet has no live equivalent of `docker network connect`. If that
matters, `podman network connect <net> postgres` takes effect immediately, and
the drop-in makes it survive the next recreate; the `attach` role does not do
this by default because it would make the play non-idempotent.
### Why not one shared database network
Every app on a shared `postgres_net` could reach every other app on it. The
generated `<name>.network` units set `Options=isolate=true`, so keeping Postgres
as the only common member of otherwise separate networks is what preserves the
per-app isolation Caddy's networks give you.
## Who runs as whom
`UserNS=keep-id:uid=70,gid=70`. `70` is the uid the *server* writes as: the
official Alpine entrypoint starts as root, chowns PGDATA to the `postgres`
account, and drops privileges, so `docker exec postgres id -u` reporting `0` is
misleading. `keep-id` maps that one uid onto `podman_user`, which means:
- the whole data root — `~/podman-data/postgres/18/docker` and the version
directory above it — is owned by `podman_user`, never by a subuid;
- plain `tar`, `pg_dump`, `cp` and `rm -rf` work without `podman unshare` and
without `sudo`;
- the reverse — chowning the data to `podman_user` before first start — is also
correct, which is what makes bringing data across simple.
PGDATA and its version directory are created by the role rather than left to the
container's own `mkdir -p`. The entrypoint runs as container-root — a subuid
under `keep-id` — and only chowns PGDATA and its contents, so the version
directory would be stranded as subuid-owned: readable but not removable by
`podman_user`. Creating the chain up front is what makes the bullets above true.
Like Caddy, `podman_user` is derived from the connection
(`ansible_facts['user_id']`) and the play carries no `become`. This playbook does
not run the podman role, so it has no guard against connecting as `root`; the
prerequisites are applied separately (see Running).
## Settings ## Settings
- `create_containers`: number of containers to create. | Variable | Purpose |
- `default_container_name`: default name for new containers. |---|---|
- `default_container_image`: default image for new containers. | `podman_user`, `podman_data_dir`, `quadlet_dir` | Rootless-Podman contract, duplicated from [caddy/vars/default.yml](../caddy/vars/default.yml) |
- `default_container_command`: default command to run on new containers. | `postgres_container` | Container / unit name, and the prefix of the drop-in directory |
| `postgres_major` | The one knob: pins both the image tag and the PGDATA path |
| `postgres_image` | `postgres:<major>-alpine`, fully qualified on purpose (see below) |
| `postgres_uid` / `postgres_gid` | The uid the server writes as — `70` in the Alpine image |
| `host_directory` | Where state lives (`~/podman-data/postgres`) |
| `postgres_mount` / `pgdata` | The image's VOLUME, and PGDATA inside it (`/var/lib/postgresql`, `/var/lib/postgresql/18/docker`) |
| `postgres_env_file` | `0600` file holding the credentials, read via `EnvironmentFile=` |
| `postgres_dropin_dir` | Where services attach their networks |
| `pg_user` / `pg_password` / `pg_database` | Credentials and the default database |
| `postgres_timeout_start` / `postgres_timeout_stop` | initdb on first start, clean shutdown on stop |
### Image and PGDATA layout
## Running this Playbook `docker.io/library/postgres:18-alpine` — fully qualified because rootless Podman
resolves short names against `unqualified-search-registries`, and a Quadlet unit
has no TTY to answer a short-name prompt. `-alpine` still floats within 18.x for
security patches.
Quick Steps: **18 moved both the image's VOLUME and its default PGDATA**, which is why
`postgres_major` exists and why the mount is a *directory* rather than the data
directory itself:
### 1. Obtain the playbook | | 17 | 18+ |
```shell |---|---|---|
git clone https://github.com/do-community/ansible-playbooks.git | `VOLUME` | `/var/lib/postgresql/data` | `/var/lib/postgresql` |
cd ansible-playbooks/docker_ubuntu1804 | `PGDATA` | `/var/lib/postgresql/data/pgdata` | `/var/lib/postgresql/18/docker` |
```
### 2. Customize Options The 18 entrypoint *refuses to start* if it finds a cluster at the old
`/var/lib/postgresql/data` path — that check exists so that bumping the image tag
without upgrading the database fails loudly instead of appearing to be data loss.
Keeping the major version in the path is also what lets a later
`pg_upgrade --link` run without mount-boundary problems.
```shell `monoco` runs 17.5, so the data comes across as a **logical dump**, not a copy of
nano vars/default.yml `PGDATA`: 17 data files are unreadable by an 18 server. The one-time move is
``` [step-by-step here](migrate-from-monoco.md).
```yml ### No published port
#vars/default.yml
---
create_containers: 4
default_container_name: docker
default_container_image: ubuntu
default_container_command: sleep 1d
```
### 3. Run the Playbook There is deliberately no `PublishPort`. The Docker-era playbook published
`5432:5432` on `0.0.0.0` with UFW inactive, so the database was reachable from
the internet; it needs no host port at all now that clients reach it over the
app networks. Verify with `ss -tlnp | grep 5432` — it should print nothing.
### Credentials
`pg_password` is still the shared `123456` from the app playbooks, so a migrated
app connects unchanged. It is written to `~/podman-data/postgres/postgres.env`
mode `0600` and read with `EnvironmentFile=` rather than as `Environment=` lines,
because the unit file itself is world-readable in `~/.config`. Note that
`podman inspect postgres` still prints the environment to anyone who can run
podman as that user; a real secret store is the next step, and is a
one-file change here plus the `postgres_password` vars in the app playbooks.
## Running
The play targets `emily` directly (`hosts: emily`):
```command ```command
ansible-playbook -l [target] -i [inventory file] -u [remote user] playbook.yml ansible-playbook -u tanshu postgres/playbook.yml
``` ```
For more information on how to run this Ansible setup, please check this guide: [How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). The inventory that defines `emily` lives in `prepare-server/`, whose `ansible.cfg`
names it — run from there, or pass an inventory explicitly.
This play applies **only** the Postgres unit and its data directory. The
rootless-Podman prerequisites — installing podman, `loginctl enable-linger`, the
low-port sysctl and the Quadlet directory — are the shared task file at
[caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), and
are not run here. A host that has already run `caddy/playbook.yml` has them. To
apply them on their own:
```yaml
- name: Rootless Podman prerequisites
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml"
```
The three variables those tasks need are duplicated at the top of
[vars/default.yml](vars/default.yml) rather than imported, because a play cannot
include Caddy's vars file wholesale — it also sets `host_directory`.
## Operational notes
```
systemctl --user status postgres
systemctl --user restart postgres
journalctl --user -u postgres -f
podman exec -it postgres psql -U postgres
podman exec postgres pg_isready -U postgres
podman inspect postgres --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
```
The play waits on `pg_isready` inside the container rather than trusting
systemd. `Restart=always` means the unit reports `active` even while the
container crash-loops, so a wrong `keep-id` (an entrypoint permission error,
over and over) would otherwise look like success.
`systemctl --user enable postgres` is neither needed nor possible: the unit is
*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself.
### Debugging a unit that will not generate
```
/usr/lib/systemd/user-generators/podman-user-generator --user --dryrun
systemd-analyze --user --generators=true verify postgres.service
```
The most common cause is a `Network=` naming a `.network` file that does not
exist — which is why `attach` checks first.
## Backups
`--link postgres:db` does not exist in Podman, so every `*-backup.sh` that uses
it (`db-`, `bitwarden-`, `hiwikijs-`, `kakawa-`, `nextcloud-`, `tandoor-`) is
broken on this host. Dump in place instead — no helper container, no networking,
no `--user` juggling:
```bash
podman exec -e PGPASSWORD="..." postgres \
pg_dump -U postgres nextcloud | bzip2 > "$WORKDIR/$DB_DUMP_NAME"
```
Whole-cluster form, which is what a migration uses:
```bash
podman exec -e PGPASSWORD="..." postgres pg_dumpall -U postgres > cluster.sql
```
Because the data is owned by `podman_user`, the `sudo tar` / `chown $(logname)`
gymnastics in the old scripts can go away too. The database is also the reason
root's crontab cannot drive it any more: root cannot `podman exec` into another
user's rootless containers, so anything scheduled belongs in a **systemd user
timer** next to the Quadlet units.
## Bringing data from the Docker host
The one-time move of monoco's cluster — 12 databases, ~900 MB, and a 17 → 18
major-version jump — is written out command by command in
**[migrate-from-monoco.md](migrate-from-monoco.md)**: dump, transfer, restore,
verify, rollback.
The short version of why it is a dump rather than a file copy:
- **17 data files are unreadable by an 18 server**, so `PGDATA` cannot simply be
copied across the major version;
- **the 18 image moved PGDATA** to `/var/lib/postgresql/18/docker` and refuses to
start when it finds a cluster at the old path;
- for a cluster well under a gigabyte, a logical dump is both the safer and the
faster thing to get right, and it re-creates the three roles
(`postgres`, `oc_tanshu`, `zitadel`) in the same pass.
`pg_dumpall` restores each database under its original name with its original
grants, so the app playbooks connect unchanged — same container name, same `db`
alias, same shared password.
## Verification
- [ ] `systemctl --user status postgres` is active, and stays active after a reboot
- [ ] `podman exec postgres pg_isready -U postgres` reports accepting connections
- [ ] `podman exec postgres psql -U postgres -c '\l'` lists the expected databases
- [ ] `ss -tlnp | grep 5432` on the host prints nothing
- [ ] `ls -ln ~/podman-data/postgres/18/docker` is owned by `tanshu`, not a `165536+` subuid
- [ ] `ls -ln ~/podman-data/postgres/18` is owned by `tanshu` too — if it is not, the
container created it as subuid and `rm -rf` will fight you
- [ ] From each client app's container: `getent hosts postgres` and `getent hosts db`
- [ ] Each app can read **and** write — that is the check that catches a wrong `keep-id`
- [ ] A `pg_dump` of every database completes and restores into a scratch database
+93
View File
@@ -0,0 +1,93 @@
---
# Attach a service's network to Postgres.
#
# Postgres is the *base* unit and knows nothing about its clients, the same way
# Caddy knows nothing about its upstreams. A service that needs a database adds
# itself by dropping files next to postgres.container, and Quadlet merges them in.
#
# Include this from a service playbook:
#
# - name: Attach this service to Postgres
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
# vars:
# network_name: "{{ network_name }}"
#
# The including play must:
#
# 1. have created {{ network_name }}.network already — caddy/roles/register
# does that, and every service here that needs a database is also behind
# Caddy, so its register include comes first;
# 2. run the `podman` role (or include its task file and define the same four
# vars), for podman_user and quadlet_dir;
# 3. define a "Restart Postgres" handler — see
# roles/postgres/handlers/main.yaml for the exact snippet; and
# 4. flush handlers before starting the app, on a first attach. This include
# notifies that restart, so by default it runs at the end of the play —
# after the app has already started and failed to resolve `db`. A
# `meta: flush_handlers` immediately after this include is enough; see
# tandoor/roles/postgres for the working example.
#
# It runs as the connection user, which must be the rootless podman_user.
#
# A service may instead write these two files from its own role; the bytes have
# to match this file exactly, or the two will overwrite each other on every run.
- name: "Check that the {{ network_name }} network unit exists"
ansible.builtin.stat:
path: "{{ quadlet_dir }}/{{ network_name }}.network"
register: postgres_attach_network
# A drop-in naming a .network file that does not exist makes Quadlet fail to
# generate the whole postgres unit, with a message that does not mention the
# drop-in at all. Fail here instead, where the cause is obvious.
- name: "Fail if {{ network_name }} has no network unit"
ansible.builtin.assert:
that: postgres_attach_network.stat.exists
fail_msg: >-
{{ quadlet_dir }}/{{ network_name }}.network does not exist, so attaching
it to Postgres would break the postgres unit. Create the network first
(caddy/roles/register does it), then include this file.
- name: Ensure the Postgres Quadlet drop-in directory exists
ansible.builtin.file:
path: "{{ quadlet_dir }}/{{ postgres_container | default('postgres') }}.container.d"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
- name: "Attach {{ network_name }} to Postgres"
ansible.builtin.copy:
dest: >-
{{ quadlet_dir }}/{{ postgres_container | default('postgres') }}.container.d/50-{{ network_name }}.conf
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
content: |
# Added by the {{ network_name }} playbook. Do not edit by hand.
#
# Deleting this file removes the network from {{ postgres_container | default('postgres') }} on
# the next daemon-reload; nothing else changes.
[Container]
Network={{ network_name }}.network
notify: Restart Postgres
# NetworkAlias= applies to every network the container joins, which is exactly
# right for a hub: every app in this repo reaches postgres as `db` (nextcloud,
# gitea, tandoor and outline use the alias; the rest use the container name).
# It is a fixed value in one shared file, not a per-service option, so the same
# bytes are written no matter how many services attach — two services writing
# their own aliases would make Quadlet pass --network-alias twice.
- name: "Give {{ postgres_container | default('postgres') }} the shared 'db' alias"
ansible.builtin.copy:
dest: >-
{{ quadlet_dir }}/{{ postgres_container | default('postgres') }}.container.d/10-network-aliases.conf
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
content: |
# Shared container aliases for {{ postgres_container | default('postgres') }}.
# Managed by Ansible (the service playbooks that use it). Do not edit.
[Container]
NetworkAlias=db
notify: Restart Postgres
@@ -0,0 +1,31 @@
---
# All handlers run as the connection user ({{ podman_user }}), so systemctl
# --user works without setting XDG_RUNTIME_DIR.
- name: Reload systemd user units
ansible.builtin.command: systemctl --user daemon-reload
changed_when: true
listen: Reload systemd user units
- name: Restart Postgres
ansible.builtin.systemd_service:
name: "{{ postgres_unit }}"
scope: user
state: restarted
daemon_reload: true
listen: Restart Postgres
# Reference implementation for service playbooks. A service that attaches its
# network with postgres/roles/attach must notify "Restart Postgres", and defines
# its own copy of this handler, e.g.:
#
# - name: Restart Postgres
# ansible.builtin.systemd_service:
# name: postgres.service
# scope: user
# state: restarted
# daemon_reload: true
# listen: Restart Postgres
#
# A new drop-in changes the generated unit, so Postgres has to be recreated to
# join the network — Quadlet has no live equivalent of `podman network connect`.
+155
View File
@@ -0,0 +1,155 @@
---
# Start PostgreSQL
#
# Everything here runs as {{ podman_user }} — the connection user. No become,
# no XDG_RUNTIME_DIR: SSH gave us a real PAM session, so systemd's user manager
# is reachable via the standard environment.
- name: Ensure the Postgres data directory exists
ansible.builtin.file:
path: "{{ host_directory }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
# PGDATA and its version directory are created here rather than left to the
# container's own `mkdir -p`. 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 the version directory would be left owned by 165536 and the host user could
# not even `rm -rf` it without podman unshare. Creating the whole chain up front
# keeps every directory in the data root owned by podman_user.
#
# Empty PGDATA is exactly right: the 18 entrypoint reads that as "run initdb".
- name: Ensure the versioned data directory exists
ansible.builtin.file:
path: "{{ pgdata_host_dir | dirname }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
- name: Ensure PGDATA exists
ansible.builtin.file:
path: "{{ pgdata_host_dir }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0700'
- name: Ensure the Quadlet drop-in directory exists
ansible.builtin.file:
path: "{{ postgres_dropin_dir }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
# The unit file is 0644 in ~/.config, so the password does not belong in it.
# This file is 0600 and is read by the container through EnvironmentFile=.
- name: Write the Postgres environment file
ansible.builtin.template:
src: postgres.env.j2
dest: "{{ postgres_env_file }}"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0600'
notify: Restart Postgres
- name: Install the base Postgres Quadlet unit
ansible.builtin.template:
src: postgres.container.j2
dest: "{{ quadlet_dir }}/{{ postgres_container }}.container"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify:
- Reload systemd user units
- Restart Postgres
# The wait is part of the block on purpose. `Restart=always` means systemd
# reports the unit active even while the container crash-loops — a wrong
# keep-id shows up as an entrypoint permission error over and over — so
# is-active alone would call a broken database healthy.
- name: Start Postgres and wait for it to accept connections
block:
- name: Start Postgres
ansible.builtin.systemd_service:
name: "{{ postgres_unit }}"
scope: user
state: started
daemon_reload: true
# pg_isready is the server's own answer, and it fails while initdb is still
# running on a fresh data directory (it exits 2, so the retry loop holds).
- name: Wait for Postgres to accept connections
ansible.builtin.command: >-
podman exec {{ postgres_container }}
pg_isready -U {{ pg_user }} -d {{ pg_database }}
register: postgres_ready
until: postgres_ready.rc == 0
retries: 12
delay: 5
changed_when: false
rescue:
# systemd reports "control process exited with error code" and nothing
# else, which is useless for the usual failures (a bad uid mapping, an
# unwritable PGDATA, a missing image). Pull the real reason out.
- name: Collect Postgres diagnostics
ansible.builtin.command: "{{ item }}"
loop:
- "systemctl --user status {{ postgres_unit }} --no-pager"
- "journalctl --user -u {{ postgres_unit }} --no-pager -n 40"
register: postgres_diag
changed_when: false
failed_when: false
- name: Show Postgres diagnostics
ansible.builtin.debug:
msg: "{{ postgres_diag.results | map(attribute='stdout') | list }}"
- name: Fail
ansible.builtin.fail:
msg: "Postgres did not come up — see the diagnostics above."
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl enable` is neither needed nor possible on a generated unit.
- name: Verify the Postgres unit is active
ansible.builtin.command: "systemctl --user is-active {{ postgres_unit }}"
register: postgres_active
changed_when: false
failed_when: postgres_active.stdout != 'active'
- name: List the services attached to Postgres
ansible.builtin.find:
paths: "{{ postgres_dropin_dir }}"
patterns: "*.conf"
register: postgres_dropins
- name: Report the networks Postgres is attached to
ansible.builtin.command: >-
podman inspect {{ postgres_container }}
--format '{% raw %}{{ range $k, $v := .NetworkSettings.Networks }}{{ $k }} {{ end }}{% endraw %}'
register: postgres_networks_now
changed_when: false
failed_when: false
- name: Show attached services
ansible.builtin.debug:
msg: >-
Postgres networks:
{{ postgres_networks_now.stdout | default('(container not running)') }}.
Attached by:
{{ postgres_dropins.files | map(attribute='path') | map('basename') | join(', ')
| default('(nothing yet)', true) }}.
# Say this out loud rather than let it be discovered by a failing app: with no
# drop-in, Postgres has no Network= at all, so it sits on the default Podman
# network and no app network can resolve it.
- name: Note that no service has attached yet
ansible.builtin.debug:
msg: >-
No service has attached to Postgres. It is on the default Podman network
only, and no app can resolve it until a service playbook includes
postgres/roles/attach (see readme.md).
when: postgres_dropins.matched == 0
@@ -0,0 +1,50 @@
# PostgreSQL — rootless Podman Quadlet unit.
# Managed by Ansible (postgres/roles/postgres). Do not edit by hand.
#
# Postgres deliberately does NOT list the app networks it serves. Each service
# attaches itself by dropping a file into:
#
# {{ postgres_dropin_dir }}/
#
# which Quadlet merges into this unit. Removing a service therefore never means
# editing this file. See readme.md, "How services attach themselves".
[Unit]
Description=PostgreSQL database
Documentation=https://hub.docker.com/_/postgres
After=network-online.target
Wants=network-online.target
[Container]
ContainerName={{ postgres_container }}
Image={{ postgres_image }}
# Map the container's postgres account (uid {{ postgres_uid }}) onto
# {{ podman_user }} on the host, so PGDATA stays owned by {{ podman_user }} and
# never by a subuid. The official entrypoint starts as root, chowns PGDATA to
# uid {{ postgres_uid }}, then drops privileges — so everything it writes lands
# on {{ podman_user }}, and plain tar / pg_dump backups keep working.
UserNS=keep-id:uid={{ postgres_uid }},gid={{ postgres_gid }}
# No PublishPort, on purpose. The Docker-era playbook published 5432 on
# 0.0.0.0 (and a UFW rule was never there to narrow it); here Postgres is
# reachable only from the app networks that attach themselves to it.
#
# The mount is the image's VOLUME, which in 18 is the *parent* directory — the
# major version is in the path instead ({{ pgdata }}). Mounting
# /var/lib/postgresql/data here instead would make the 18 entrypoint refuse to
# start, because that path is where it looks for an un-upgraded 17 cluster.
Volume={{ host_directory }}:{{ postgres_mount }}
# POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB / PGDATA live in this 0600
# file rather than in Environment= lines here: this unit is world-readable.
EnvironmentFile={{ postgres_env_file }}
[Service]
Restart=always
# The first start runs initdb, and a database needs time to shut down cleanly.
TimeoutStartSec={{ postgres_timeout_start }}
TimeoutStopSec={{ postgres_timeout_stop }}
[Install]
WantedBy=default.target
@@ -0,0 +1,10 @@
# Environment for postgres.container.
# Managed by Ansible (postgres/roles/postgres). Do not edit by hand.
#
# Mode 0600 on purpose: the Quadlet unit that reads this file is world-readable,
# so the password must not live there. Note that `podman inspect` still prints
# the resulting environment to anyone who can run podman as this user.
POSTGRES_USER={{ pg_user }}
POSTGRES_PASSWORD={{ pg_password }}
POSTGRES_DB={{ pg_database }}
PGDATA={{ pgdata }}
+90 -4
View File
@@ -1,8 +1,94 @@
--- ---
postgres_name: postgres # ---------------------------------------------------------------------------
postgres_image: postgres:17-alpine # Rootless Podman / Quadlet
# ---------------------------------------------------------------------------
# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that
# file wholesale, because it also sets `host_directory` — to the Caddy data
# directory — and every playbook defines that for itself. Keep these three in
# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start,
# belongs to the podman tasks, which this play does not run: Postgres listens on
# 5432 and publishes nothing.)
#
# podman_user is derived from the connection: this play carries no become, so
# the Quadlet units belong to whoever Ansible connects as. Requires gather_facts
# (set in playbook.yml).
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# PostgreSQL
# ---------------------------------------------------------------------------
# The name the rest of the repo already uses for this container in its
# `postgres_container` vars, and the name every app resolves over the network.
postgres_container: postgres
postgres_unit: "{{ postgres_container }}.service"
# The major version is the one knob, because the image tag and the PGDATA
# directory have to agree.
#
# 18 is deliberate. monoco runs 17.5 and the data is brought across with a
# logical dump — a PGDATA copy cannot cross a major version. See
# migrate-from-monoco.md for the one-time move.
#
# `-alpine` still floats within 18.x, which is what you want for security
# patches; pin a digest here if the image itself must be immutable.
postgres_major: '18'
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts.
postgres_image: "docker.io/library/postgres:{{ postgres_major }}-alpine"
# The uid the server actually writes as — not the image's configured user. The
# Alpine image creates the "postgres" account at uid/gid 70 ("70 is the standard
# uid/gid for postgres in Alpine"), and the entrypoint starts as root, chowns
# PGDATA to it, then drops privileges. keep-id maps that uid onto podman_user,
# so PGDATA ends up owned by podman_user on the host and never by a subuid. That
# is what keeps backups working without podman unshare or sudo.
postgres_uid: 70
postgres_gid: 70
host_directory: "{{ podman_data_dir }}/{{ postgres_container }}"
# Postgres 18 moved both the image's VOLUME and its default PGDATA:
#
# 17 Volume /var/lib/postgresql/data PGDATA /var/lib/postgresql/data/pgdata
# 18+ Volume /var/lib/postgresql PGDATA /var/lib/postgresql/<major>/docker
#
# So the mount is now the parent directory and the version lives in the path.
# That is what the 18 entrypoint expects: it refuses to start — "there appears
# to be PostgreSQL data in ..." — if it finds a cluster in the old layout, and
# the versioned subdirectory is what makes a later `pg_upgrade --link` work
# without mount-boundary trouble.
postgres_mount: /var/lib/postgresql
# Everything under the mount that identifies this cluster, kept as one value so
# the container path and the host path cannot drift apart.
postgres_versioned_subdir: "{{ postgres_major }}/docker"
pgdata: "{{ postgres_mount }}/{{ postgres_versioned_subdir }}"
pgdata_host_dir: "{{ host_directory }}/{{ postgres_versioned_subdir }}"
# Secrets do not go in the unit file: it is world-readable, and `podman inspect`
# prints the environment anyway. They go in a 0600 file the unit reads with
# EnvironmentFile=.
postgres_env_file: "{{ host_directory }}/postgres.env"
# Quadlet drop-in directory for the Postgres unit. Services attach themselves
# here; see the "How services attach themselves" section of readme.md.
# Postgres itself never lists its app networks.
postgres_dropin_dir: "{{ quadlet_dir }}/{{ postgres_container }}.container.d"
# Credentials. This is the placeholder the app playbooks already embed in their
# db_url / POSTGRES_PASSWORD, kept as-is so a migrated app connects unchanged.
# monoco's cluster uses the same one, so the dump restores and the apps connect
# with no password reset. Replace it here and in those playbooks together when
# the fleet moves to a real secret.
pg_user: postgres pg_user: postgres
pg_password: '123456' pg_password: '123456'
data_location: /var/lib/postgresql/data pg_database: postgres
host_directory: postgresql # The first start runs initdb, and a database wants time to shut down cleanly.
postgres_timeout_start: 120
postgres_timeout_stop: 60