Files
mozimo.in/ansible
tanshu d04314ab1c Deploy with rootless podman + Quadlet on emily
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.
2026-10-16 10:11:39 +05:30
..
2025-10-04 14:53:45 +00:00

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:

  1. it ships a <name>.network Quadlet with NetworkName=<name>_net;
  2. it drops caddy.container.d/50-<name>.conf containing [Container] Network=<name>.network, which Quadlet merges into Caddy;
  3. 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.