Retargets the build and the playbook off rootful Docker on monoco, following the conventions already in place on emily's other services: Quadlet units in ~/.config/containers/systemd, per-service data in ~/podman-data, and a shared Caddy that every service registers itself with — a network drop-in plus a marked block in its Caddyfile. Nothing is published on the host; Caddy reaches the containers by name over mozimo_net. - Makefile: podman build + push instead of docker buildx, PLATFORMS=linux/arm64 (both hosts are aarch64). podman has no built-in QEMU the way buildx's builder image does, so a foreign-platform failure now points at qemu-user-static. - deploy.sh: pass COMMIT= to make, which the Makefile actually reads; it was passing TAG=, so the tag silently came from `git rev-parse` instead. - ansible: hosts emily, become false, XDG_RUNTIME_DIR for rootless podman and systemctl --user. Docker modules become Quadlet templates with user-service handlers, and the network role now ships mozimo.network plus the Caddy drop-in rather than calling docker_network. - UserNS=keep-id maps the container's uid 1000 (node) and root (vince) onto the host user, so PocketBase and Vince write host-owned files instead of subuid-owned ones; the play asserts the pinned uid still matches the host. - ansible/README.md: the unit layout, the service-registration convention, and the monoco -> emily migration (PocketBase 23 MB + Vince 119 MB, DNS cutover, rollback). Verified by a check-mode rehearsal against emily (13 ok / 8 changed / 0 failed) and a disposable Quadlet unit on that host proving the uid mapping, the EnvironmentFile and the isolated network. The real build and cutover have not been run.
6.2 KiB
Deploying mozimo
Rootless podman + Quadlet on the host emily (aarch64, Ubuntu 26.04),
fronted by that host's shared Caddy. Nothing here runs as root.
./deploy.sh # build the pushed commit + deploy
ansible-playbook playbook.yml -e image_tag=<commit> [--check --diff]
How it fits together
| Piece | Where |
|---|---|
| Quadlet units | ~/.config/containers/systemd/{mozimo,vince}.container, mozimo.network |
| Service data | ~/podman-data/mozimo/ (PocketBase db + files, .env), ~/podman-data/vince/ |
| Shared Caddy config | ~/podman-data/caddy/conf/Caddyfile — one marked block per service |
| Caddy's network membership | ~/.config/containers/systemd/caddy.container.d/50-mozimo.conf |
Quadlet units are not read by systemd directly: the podman user generator turns
them into user services on daemon-reload, and [Install] WantedBy=default.target
brings them up at boot. systemctl --user is-enabled mozimo reports generated
— that is normal, there is no unit to enable.
Nothing is published on the host. Caddy joins mozimo_net (through the
drop-in) and proxies to the containers by name:
mozimo.in → mozimo:3000 (plus /_/* and /api/* → mozimo:8090)
www.mozimo.in → 301 mozimo.in
cms.mozimo.in → mozimo:8090
stats.mozimo.in → vince:8000
mozimo.in/stats/* → vince:8000 (prefix stripped)
The user id mapping matters: the app image runs as uid 1000 (node) and Vince
as root, so both units use UserNS=keep-id: to map that uid onto tanshu.
Without it, PocketBase and Vince would write files owned by a subuid, and
backups, rsync and tar would all trip over them.
Services register themselves
This host's convention (also used by its tandoor, postgres and caddy
playbooks) is that a service owns its own registration rather than editing a
central list:
- it ships a
<name>.networkQuadlet withNetworkName=<name>_net; - it drops
caddy.container.d/50-<name>.confcontaining[Container] Network=<name>.network, which Quadlet merges into Caddy; - it appends a marked block to Caddy's Caddyfile and reloads it.
Removing a service is the reverse: delete the drop-in, delete the Caddyfile
block, systemctl --user daemon-reload, and the network is no longer Caddy's.
Migrating monoco → emily
monoco is the current host: rootful Docker, data in /var/lib/mozimo, its own
Caddy, DNS for mozimo.in / www / cms / stats all pointing at
13.202.6.2. emily is 35.154.14.59.
Certificates are not copied. emily's Caddy is shared with other sites, and
its ACME storage already holds their certificates — overwriting it would break
them. After the DNS switch emily issues fresh certificates for mozimo.in;
until the DNS switch it retries, which is why the Caddy role can be skipped
first (--skip-tags caddy).
0. Compare the deployed environment with the repo's copy
The playbook uploads ansible/files/.env (gitignored) over whatever is on the
host, so anything that has drifted on monoco would be silently dropped:
ssh monoco 'sudo -n cat /var/lib/mozimo/.env' > /tmp/monoco.env
diff /tmp/monoco.env ansible/files/.env
1. Rehearse against emily without touching DNS
cd ansible
ansible-playbook playbook.yml -e image_tag=$(git rev-parse --short HEAD) --check --diff # what would change
ansible-playbook playbook.yml -e image_tag=$(git rev-parse --short HEAD) --skip-tags caddy
This creates the network, the units and a fresh, empty PocketBase database on emily. Nothing public changes yet.
2. Copy the data (the cutover window)
Stop both sides first. PocketBase keeps its database in SQLite, so a copy taken underneath a running writer can be inconsistent — and on emily the rehearsal left an empty database that this snapshot replaces:
# emily: stop its copy and drop the empty database the rehearsal created
ssh emily 'systemctl --user stop mozimo vince; rm -rf ~/podman-data/mozimo/pb_data ~/podman-data/vince'
# monoco: the site is down from here until DNS + emily are live
ssh monoco 'docker stop mozimo vince'
# PocketBase database + files (23 MB)
ssh monoco 'sudo -n tar -C /var/lib/mozimo -cf - pb_data' \
| ssh emily 'mkdir -p ~/podman-data/mozimo && tar -C ~/podman-data/mozimo -xf -'
# Vince analytics history (119 MB), contents dropped directly into its data dir
ssh monoco 'sudo -n tar -C /var/lib/mozimo -cf - vince_data' \
| ssh emily 'mkdir -p ~/podman-data/vince && tar -C ~/podman-data/vince -xf - --strip-components=1'
The extraction runs as tanshu, so everything lands owned by tanshu — which
is exactly what the keep-id mapping expects, whatever it was owned by on
monoco. Resumable equivalents, if the link is unreliable:
rsync -a --info=progress2 --rsync-path="sudo -n rsync" \
monoco:/var/lib/mozimo/pb_data/ emily:podman-data/mozimo/pb_data/
rsync -a --info=progress2 --rsync-path="sudo -n rsync" \
monoco:/var/lib/mozimo/vince_data/ emily:podman-data/vince/
3. Point DNS at emily and deploy the Caddy block
mozimo.in, www.mozimo.in, cms.mozimo.in, stats.mozimo.in →
35.154.14.59. Then:
cd ansible
ansible-playbook playbook.yml -e image_tag=$(git rev-parse --short HEAD)
# the play restarts a service whose unit or .env changed; starting is explicit
# here because step 2 stopped both, and `start` is a no-op if already running
ssh emily 'systemctl --user start mozimo vince'
The first request after the reload makes Caddy issue certificates for the four names.
4. Verify, then retire monoco
ssh emily 'systemctl --user status mozimo vince caddy --no-pager | head -30'
ssh emily 'podman ps --format "{{.Names}} {{.Status}}"'
curl -sI https://mozimo.in | head -3
curl -s https://cms.mozimo.in/api/health | head -3 # PocketBase
curl -sI https://stats.mozimo.in | head -3
Check a product page renders live prices and that the CMS admin login works with the same superuser credentials. Only then:
ssh monoco 'docker rm -f mozimo vince' # or leave stopped for a few days
Rollback at any point before that: point DNS back at 13.202.6.2 and
ssh monoco 'docker start mozimo vince'. On emily, systemctl --user stop mozimo vince and delete the mozimo.in block from its Caddyfile.