Comment blocks now stay within three lines and explain only the non-obvious. Long histories, include examples, and commented-out code are gone. No task, key, or value changed; the only data change is comment text inside the generated unit files.
Wiki.js (rootless Podman + Quadlet)
Runs Wiki.js on emily as a rootless Podman container
managed by a systemd user unit (Quadlet), behind the shared
rootless Caddy, with its data in the shared
rootless Postgres. Replaces the docker_container
deployment on monoco.
The name is split on purpose: the container, unit, Podman network and Postgres
database are all hiwikijs, as they have been since the Docker-era playbook, and
the Caddyfile upstream is hiwikijs:3000. Only the directory is wikijs — the
playbook's name. Renaming it would touch the Caddyfile, the network name and the
database for no gain.
The database is the whole wiki. Every page, every revision, every user and
every uploaded file lives in the hiwikijs database. The image's declared
/wiki/data/content volume is empty on monoco, /wiki/data/uploads is
empty too, and the 49 uploaded assets are rows in the assetData table (5.8 MB of
the 16 MB database). So this is the one service in the fleet where "copy the old
data" is a verification rather than a transfer — the commands that prove it are
in Migrating from monoco.
This corrects the expectation set by
docs/podman-compat-audit.md §3.1 and
docs/backup-plan.md §7.6, which treat the anonymous
volume as Wiki.js's store. What those documents are right about is the bug:
~/bin/hiwikijs-backup.sh archives /var/lib/hiwikijs, which is an empty decoy,
so the wiki had no usable file backup. The Volume= mounts below fix the
mechanism — nothing may live in the container's writable layer — but the bytes it
was meant to capture are in Postgres, and they already moved.
Ordering
Both shared services are already migrated and running on emily:
- caddy/playbook.yml — the reverse proxy, which owns the
Caddyfile this site appends to, supplies the
caddy*.servicehandlers, and is the one play that applies the rootless-Podman prerequisites. - postgres/playbook.yml — deployed, and its cluster
brought across from monoco. The
hiwikijsdatabase is already restored (16 MB, one of the 12 databases in postgres/migrate-from-monoco.md). - This playbook.
Unlike the other services there is no file copy to do first, and there is no
ordering constraint between this playbook and the data: the app is stateless on
disk. Deploy it and point DNS at emily.
wiki.hinchco.in still resolves to monoco. Until the record is repointed,
Caddy on emily cannot obtain a certificate for the site, and the old host is
still what answers. That DNS change — not this playbook — is what performs the
cutover.
Layout
wikijs/
├── playbook.yml
├── vars/default.yml
└── roles/
├── postgres/ # attaches this app's network to Postgres (+ its handler)
├── wikijs/ # data dirs, the 0600 env file, the Quadlet unit, start + verify
└── caddy/ # appends this site's block to the shared Caddyfile
Quadlet units and state on the host:
| Path | What |
|---|---|
~/.config/containers/systemd/hiwikijs.container |
the generated unit (Ansible-templated) |
~/.config/containers/systemd/hiwikijs.network |
the app network, written by caddy/roles/register |
~/.config/containers/systemd/caddy.container.d/50-hiwikijs.conf |
attaches Caddy to that network |
~/.config/containers/systemd/postgres.container.d/50-hiwikijs.conf |
attaches Postgres to it |
~/.config/containers/systemd/postgres.container.d/10-network-aliases.conf |
the shared db alias DB_HOST asks for |
~/podman-data/hiwikijs/content/ |
the image's /wiki/data/content (empty in practice) |
~/podman-data/hiwikijs/uploads/ |
/wiki/data/uploads, the local storage target |
~/podman-data/hiwikijs/hiwikijs.env |
DB_*, mode 0600, read by systemd |
Note the network-name indirection, which is the same everywhere in this repo: the
file is hiwikijs.network and the Podman network is hiwikijs_net, pinned by
NetworkName= in the network unit. The Docker-era container used hiwikijs_net,
so nothing about the network name changes.
What is state, and what is not
| Where | Keep? | Why |
|---|---|---|
Postgres hiwikijs |
required | 32 tables: pages (80), pageHistory, users (2), comments, navigation, settings, and assetData |
assetData table |
required | 49 rows, 5.8 MB — the actual bytes of every uploaded image. A file-level backup of the wiki would miss all of them |
~/podman-data/hiwikijs/content/ |
empty | the image's declared VOLUME. Empty on monoco, and Wiki.js writes nothing to it in this configuration |
~/podman-data/hiwikijs/uploads/ |
empty | the default local storage target. The wiki is configured to store assets in the database instead |
hiwikijs.env |
regenerated | the template reproduces the Docker-era .env exactly |
/var/lib/hiwikijs on monoco |
no | a decoy: an empty directory holding one root-owned 90-byte .env, never mounted into the container |
How it registers with Caddy
Caddy is the base unit and knows nothing about its upstreams. This playbook calls the shared include in caddy/roles/register, which writes the network unit and the drop-in that pulls it into Caddy:
- name: Register Wiki.js's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
It runs in pre_tasks, before the app unit is started, because
hiwikijs.container references hiwikijs.network: Quadlet fails unit
generation — so systemctl --user start reports "unit not found" — when a
referenced .network file does not exist yet. The Postgres attach below depends on
it for the same reason.
The site block itself is appended to the shared Caddyfile by
roles/caddy with blockinfile, giving exactly the
block the Docker-era Caddyfile had:
# BEGIN Ansible managed Caddy snippet for wiki.hinchco.in
wiki.hinchco.in {
reverse_proxy hiwikijs:3000
}
# END Ansible managed Caddy snippet for wiki.hinchco.in
caddyfile_path changed from /var/lib/caddy/conf/Caddyfile to
~/podman-data/caddy/conf/Caddyfile; the marker and the upstream are unchanged, so
running this playbook against the old host's Caddyfile would be a no-op.
The old roles/caddy slurped the Caddyfile and compared strings before appending.
That is gone: blockinfile is idempotent through its marker, and slurp fails
when the file does not exist yet, which is the case on a host where the Caddy
playbook has not run.
How it attaches to Postgres
Same idea at the other end: Postgres knows nothing about its clients, and this
playbook uses the shared include in
postgres/roles/attach rather than
writing those drop-ins itself. That writes two files, both merged into
postgres.container by Quadlet:
| File | Content |
|---|---|
postgres.container.d/50-hiwikijs.conf |
Network=hiwikijs.network |
postgres.container.d/10-network-aliases.conf |
NetworkAlias=db |
DB_HOST=db is what the Docker-era container used — it was --link postgres:db —
and 10-network-aliases.conf is the Quadlet equivalent. postgres, the container
name, resolves too, and the play checks both.
roles/postgres wraps that include for two reasons:
- it defines the
Restart Postgreshandler the include notifies; and - it flushes handlers immediately afterwards. On a first attach the drop-in is
new, so Postgres has to be recreated to join
hiwikijs_net— and that restart is a handler, which by default runs at the end of the play, after thewikijsrole has already started the app. The app would then come up beforedbresolves, fail its own HTTP probe, and fail the play on a machine where nothing is wrong. Flushing applies the drop-in first; on later runs nothing is pending and it is a no-op.
flush_handlers flushes everything pending, which here includes the
Restart Caddy that the register include notified in pre_tasks. That is correct
rather than incidental: Caddy's drop-in is new in the same run, so Caddy has to be
recreated to join the network before it can proxy to hiwikijs.
Note the cost stated in postgres/readme.md: a new drop-in recreates Postgres, which is a few seconds of downtime for every attached app, not just this one.
Settings
| Variable | Value / purpose |
|---|---|
podman_user |
Derived from the connection (ansible_facts['user_id']), not set by hand |
podman_data_dir |
~/podman-data |
quadlet_dir |
~/.config/containers/systemd |
host |
wiki.hinchco.in — the site block Caddy serves |
container_name |
hiwikijs, and the Quadlet unit / container name |
image |
ghcr.io/requarks/wiki:latest — registry + org + repo, so no short-name lookup |
network_name |
hiwikijs → hiwikijs.network / hiwikijs_net |
wikijs_uid / wikijs_gid |
1000 / 1000 — the image's node account, mapped onto podman_user by keep-id |
content_dir |
~/podman-data/hiwikijs/content → the image's /wiki/data/content |
uploads_dir |
~/podman-data/hiwikijs/uploads → /wiki/data/uploads |
env_file |
~/podman-data/hiwikijs/hiwikijs.env, mode 0600 |
container_port |
3000, the port inside the container that Caddy proxies to |
postgres_container / postgres_unit |
The shared database unit this app attaches to |
pg_user / pg_password / pg_database |
Credentials, and the database name — hiwikijs |
db_host |
db, the shared NetworkAlias |
caddy_name / caddyfile_path |
The shared Caddy unit and its Caddyfile |
The three podman_* / quadlet_dir vars are duplicated from
caddy/vars/default.yml, as in every service
playbook. That is a deliberate trade: a play cannot include the Caddy vars file
wholesale, because it also sets host_directory (to the Caddy data directory)
and every service defines that for itself. Keep them in sync.
Like Caddy, podman_user is derived from the connection and the play carries no
become. Because the podman tasks are not run here, there is no guard against
connecting as root; the prerequisites, including that check, live in
caddy/playbook.yml.
keep-id:uid=1000, not uid=0
Wiki.js is the odd one out among the converted services. Vaultwarden, Tandoor,
Caddy and Postgres all run as container-root and use keep-id:uid=0,gid=0. This
image sets USER node, and node is uid/gid 1000:
docker exec hiwikijs id → uid=1000(node) gid=1000(node)
/usr/local/bin/docker-entrypoint.sh is a bare exec "$@" — it chowns nothing, so
whatever uid the image starts as is the uid that writes the bind mounts. Mapping
uid 1000 onto podman_user is what keeps ~/podman-data/hiwikijs owned by
tanshu and leaves no subuid-owned files to trip over backups
(docs/podman-migration.md §4.2 lists hiwikijs
under exactly this mapping).
tanshu is uid 1001, not 1000 —
docs/podman-migration.md §4.1 is about precisely
this trap, and most uid-1000 data on monoco belongs to ubuntu. Nothing in this
playbook assumes the app can read a file just because tanshu owns it; the two
data directories are created as tanshu and the container writes as 1000, which
keep-id maps onto tanshu. The mapping is what makes that true, not the
ownership.
Running
ansible-playbook -u tanshu wikijs/playbook.yml
The play targets emily directly, so the inventory must contain it — the one in
prepare-server/ does, and its ansible.cfg names it.
This play applies only this app's units and data. Its pre_tasks run Caddy's
register include and nothing else. 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.
systemctl --user enable hiwikijs is neither needed nor possible: the unit is
generated, and Quadlet applies [Install] WantedBy=default.target itself.
Migrating from monoco
There is nothing to copy. The database already moved on 2026-10-15, and the app has no file state. What follows is how to confirm that, and what to do in the unlikely case the confirmation fails.
The commands are meant to be run from the machine that holds the monoco and
emily SSH aliases. rsync cannot copy remote-to-remote, so anything that does
move is streamed through a pipe.
1. Prove the file state is empty
The image declares VOLUME /wiki/data/content, which Docker turned into an
anonymous volume under /var/lib/docker/volumes/<hash>/_data. The running
container already has it mounted, so no hash and no sudo are needed to look:
ssh monoco 'bash -s' <<'EOF'
docker exec hiwikijs sh -c '
echo "--- /wiki/data/content (the image VOLUME) ---"; ls -la /wiki/data/content
echo "--- /wiki/data/uploads (local storage target) ---"; ls -la /wiki/data/uploads
echo "--- sizes ---"; du -sh /wiki/data/content /wiki/data/uploads'
EOF
Expected — both listings show only . and .., and both du lines say 4.0K:
--- /wiki/data/content (the image VOLUME) ---
total 8
drwxr-xr-x 2 node node 4096 Mar 24 2025 .
drwxr-xr-x 1 node node 4096 Jul 14 2025 ..
--- /wiki/data/uploads (local storage target) ---
total 8
drwxr-xr-x 2 node node 4096 Jul 14 2025 .
drwxr-xr-x 1 node node 4096 Jul 14 2025 ..
--- sizes ---
4.0K /wiki/data/content
4.0K /wiki/data/uploads
/var/lib/hiwikijs on monoco is a decoy and is worth looking at once so nobody
copies it later. It holds a single root-owned .env and is not mounted into
the container — the container's environment came from env_file: at create time,
which is the Docker-side counterpart of the Quadlet unit's EnvironmentFile=, and
which the role's template reproduces:
ssh monoco 'ls -la /var/lib/hiwikijs; cat /var/lib/hiwikijs/.env'
2. Prove the data is in the database, and already on emily
This is the real check, and it is the whole migration. Run the same block on both
hosts and compare — the only difference is docker exec against podman exec:
ssh monoco 'bash -s' <<'EOF'
EXEC="docker exec -e PGPASSWORD=123456 postgres"
$EXEC psql -U postgres -d hiwikijs -tAc 'select count(*) from pages' | sed 's/^/pages : /'
$EXEC psql -U postgres -d hiwikijs -tAc 'select count(*) from users' | sed 's/^/users : /'
$EXEC psql -U postgres -d hiwikijs -tAc 'select count(*), pg_size_pretty(sum(octet_length(data))::bigint) from "assetData"' | sed 's/^/assets : /'
$EXEC psql -U postgres -tAc "select pg_size_pretty(pg_database_size('hiwikijs'))" | sed 's/^/size : /'
echo "newest edit: $($EXEC psql -U postgres -d hiwikijs -tAc 'select max("updatedAt") from pages')"
EOF
ssh emily 'bash -s' <<'EOF'
EXEC="podman exec -e PGPASSWORD=123456 postgres"
$EXEC psql -U postgres -d hiwikijs -tAc 'select count(*) from pages' | sed 's/^/pages : /'
$EXEC psql -U postgres -d hiwikijs -tAc 'select count(*) from users' | sed 's/^/users : /'
$EXEC psql -U postgres -d hiwikijs -tAc 'select count(*), pg_size_pretty(sum(octet_length(data))::bigint) from "assetData"' | sed 's/^/assets : /'
$EXEC psql -U postgres -tAc "select pg_size_pretty(pg_database_size('hiwikijs'))" | sed 's/^/size : /'
echo "newest edit: $($EXEC psql -U postgres -d hiwikijs -tAc 'select max("updatedAt") from pages')"
EOF
The assetData identifier is quoted because the table is genuinely camelCase —
created "assetData" by Wiki.js's migrations, so unquoted assetdata does not
find it.
Both hosts print the same thing, which is what makes this a verified copy rather than an assumption:
pages : 80
users : 2
assets : 49|5979 kB
size : 16 MB
newest edit: 2024-02-03T03:23:57.645Z
The newest edit line is the one that matters for cutover. The dump was taken
on 2026-10-15; if the newest updatedAt on monoco is later than on emily, the
wiki was edited after the dump and those revisions exist only on the old host. On
this wiki it is not: the newest revision is from February 2024, identical on
both hosts, so the copy is current and the wiki is effectively read-only.
3. If the numbers differ: refresh the database
Only needed if step 2 shows drift (a later updatedAt on monoco, or different
counts). Stop both apps first — nothing may write while the dump is taken, and
drop database needs the connections gone:
ssh monoco 'docker stop hiwikijs'
ssh emily 'systemctl --user stop hiwikijs.service'
# Dump just this database, stream it across, and keep a copy on emily.
set -o pipefail
ssh monoco 'docker exec -e PGPASSWORD=123456 postgres pg_dump -U postgres -Fc hiwikijs' \
| ssh emily 'cat > ~/hiwikijs-refresh.dump'
# Confirm the archive is readable before dropping anything.
ssh emily 'podman exec -i -e PGPASSWORD=123456 postgres pg_restore --list < ~/hiwikijs-refresh.dump | head -5'
ssh emily 'bash -s' <<'EOF'
set -e
EXEC="podman exec -e PGPASSWORD=123456 postgres"
$EXEC psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c 'drop database if exists hiwikijs'
$EXEC psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c 'create database hiwikijs owner postgres'
podman exec -i -e PGPASSWORD=123456 postgres pg_restore --exit-on-error -U postgres -d hiwikijs < ~/hiwikijs-refresh.dump
EOF
# Re-run step 2 — the counts must now match.
-Fc plus pg_restore rather than piping SQL into psql: it is the form
postgres/readme.md recommends for per-database work,
--exit-on-error stops on the first problem instead of half-loading, and
pg_restore --list catches a truncated transfer before the old database is
dropped.
Do not re-run postgres/migrate-from-monoco.md.
That is a whole-cluster pg_dumpall into an empty cluster; against the live one
it fails on the first CREATE DATABASE, and its "start over" block destroys
~/podman-data/postgres/18.
4. Repoint DNS, then stop the old container
The record, not the playbook, is the cutover:
# Before: monoco. After the change: emily.
dig +short wiki.hinchco.in
# 13.202.6.2 ← monoco
# Change the A/AAAA record to emily's address, then confirm it has propagated
# before Caddy on emily can complete an ACME challenge for the site.
dig +short wiki.hinchco.in
# 35.154.14.59 ← emily
Then retire the old instance so the two copies cannot diverge:
ssh monoco 'docker stop hiwikijs'
Rolling back means rolling back both halves, and the DNS change is the deciding
one: docker start hiwikijs on monoco only produces a working site once the record
points back at 13.202.6.2, and monoco's own Postgres 17 cluster has to still be
intact for the wiki to have any content. That cluster is deliberately left alone as
the rollback — do not drop databases on it, exactly as
postgres/migrate-from-monoco.md §7 says.
Two loose ends when the old host is retired:
- Remove the
wiki.hinchco.inblock from monoco's Caddyfile. Monoco's Caddy keeps serving other sites, and once DNS points away it can no longer answer the ACME challenge for this one — which shows up as renewal errors indocker logs caddyforever. It does not affect the other sites. ~/bin/hiwikijs-backup.shis not a backup. It archives/var/lib/hiwikijs, the empty decoy. The database is the wiki; anything scheduled belongs in a systemd user timer doing thepodman exec … pg_dumpform from postgres/readme.md, "Backups".
5. Deploy
cd prepare-server && ansible-playbook -u tanshu ../wikijs/playbook.yml
Verification
systemctl --user status hiwikijs
journalctl --user -u hiwikijs -f
podman logs hiwikijs
# What the playbook checks for you:
podman exec hiwikijs curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/
podman exec hiwikijs getent hosts db
# No anonymous volume was created: the mount is the real directory.
podman inspect hiwikijs --format '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}}{{println}}{{end}}'
# expect: bind /home/tanshu/podman-data/hiwikijs/content -> /wiki/data/content
# bind /home/tanshu/podman-data/hiwikijs/uploads -> /wiki/data/uploads
# Nothing is published host-side, exactly like the Docker-era container. Podman
# lists the image's EXPOSE'd ports with a null host binding, which is Docker's
# `PortBindings: {}` — the port numbers appear, the host side does not.
podman inspect hiwikijs --format '{{json .NetworkSettings.Ports}}'
# expect: {"3000/tcp":null,"3443/tcp":null} — null means "not published"
# Nothing may end up owned by root or by a subuid.
find ~/podman-data/hiwikijs ! -user tanshu # empty = correct
# The site, end to end (after DNS points here).
curl -sSI https://wiki.hinchco.in
Then log in in a browser, open a page, and confirm an image renders. The
image is the check that matters: it is served from the assetData table, so it is
the one that proves the database came across whole. A curl of / cannot make
that check, and neither can a table count.
Also reboot the host and confirm the unit returns unattended — without
loginctl enable-linger tanshu it will not, and the failure is silent.
Notes and gotchas
wiki.tanshu.comis not this service. The Caddyfile on monoco has awiki.tanshu.comblock pointing ataffine:3010; that comes from affine/vars/default.yml, which uses the hostname for a different app. This playbook ownswiki.hinchco.inonly. The two are unrelated, and the similar name is worth knowing before "fixing" either.- There is no
HEALTHCHECKin the image.docker inspect hiwikijsreports.Config.Healthcheckasnull, so thepodman healthcheck runform the Vaultwarden role uses is unavailable here. The play probes HTTP with the image's owncurlinstead, which is the next best thing: it says the app answers, not merely that the process exists. getentis there, despite the Alpine base. The image ships/usr/bin/getent(musl'slibc-utils), so the database-resolution check the other roles use works unchanged. It exits2with no output when a name does not resolve, which is what the play's assert keys off — worth knowing, because a baregetentfailure is silent.latestfloats. Neither the old playbook nor this one pins a version or a digest, so an image bump is a normalpodman pullaway and may run new schema migrations against the 16 MB database on first start. Take apg_dumpfirst when that matters.- The 49 assets have no files.
assetDataholds their bytes, so the wiki is self-contained — but this is whyuploads/is empty, and why a filesystem-level backup of this service would produce an archive that restores to a wiki with no images. Back up the database. - Client IPs. The app sees Caddy's address on the app network, not the
visitor's. Whether the original address would survive the rootless port forward
is moot here: Caddy terminates the connection and proxies over
hiwikijs_neteither way. Nothing in Wiki.js uses the client address for security. - Registering restarts all of Caddy, and attaching restarts Postgres for everyone — a brief restart of every hosted site, and a few seconds of downtime for every attached app. See the trade-offs in caddy/readme.md and postgres/readme.md.
- If the Caddyfile does not exist yet, this playbook creates it containing only the site block. That is a valid Caddyfile; the Caddy playbook's seeded global-options block is then not added, because its seed only fires on a missing or zero-length file. Deploy Caddy first on a fresh host.
- The old
/var/lib/hiwikijsdirectory is not migrated and should not be: it holds one.envwhose contents the role's template reproduces. Nothing in the Docker-era container ever mounted it.
Not done here
- Rotating the shared
123456Postgres password — it lives inhiwikijs.envat mode0600rather than in the world-readable unit file, but it is still the fleet-wide placeholder committed in this repo. - Replacing
~/bin/hiwikijs-backup.shwith a systemd user timer that dumpshiwikijs, and removing the entry from root's crontab — root cannotpodman execinto another user's rootless containers. - Upgrading Wiki.js to 3.x, which is a separate migration with its own schema changes and not something an image tag bump should do unattended.