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.
This commit is contained in:
tanshu committed 2026-10-16 10:11:39 +05:30
1 parent 1bc1d5f753
commit d04314ab1c
20 files changed
+535 -127

No files matched your search

@@ -0,0 +1,9 @@
---
# Handlers for the analytics role.
- name: Restart Vince
ansible.builtin.systemd_service:
name: "{{ vince_container }}.service"
state: restarted
scope: user
when: not ansible_check_mode
listen: "Restart Vince"
+17 -37
View File
@@ -1,44 +1,24 @@
---
# Self-hosted first-party analytics (Vince — Plausible-protocol, single
# binary). Only reachable on the docker network:
# - Caddy proxies /stats/* on the www host here (prefix stripped), serving
# binary). Reachable only on mozimo's network:
# - Caddy proxies /stats/* on the apex host here (prefix stripped), serving
# the tracker script and event API first-party;
# - Caddy publishes the dashboard on {{ stats_host }}.
#
# The image's ENTRYPOINT is the bare /vince binary (prints help and exits
# with no args), so the container MUST be given the `serve` command.
- name: Pull Vince image
docker_image:
name: "{{ vince_image }}"
source: pull
state: present
force_source: "{{ vince_image_force_update | default(false) }}"
# Analytics history lives in {{ vince_directory }} and is migrated separately
# from monoco — see ansible/README.md.
- name: Pull the Vince image
ansible.builtin.command: "podman pull {{ vince_image }}"
changed_when: false
- name: Ensure Vince data directory exists
file:
path: "{{ host_directory }}/vince_data"
state: directory
owner: "1000"
group: "1000"
- name: Install the Vince Quadlet unit
ansible.builtin.template:
src: vince.container.j2
dest: "{{ quadlet_dir }}/{{ vince_container }}.container"
mode: "0644"
notify: Restart Vince
- name: Create Vince container
docker_container:
name: "{{ vince_container }}"
image: "{{ vince_image }}"
command: serve
state: started
restart_policy: "unless-stopped"
env:
VINCE_DATA: /data
VINCE_LISTEN: "0.0.0.0:{{ vince_port }}"
# serve (re)creates the admin account from these on every boot —
# the env file is the source of truth, like the PocketBase superuser.
VINCE_ADMIN_NAME: "{{ vince_admin_name }}"
VINCE_ADMIN_PASSWORD: "{{ vince_admin_password }}"
# create the tracked site on startup (CSV list)
VINCE_DOMAINS: "{{ vince_site_domain }}"
VINCE_URL: "https://{{ stats_host }}"
volumes:
- "{{ host_directory }}/vince_data:/data"
networks:
- name: "{{ docker_network }}"
- name: Generate the user service from the Quadlet unit
ansible.builtin.systemd_service:
daemon_reload: true
scope: user
@@ -0,0 +1,42 @@
# Vince (first-party analytics, Plausible protocol) — rootless Podman Quadlet.
# Managed by Ansible. Do not edit by hand.
#
# The image's ENTRYPOINT is the bare /vince binary (prints help and exits with
# no args), so the `serve` subcommand is required.
#
# Reachable only on {{ network_quadlet }}.network: Caddy proxies /stats/* on
# the apex host here (prefix stripped) and publishes the dashboard on
# {{ stats_host }}. There is no published port.
[Unit]
Description=Vince analytics
After=network-online.target
Wants=network-online.target
[Container]
ContainerName={{ vince_container }}
Image={{ vince_image }}
# Vince runs as root in the image and writes /data; mapping container root
# onto {{ podman_user }} keeps {{ vince_directory }} host-owned.
UserNS=keep-id:uid=0,gid=0
Exec=serve
Environment=VINCE_DATA=/data
Environment=VINCE_LISTEN=0.0.0.0:{{ vince_port }}
# serve (re)creates the admin account from these on every boot — the playbook
# vars are the source of truth, like the PocketBase superuser.
Environment=VINCE_ADMIN_NAME={{ vince_admin_name }}
Environment=VINCE_ADMIN_PASSWORD={{ vince_admin_password }}
# create the tracked site on startup (CSV list)
Environment=VINCE_DOMAINS={{ vince_site_domain }}
Environment=VINCE_URL=https://{{ stats_host }}
Volume={{ vince_directory }}:/data
Network={{ network_quadlet }}.network
[Service]
Restart=on-failure
[Install]
WantedBy=default.target
+8 -3
View File
@@ -1,5 +1,10 @@
---
# Handlers for Caddy role
# Handlers for the Caddy role.
#
# A reload, not a restart: only the site blocks changed, and Caddy reloads its
# config in place without dropping the other services' connections. Network
# membership changes are a restart and are handled by the network role.
- name: Reload Caddy configuration
ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload"
listen: "Reload Caddy"
ansible.builtin.command: "podman exec -w /etc/caddy {{ caddy_container }} caddy reload"
changed_when: true
listen: "Reload Caddy"
+7 -2
View File
@@ -1,12 +1,17 @@
---
# mozimo's block in the host's shared Caddyfile.
#
# This host's Caddy is shared and its Caddyfile is not templated: every service
# owns a marked block in it, appended here, so a service can be removed without
# touching anyone else's routing.
- name: Read snippet from template
ansible.builtin.set_fact:
caddy_snippet_block: "{{ lookup('ansible.builtin.template', 'files/Caddyfile.j2') }}"
- name: Install mozimo Caddy snippet (web + cms)
- name: Install the mozimo Caddy snippet (web + cms + stats)
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
marker: "# {mark} Ansible managed Caddy snippet (mozimo web + cms)"
marker: "# {mark} Ansible managed Caddy snippet for {{ apex_host }}"
block: "{{ caddy_snippet_block }}"
insertafter: EOF
notify: "Reload Caddy"
+17
View File
@@ -0,0 +1,17 @@
---
# Handlers for the mozimo role.
#
# Quadlet units are not read by systemd directly: the podman user generator
# turns them into services on daemon-reload, which the role does explicitly
# after installing the unit. The service is "generated" (is-enabled reports
# generated, not enabled) and is pulled in by default.target through the unit's
# [Install] section, so there is nothing to enable.
- name: Restart Mozimo
ansible.builtin.systemd_service:
name: "{{ title }}.service"
state: restarted
scope: user
# check mode cannot generate the unit from a file it did not write, so a
# rehearsal reports the change instead of failing on a missing service
when: not ansible_check_mode
listen: "Restart Mozimo"
+33 -30
View File
@@ -1,40 +1,43 @@
---
# Tasks for docker role
- name: Log in to Docker registry
docker_login:
registry: "{{ registry }}"
username: "{{ username }}"
password: "{{ password }}"
# The mozimo service itself: registry credentials, the pinned image, and the
# Quadlet unit that systemd turns into a user service.
#
# There is one unit, not two: the image's entrypoint runs the SSR server and
# PocketBase side by side, so the pair restart together.
- name: Check the rootless podman installation
ansible.builtin.command: podman --version
changed_when: false
- name: Pull Mozimo image
docker_image:
name: "{{ docker_image }}"
source: pull
state: present
force_source: yes
- name: Log in to the registry as the podman user
ansible.builtin.command:
cmd: "podman login --username {{ username }} --password-stdin {{ registry }}"
stdin: "{{ password }}"
no_log: true
changed_when: false
- name: Ensure PocketBase data directory exists
file:
path: "{{ host_directory }}/pb_data"
state: directory
owner: "1000"
group: "1000"
- name: Pull the image for this revision
ansible.builtin.command: "podman pull {{ image }}"
# the pull is a means to an end; the restart handler reports the real change
changed_when: false
- name: Create Mozimo container
docker_container:
name: "{{ docker_container }}"
image: "{{ docker_image }}"
state: started
restart_policy: "unless-stopped"
env_file: "{{ host_directory }}/.env"
volumes:
- "{{ host_directory }}/pb_data:/app/pb_data"
networks:
- name: "{{ docker_network }}"
- name: Install the mozimo Quadlet unit
ansible.builtin.template:
src: mozimo.container.j2
dest: "{{ quadlet_dir }}/{{ title }}.container"
mode: "0644"
notify: Restart Mozimo
# Explicit, and before the handlers run: the unit does not exist as a systemd
# service until the podman user generator has seen it, so generating it here
# means the restart handler has something to restart on a first deploy.
- name: Generate the user service from the Quadlet unit
ansible.builtin.systemd_service:
daemon_reload: true
scope: user
- name: Prune old mozimo images, keep newest 3 (opt-in via ./deploy.sh --prune)
ansible.builtin.shell: >
docker images "registry.tanshu.com/mozimo" --format "{{.ID}}" | tail -n +4 | xargs -r docker rmi
podman images -q {{ registry }}/{{ title }} | tail -n +4 | xargs -r podman rmi
when: mozimo_image_prune | default(false) | bool
failed_when: false
changed_when: true
@@ -0,0 +1,38 @@
# Mozimo — rootless Podman Quadlet unit.
# Managed by Ansible. Do not edit by hand.
#
# One unit runs the whole service: the image's entrypoint starts the TanStack
# Start SSR server and PocketBase side by side, so they restart together.
#
# Nothing is published on the host. The shared Caddy joins
# {{ network_quadlet }}.network and proxies to {{ app_container }}:{{ www_port }}
# and :{{ cms_port }} by container name.
#
# Data lives at {{ host_directory }}/pb_data and survives redeploys: the
# PocketBase database and files, the minted Shopify token, and the last-good
# catalogue cache all sit there.
[Unit]
Description=Mozimo commerce (TanStack Start + PocketBase)
Documentation=https://git.tanshu.com/tanshu/mozimo.in
After=network-online.target
Wants=network-online.target
[Container]
ContainerName={{ app_container }}
Image={{ image }}
# The image's process runs as uid 1000 (node). Mapping that uid onto the host
# user keeps pb_data owned by {{ podman_user }} rather than by a subuid, so
# rsync, tar and backups stay simple.
UserNS=keep-id:uid=1000,gid=1000
EnvironmentFile={{ host_directory }}/.env
Volume={{ host_directory }}/pb_data:/app/pb_data
Network={{ network_quadlet }}.network
[Service]
Restart=on-failure
[Install]
WantedBy=default.target
+12
View File
@@ -0,0 +1,12 @@
---
# Handlers for the network role.
#
# A restart, not a reload: joining a network changes the container's network
# namespace, which Caddy cannot pick up from a config reload.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_service }}.service"
state: restarted
daemon_reload: true
scope: user
listen: "Restart Caddy"
+28 -7
View File
@@ -1,8 +1,29 @@
---
- name: Ensure 'mozimo' Docker network exists
docker_network:
name: "{{ docker_network }}"
state: present
connected:
- name: caddy
appends: yes
# mozimo's own podman network, and its registration with the shared Caddy.
#
# This is how services register themselves on this host: the network is a
# Quadlet unit, and Caddy joins it through a drop-in file rather than through
# a global list of upstream networks. Deleting the drop-in removes Caddy from
# the network on the next daemon-reload — the reverse of deployment.
- name: Install the mozimo network Quadlet
ansible.builtin.template:
src: mozimo.network.j2
dest: "{{ quadlet_dir }}/{{ network_quadlet }}.network"
mode: "0644"
register: mozimo_network_unit
notify: Restart Caddy
- name: Register the network with Caddy
ansible.builtin.template:
src: caddy-network.conf.j2
dest: "{{ caddy_dropin_dir }}/{{ caddy_dropin_file }}"
mode: "0644"
register: mozimo_caddy_dropin
notify: Restart Caddy
# Caddy has to be on the network before the site block starts proxying to it,
# otherwise the first requests after a reload are 502s. Restarting here also
# flushes before the mozimo unit comes up.
- name: Restart Caddy now if its network membership changed
ansible.builtin.meta: flush_handlers
when: mozimo_network_unit.changed or mozimo_caddy_dropin.changed
@@ -0,0 +1,6 @@
# Added by the mozimo playbook. Do not edit by hand.
#
# Quadlet merges this into caddy.container. Deleting this file removes
# {{ network_name }} from Caddy on the next daemon-reload.
[Container]
Network={{ network_quadlet }}.network
@@ -0,0 +1,13 @@
# mozimo — logical app network.
# Managed by Ansible. Do not edit by hand.
#
# NetworkName pins the Podman network name; without it Quadlet would name it
# "systemd-mozimo".
#
# isolate blocks traffic between separate Podman networks, so a container on
# this network cannot reach one that is only on another. Caddy still reaches
# it, because Caddy is a member of it.
[Network]
NetworkName={{ network_name }}
Options=isolate=true
+30 -7
View File
@@ -1,11 +1,34 @@
---
# Tasks for upload role
- name: Ensure Host Directory exists
file:
path: "{{ host_directory }}"
state: directory
# The service's directories and its runtime environment file.
#
# The .env is the source of truth for the container's secrets (PocketBase
# superuser, Shopify credentials, Sentry) and is read by systemd through the
# unit's EnvironmentFile, not by Ansible.
- name: Confirm the podman user's uid still matches the pinned one
ansible.builtin.assert:
that: ansible_user_uid | int == podman_uid | int
fail_msg: >-
{{ podman_user }} is uid {{ ansible_user_uid }} on this host, but
vars/default.yml pins podman_uid={{ podman_uid }} (used to build the
play-level XDG_RUNTIME_DIR). Update the pin.
quiet: true
- name: Upload the .env file
template:
- name: Ensure the mozimo data, unit and analytics directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ podman_data }}"
- "{{ host_directory }}"
- "{{ host_directory }}/pb_data"
- "{{ vince_directory }}"
- "{{ quadlet_dir }}"
- "{{ caddy_dropin_dir }}"
- name: Upload the runtime environment file
ansible.builtin.template:
src: "files/.env"
dest: "{{ host_directory }}/.env"
mode: "0600"
notify: Restart Mozimo