--- name: compose-to-quadlet-cutover description: "Convert a compose-only service to a boot-persistent rootless Quadlet unit on the Dankclaw/Dankserver fleet — checklist plus the traps (CMD-SHELL healthchecks in distroless images, --format docker, EnvironmentFile HOME=/root, loopback publish posture)" --- # Compose service → boot-persistent Quadlet unit (this fleet) Use when a service in `dankclaw/compose.yaml` (or `dankserver/`) must survive a reboot. Precedents to copy: `dankclaw/{caddy,gitea,searxng,open-webui,seerr}/quadlet/` and `dankclaw/observability/quadlet/` (five units, 2026-09-11). ## Checklist 1. **Unit file** at `dankclaw//quadlet/.container`, mirroring the compose service line for line: `Image=`, `ContainerName=` (Caddy and scrape configs address containers by name), `Network=dankclaw_default`, `User=0:0` when the image's own user cannot write a rootless-owned bind mount, `Volume=` for each mount (`:ro` preserved), `Exec=` for the compose `command:`, `PublishPort=`, `HealthCmd`/`HealthInterval`/`HealthTimeout`/`HealthRetries`/ `HealthStartPeriod`, `[Service] Restart=always` + `RestartSec=10`, `[Install] WantedBy=default.target`. Never `systemctl --user enable` the generated unit — `[Install]` plus linger IS the boot persistence. 2. **Exposure**: publish on `127.0.0.1` unless something off-host genuinely needs it. Caddy reaches containers by name over the shared network, and the homepage status timer probes loopback, so the 0.0.0.0 publish is almost always gratuitous and is a guard-chain bypass. 3. **Handler** in `scripts/deploy-remote.sh`: `install_quadlet ` → `systemctl --user restart .service` → `wait_for_health` on the loopback endpoint. Rebuild locally built images first (`build_compose_image`), and seed any per-service env file (see traps). 4. **Planner**: map `dankclaw//*` and `shared//*` to the service in `scripts/deploy-plan.py`; keep dependencies ordered in `SERVICE_ORDER`. Add a `scripts/test-changed.sh` case *above* the generic `dankclaw/*/quadlet/*.container` fallback. 5. **Retire the compose entry** in the same change — two owners for one container name is how a stale duplicate comes back. 6. **Seam test** `tests/_quadlet.bats`: boot persistence, restart policy, no wildcard publish, container name still matching Caddy/scrape references, health command, deployer installs+restarts+waits, never `rm`, never `enable`. Migrate any older assertion that read the compose file. 7. **Deploy** per service (`./scripts/deploy.sh dankclaw --service `), then prove: `is-active`/`is-enabled` = active/generated, container health `healthy`, the endpoint answers, `list-dependencies default.target` shows the unit, and off-host probes on the previously published ports now refuse. ## Traps that cost real time - **`HealthCmd` = CMD-SHELL.** podman runs it as `/bin/sh -c`; a distroless image has no shell, so the check fails forever. `--health-cmd` will not take exec form either (a JSON array is stored as one literal argument). Put `HEALTHCHECK ... CMD ["binary","arg"]` in the image and **build with `--format docker`** — the default OCI format has no HEALTHCHECK field and drops it with only a warning. - **Never point a unit's `EnvironmentFile` at the repo root `.env`.** That file is a gluetun env file carrying `HOME=/root`: systemd sets HOME for the service, podman then looks for its config under `/root`, and the unit dies with `stat /root/.config: permission denied`. Use a per-service file under `/home/dank/Containers//env`, seeded by the deploy handler with a single-variable filter and validated so a missing secret fails loudly. - **`Network=host` exporters** that a containerized Prometheus scrapes must bind the host's LAN address (containers cannot reach the host loopback) plus `127.0.0.1` for the homepage probe — two `--web.listen-address` flags, never the wildcard. - **alloy's UI** listens on the container's loopback and 1.x refuses the `ui` config block, so the unit must pass `--server.http.listen-addr=0.0.0.0:12345` before its port can be published; without it the service has no reachable health endpoint and its status card reads down forever. - **Locally built images** need `Pull=never` and `Image=localhost//:tag`; registry-prefixed refs do not resolve from local storage on these hosts. - Compose-managed containers are replaced in place by `podman run --replace --rm`; verify afterwards that the container no longer carries `io.podman.compose.project`.