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:
1 parent
1bc1d5f753
commit
d04314ab1c
20 files changed
+535
-127
No files matched your search
@@ -1,7 +1,14 @@
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD)
|
||||
|
||||
# Both deployment hosts are aarch64, so that is the default target. A foreign
|
||||
# platform needs the *host's* binfmt handlers — podman, unlike docker buildx,
|
||||
# does not carry its own QEMU — hence the pointer in the failure path.
|
||||
# Override with e.g. `make build-production PLATFORMS=linux/amd64`.
|
||||
PLATFORMS ?= linux/arm64
|
||||
IMAGE ?= registry.tanshu.com/mozimo
|
||||
|
||||
# Analytics ids baked into the client bundle — read from the local .env
|
||||
# (gitignored) and passed to docker build. Empty values disable the tool.
|
||||
# (gitignored) and passed to podman build. Empty values disable the tool.
|
||||
VITE_ENV_KEYS := VITE_GA4_MEASUREMENT_ID VITE_PLAUSIBLE_DOMAIN VITE_SENTRY_DSN
|
||||
BUILD_ARGS := $(shell for k in VITE_GA4_MEASUREMENT_ID VITE_PLAUSIBLE_DOMAIN VITE_SENTRY_DSN; do \
|
||||
v=$$(grep -E "^$$k=" .env 2>/dev/null | head -1 | cut -d= -f2-); \
|
||||
@@ -10,21 +17,27 @@ BUILD_ARGS := $(shell for k in VITE_GA4_MEASUREMENT_ID VITE_PLAUSIBLE_DOMAIN VIT
|
||||
BUILD_ARGS += --build-arg VITE_SENTRY_RELEASE=$(COMMIT) --build-arg SENTRY_RELEASE=$(COMMIT)
|
||||
|
||||
.PHONY: build-production
|
||||
build-production: ## Multi-arch build from HEAD; tags registry.tanshu.com/mozimo:<commit> + :main
|
||||
@git archive --format=tar HEAD | docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag registry.tanshu.com/mozimo:$(COMMIT) \
|
||||
--tag registry.tanshu.com/mozimo:main \
|
||||
build-production: ## Build HEAD with podman for $(PLATFORMS); tag + push :$(COMMIT) and :main
|
||||
@echo "▶ podman build --platform $(PLATFORMS) → $(IMAGE):$(COMMIT) + :main"
|
||||
@git archive --format=tar HEAD | podman build \
|
||||
--platform $(PLATFORMS) \
|
||||
--tag $(IMAGE):$(COMMIT) \
|
||||
--tag $(IMAGE):main \
|
||||
$(BUILD_ARGS) \
|
||||
--push \
|
||||
-
|
||||
- || { \
|
||||
echo "✗ podman build failed."; \
|
||||
echo " Cross-platform builds need binfmt on this host (podman has no built-in QEMU):"; \
|
||||
echo " sudo apt install qemu-user-static # on an x86_64 host, for $(PLATFORMS)"; \
|
||||
exit 1; \
|
||||
}
|
||||
podman push $(IMAGE):$(COMMIT)
|
||||
podman push $(IMAGE):main
|
||||
|
||||
.PHONY: build-check
|
||||
build-check: ## Multi-arch compile check from HEAD, nothing pushed
|
||||
@git archive --format=tar HEAD | docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
build-check: ## Compile check from HEAD with podman, nothing pushed
|
||||
@git archive --format=tar HEAD | podman build \
|
||||
--platform $(PLATFORMS) \
|
||||
--tag mozimo:check \
|
||||
--pull \
|
||||
$(BUILD_ARGS) \
|
||||
--progress=plain \
|
||||
-
|
||||
@@ -69,13 +69,21 @@ npm run lint # eslint (currently broken: leftover Next.js config)
|
||||
Copy `.env.example` to `.env` and fill in the Shopify Storefront token and
|
||||
PocketBase superuser credentials (`.env*` is gitignored).
|
||||
|
||||
## Docker
|
||||
## Containers
|
||||
|
||||
The image is built with **podman** (`podman build` reads the same `Dockerfile`):
|
||||
|
||||
```bash
|
||||
docker build -t mozimo-web .
|
||||
docker run -p 3000:3000 -p 8090:8090 -v mozimo_pb_data:/app/pb_data mozimo-web
|
||||
podman build -t mozimo-web .
|
||||
podman run -p 3000:3000 -p 8090:8090 -v mozimo_pb_data:/app/pb_data mozimo-web
|
||||
```
|
||||
|
||||
Production is **rootless podman + Quadlet** on the host `emily`: systemd user
|
||||
services generated from `.container` units, fronted by that host's shared
|
||||
Caddy. `./deploy.sh` builds the pushed commit with `make build-production` and
|
||||
runs `ansible/playbook.yml`. See [`ansible/README.md`](ansible/README.md) for
|
||||
the unit layout and the monoco → emily migration.
|
||||
|
||||
Three-stage build (`deps → builder → runner`) on pinned `node:26-trixie-slim`:
|
||||
dependencies install with `npm ci` and BuildKit cache mounts, the final image
|
||||
runs as the unprivileged `node` user, ships production dependencies, the build
|
||||
@@ -83,4 +91,5 @@ output, PocketBase v0.40.2 (multi-arch) and `entrypoint.sh` which starts both
|
||||
processes, ensures the PocketBase superuser matches `PB_SUPERUSER_EMAIL` /
|
||||
`PB_SUPERUSER_PASSWORD` on every boot, and seeds the CMS schema + defaults.
|
||||
Volumes: `/app/pb_data` holds the CMS database — back it up. Ports: `3000`
|
||||
web, `8090` PocketBase (`cms.mozimo.in` → 8090 via Caddy, see `ansible/`).
|
||||
web, `8090` PocketBase (both reached by Caddy over the podman network, not
|
||||
published on the host; see [`ansible/`](ansible/)).
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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.
|
||||
|
||||
```bash
|
||||
./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:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.
|
||||
@@ -1,14 +1,17 @@
|
||||
# Canonical origin. Everything the site emits — og:url, robots.txt, the
|
||||
# sitemap, the GA4 property, vince_site_domain — already says mozimo.in, so
|
||||
# the apex serves and www is permanently redirected here (below).
|
||||
#
|
||||
# Upstreams are container names on {{ network_quadlet }}.network: Caddy
|
||||
# resolves them through podman's DNS because it has joined that network.
|
||||
{{ apex_host }} {
|
||||
# PocketBase Admin UI and REST API (files included)
|
||||
redir /_ /_/
|
||||
handle /_/* {
|
||||
reverse_proxy {{ docker_container }}:{{ cms_port }}
|
||||
reverse_proxy {{ app_container }}:{{ cms_port }}
|
||||
}
|
||||
handle /api/* {
|
||||
reverse_proxy {{ docker_container }}:{{ cms_port }}
|
||||
reverse_proxy {{ app_container }}:{{ cms_port }}
|
||||
}
|
||||
|
||||
# First-party analytics (Vince) — the tracker script and event endpoint
|
||||
@@ -21,7 +24,7 @@
|
||||
|
||||
# TanStack Start frontend
|
||||
handle {
|
||||
reverse_proxy {{ docker_container }}:{{ www_port }}
|
||||
reverse_proxy {{ app_container }}:{{ www_port }}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +36,7 @@
|
||||
|
||||
{{ cms_host }} {
|
||||
redir / /_/
|
||||
reverse_proxy {{ docker_container }}:{{ cms_port }}
|
||||
reverse_proxy {{ app_container }}:{{ cms_port }}
|
||||
}
|
||||
|
||||
# Vince analytics dashboard (login-protected). DNS for this host must point
|
||||
|
||||
+24
-5
@@ -1,15 +1,34 @@
|
||||
#################################################
|
||||
# DO Community Playbooks: Docker
|
||||
# mozimo — rootless Podman + Quadlet deployment
|
||||
#################################################
|
||||
---
|
||||
- hosts: monoco
|
||||
become: true
|
||||
# Everything runs unprivileged as the ssh user: the containers are systemd
|
||||
# *user* services generated from Quadlet units, and Caddy is the host's shared
|
||||
# reverse proxy, which each service registers itself with (a network drop-in
|
||||
# plus a block in its Caddyfile).
|
||||
#
|
||||
# ansible-playbook playbook.yml -e image_tag=<commit>
|
||||
#
|
||||
# Before DNS points at this host, skip the Caddy role — appending the site
|
||||
# block makes Caddy try to issue certificates for a name that still resolves
|
||||
# elsewhere (it retries, but the log is noisy):
|
||||
#
|
||||
# ansible-playbook playbook.yml -e image_tag=<commit> --skip-tags caddy
|
||||
- hosts: emily
|
||||
become: false
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- vars/default.yml
|
||||
|
||||
environment:
|
||||
# rootless podman and `systemctl --user` both need the user's runtime
|
||||
# directory, and an ansible ssh session has no login session to set it.
|
||||
XDG_RUNTIME_DIR: "{{ xdg_runtime_dir }}"
|
||||
|
||||
roles:
|
||||
- upload
|
||||
- network
|
||||
- analytics
|
||||
- mozimo
|
||||
- caddy
|
||||
- analytics
|
||||
- role: caddy
|
||||
tags: [caddy]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
+45
-13
@@ -1,4 +1,17 @@
|
||||
---
|
||||
# mozimo deployment — rootless Podman + Quadlet, on the host named emily.
|
||||
#
|
||||
# The layout follows the convention already in place on that host (see the
|
||||
# caddy / tandoor / postgres playbooks that manage its other services):
|
||||
#
|
||||
# ~/.config/containers/systemd/ Quadlet units, generated into user services
|
||||
# ~/podman-data/<service>/ each service's data
|
||||
# ~/podman-data/caddy/conf/Caddyfile the shared Caddy config; every service
|
||||
# appends its own block to it
|
||||
#
|
||||
# Nothing here needs root: podman runs as the ssh user, the units are user
|
||||
# services, and the shared Caddy is already a user service on that host.
|
||||
|
||||
registry: registry.tanshu.com
|
||||
username: ta-registry
|
||||
password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
|
||||
@@ -6,7 +19,25 @@ password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
|
||||
title: mozimo
|
||||
tag: latest
|
||||
|
||||
host_directory: "/var/lib/{{ title }}"
|
||||
# The unprivileged user that owns the containers. `ansible_user` on emily is
|
||||
# the same account, so no become is involved anywhere in this play.
|
||||
podman_user: tanshu
|
||||
# `id -u tanshu` on emily. Pinned rather than read from facts because the
|
||||
# play-level environment (XDG_RUNTIME_DIR) is templated before facts exist;
|
||||
# the upload role asserts the two still agree.
|
||||
podman_uid: 1001
|
||||
xdg_runtime_dir: "/run/user/{{ podman_uid }}"
|
||||
podman_data: "/home/{{ podman_user }}/podman-data"
|
||||
quadlet_dir: "/home/{{ podman_user }}/.config/containers/systemd"
|
||||
|
||||
# mozimo's data root: PocketBase database and files, the Shopify token cache,
|
||||
# and the runtime .env that the unit reads.
|
||||
host_directory: "{{ podman_data }}/{{ title }}"
|
||||
|
||||
# image tag: deploy.sh passes -e image_tag=<commit>; "tag" is the fallback
|
||||
# when running the playbook by hand — rollback = -e image_tag=<older-commit>
|
||||
image: "{{ registry }}/{{ title }}:{{ image_tag | default(tag) }}"
|
||||
app_container: "{{ title }}"
|
||||
|
||||
# apex is the canonical origin and serves the app; www 301-redirects to it
|
||||
apex_host: "mozimo.in"
|
||||
@@ -15,25 +46,26 @@ www_port: 3000
|
||||
cms_host: "cms.mozimo.in"
|
||||
cms_port: 8090
|
||||
|
||||
docker_network: "{{ title }}_net"
|
||||
# mozimo gets its own podman network, and registers itself with the shared
|
||||
# Caddy through a drop-in rather than by editing Caddy's unit.
|
||||
network_name: "{{ title }}_net"
|
||||
network_quadlet: "{{ title }}"
|
||||
|
||||
# image tag: deploy.sh passes -e image_tag=<commit>; "tag" is the fallback
|
||||
# when running the playbook by hand — rollback = -e image_tag=<older-commit>
|
||||
docker_image: "{{ registry }}/{{ title }}:{{ image_tag | default(tag) }}"
|
||||
docker_container: "{{ title }}"
|
||||
# The shared Caddy on emily. Each service owns a block in its Caddyfile
|
||||
# (blockinfile markers) and a drop-in that adds its network to Caddy.
|
||||
caddy_container: caddy
|
||||
caddy_service: caddy
|
||||
caddyfile_path: "{{ podman_data }}/caddy/conf/Caddyfile"
|
||||
caddy_dropin_dir: "{{ quadlet_dir }}/caddy.container.d"
|
||||
caddy_dropin_file: "50-{{ title }}.conf"
|
||||
|
||||
# self-hosted first-party analytics (Vince) — script/events proxied at
|
||||
# /stats/* on the www host, dashboard published on stats_host
|
||||
# /stats/* on the apex host, dashboard published on stats_host
|
||||
vince_container: vince
|
||||
vince_port: 8000
|
||||
vince_admin_name: admin
|
||||
# change this before first deploy — it (re)sets the dashboard login
|
||||
vince_admin_password: "ChangeMe-Vince-2025!"
|
||||
vince_site_domain: mozimo.in
|
||||
vince_directory: "{{ podman_data }}/vince"
|
||||
stats_host: stats.mozimo.in
|
||||
|
||||
# caddy publishes the apex site (SSR + /api/* + /_/* -> pocketbase), redirects
|
||||
# www to it, and publishes the cms admin host
|
||||
|
||||
caddy_container: caddy
|
||||
caddyfile_path: /var/lib/caddy/conf/Caddyfile
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mozimo deploy — build the exact commit at origin/main, tag it with its
|
||||
# commit id, and hand that tag to ansible. Guards make it impossible to
|
||||
# Mozimo deploy — build the exact commit at origin/main with podman, tag it
|
||||
# with its commit id, and hand that tag to ansible (which installs the Quadlet
|
||||
# unit on emily and restarts the user service). Guards make it impossible to
|
||||
# deploy anything other than what git knows about.
|
||||
#
|
||||
# ./deploy.sh deploy HEAD (must be pushed, tree must be clean)
|
||||
@@ -41,7 +42,7 @@ fi
|
||||
git pull --ff-only
|
||||
TAG=$(git rev-parse --short HEAD)
|
||||
echo "▶ deploying commit $TAG"
|
||||
make build-production TAG="$TAG"
|
||||
make build-production COMMIT="$TAG"
|
||||
|
||||
PRUNE_FLAG=""
|
||||
if [ "$PRUNE" = true ]; then
|
||||
|
||||
Reference in new issue
Block a user