| name | service-deploy |
| description | Use this skill when enabling, moving, or migrating a NixOS service between hosts (doc1/proxmox-vm, doc2, igpu, framework, etc.), or when verifying that a service is actually working after deployment. Trigger phrases include "move X to host Y", "enable X on Y", "deploy X", "is X working", "verify X is up", "X isn't accessible", "can't reach X". |
| version | 1.0.0 |
Service Deploy & Verify Skill
Covers the full lifecycle: config change → deploy → verify end-to-end. Never declare success until the service is reachable.
Fleet Quick Reference
| Host | SSH alias | LAN IP | Role |
|---|
| proxmox-vm | doc1 | 192.168.1.29 | Bastion, cache, control plane |
| doc2 | doc2 | 192.168.1.35 | Most homelab services |
| igpu | igpu | 192.168.1.33 | Media transcoding LXC |
| servarr | servarr | 192.168.1.4 | arr stack |
| caddy | cad | 192.168.1.6 | Legacy appliance edge |
doc1 port conflict: nix-serve binds 127.0.0.1:5000. Any service with default port 5000 will fail on doc1 — always use a different port.
Moving a Service Between Hosts
-
Check for port conflicts on the destination host before touching config:
ssh <dest> "ss -tln | grep :<port>"
-
Update config — disable on source, enable on destination:
# source host configuration.nix
services.foo.enable = false;
# destination host configuration.nix
services.foo = {
enable = true;
dataDir = "/mnt/virtio/foo"; # match existing path if data must survive
port = 5001; # adjust if default conflicts
};
-
Deploy DESTINATION first, then source — both in one maintenance window.
Deploying the new host first lets its homelab-dns-sync take over the A
record in place (zero downtime); the old host's cleanup then sees the new
owner and leaves it alone (see DNS ownership note below). Push the signed
commit to Forgejo, then have each host fetch + verify + build via
fleet-update:
git push
ssh <dest> "hostname"
fleet-deploy <dest>
ssh <source> "hostname"
fleet-deploy <source>
fleet-update/fleet-deploy fetch Forgejo, verify every commit in range is
signed and descends from the running rev, then build locally from a root-owned
verified clone — nothing transits your laptop or the SSH link. Post-#235 +
forgejo#2. Do NOT use --target-host, and do NOT deploy from
github:abl030/nixosconfig (stale/frozen). Break-glass only: a local
nixos-rebuild switch --flake .#<host> from a tree fast-forwarded to Forgejo
tip. Full model: docs/wiki/infrastructure/fleet-deploy-and-sibling-lockdown.md.
Sudo posture is separate from the fleet role. doc2 and servarr have
deliberate full NOPASSWD host overrides. igpu and wsl retain only the
narrow read-only/container recovery allowlist; use Loki for their privileged
logs and deploy config fixes through fleet-deploy. Read the target host
config before using any sudo verification example below.
-
DNS record ownership (the #202 race is fixed in code, but verify anyway).
Each Cloudflare A record now carries comment = "managed-by:<host>", and
cleanup only deletes records it owns — so the source host can no longer
delete a record the destination just claimed. The old failure mode (both
hosts run homelab-dns-sync, source's delete races and wins → no A record →
wildcard 502) should no longer happen with destination-first deploys. Full
model: docs/wiki/services/local-proxy-dns-sync.md. After deploy, still
verify DNS:
TOKEN=$(ssh <dest> "sudo grep -oP 'CLOUDFLARE_DNS_API_TOKEN=\K.*' /run/secrets/acme/cloudflare | tr -d '\r\n'")
ZONE=$(ssh <dest> "sudo cat /var/lib/homelab/dns/zone-id")
curl -fsS -H "Authorization: Bearer $TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?type=A&name=<hostname>" \
| jq '.result[] | {id, name, content, comment}'
ssh <dest> "sudo jq 'del(.\"<hostname>\")' /var/lib/homelab/dns/records.json \
| sudo tee /var/lib/homelab/dns/records.json.tmp \
&& sudo mv /var/lib/homelab/dns/records.json.tmp /var/lib/homelab/dns/records.json"
ssh <dest> "sudo systemctl start homelab-dns-sync.service"
Then verify the record was actually created (not just cached):
curl -fsS -H "Authorization: Bearer $TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?type=A&name=<hostname>" \
| jq '.result | length'
Post-Deploy Verification Checklist
Run through these in order — stop and diagnose at the first failure:
1. Systemd units are up
ssh <host> "sudo systemctl status podman-<service>-*.service --no-pager -n 5"
2. No port conflicts
ssh <host> "sudo journalctl -u podman-<service>-nginx.service -n 20 --no-pager | grep -i 'error\|bind\|address already'"
ssh <host> "sudo ss -tlnp | grep :<port>"
3. DNS resolves to the right IP
dig <hostname> +short @1.1.1.1
4. HTTPS is reachable
curl -sI https://<hostname>/ | head -5
5. Application-level health (if applicable)
curl -s https://<hostname>/api/health | jq .
Common Failure Modes & Fixes
"address already in use" on container start
Something else owns the port. Find it and either move the service to a free port or stop the conflict.
ssh <host> "sudo ss -tlnp | grep :<port>"
Nginx returns "File not found"
The nginx container is up but the upstream (meelo-server, meelo-front, etc.) isn't. Check dependencies:
ssh <host> "sudo systemctl status podman-<service>-server.service --no-pager -n 10"
DNS points to wildcard catch-all (192.168.1.6)
No specific A record exists. Either the sync failed silently or the race condition deleted it. See DNS race fix above.
Container restart-looping
ssh <host> "sudo journalctl -u podman-<service>-<container>.service --no-pager -n 30"
Data missing after move
Verify both hosts share the same virtiofs tag:
grep -A3 'virtiofs\|device.*container' hosts/<source>/configuration.nix
grep -A3 'virtiofs\|device.*container' hosts/<dest>/configuration.nix