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.
PostgreSQL (rootless Podman + Quadlet)
Runs PostgreSQL as a rootless Podman container managed by systemd user
units (Quadlet). Replaces the docker_container playbook, which published
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, 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:
- 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 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 dbtwice.
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 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/dockerand the version directory above it — is owned bypodman_user, never by a subuid; - plain
tar,pg_dump,cpandrm -rfwork withoutpodman unshareand withoutsudo; - the reverse — chowning the data to
podman_userbefore 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
| Variable | Purpose |
|---|---|
podman_user, podman_data_dir, quadlet_dir |
Rootless-Podman contract, duplicated from caddy/vars/default.yml |
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
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.
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:
| 17 | 18+ | |
|---|---|---|
VOLUME |
/var/lib/postgresql/data |
/var/lib/postgresql |
PGDATA |
/var/lib/postgresql/data/pgdata |
/var/lib/postgresql/18/docker |
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.
monoco runs 17.5, so the data comes across as a logical dump, not a copy of
PGDATA: 17 data files are unreadable by an 18 server. The one-time move is
step-by-step here.
No published port
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):
ansible-playbook -u tanshu postgres/playbook.yml
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, and
are not run here. A host that has already run caddy/playbook.yml has them. To
apply them on their own:
- 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 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:
podman exec -e PGPASSWORD="..." postgres \
pg_dump -U postgres nextcloud | bzip2 > "$WORKDIR/$DB_DUMP_NAME"
Whole-cluster form, which is what a migration uses:
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: 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
PGDATAcannot simply be copied across the major version; - the 18 image moved PGDATA to
/var/lib/postgresql/18/dockerand 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 postgresis active, and stays active after a rebootpodman exec postgres pg_isready -U postgresreports accepting connectionspodman exec postgres psql -U postgres -c '\l'lists the expected databasesss -tlnp | grep 5432on the host prints nothingls -ln ~/podman-data/postgres/18/dockeris owned bytanshu, not a165536+subuidls -ln ~/podman-data/postgres/18is owned bytanshutoo — if it is not, the container created it as subuid andrm -rfwill fight you- From each client app's container:
getent hosts postgresandgetent hosts db - Each app can read and write — that is the check that catches a wrong
keep-id - A
pg_dumpof every database completes and restores into a scratch database