Files
command-center/agent/managed-skills/compose-to-quadlet-cutover/SKILL.md
T

4.5 KiB

name, description
name description
compose-to-quadlet-cutover 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/<svc>/quadlet/<name>.container, mirroring the compose service line for line: Image=, ContainerName=<the compose name> (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 <dir> <name> → systemctl --user restart <name>.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/<svc>/* and shared/<svc>/* 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/<svc>_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 <name>), 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/<svc>/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/<repo>/<name>: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.