hardware.nix / networking.nix are now the nixos-infect output of 195.201.88.249 (ESP E079-7D41, eth0 MAC-pinned), with the one edit that matters: defaultGateway as an attrset with interface = "eth0". README §2a records what it actually took to get NixOS running on the Debian 13 image: NO_SWAP=true (tmpfs /tmp), a manual lustration from rescue mode because 26.05's systemd initrd did not honour NIXOS_LUSTRATE (Debian's unit files shadowed every NixOS service), and the gateway fix applied via a chroot rebuild with the nix sandbox off. flake: cc-ci input back on main (PR #32 merged as f6dbfa3); loops uid pinned to 1000 so workspace rsyncs by uid line up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz
cc-ci-orchestrator
The cc-ci orchestrator: the agent loops that built the cc-ci Co-op Cloud recipe CI server,
the operator's steering session, and the weekly autonomous recipe-upgrade run — plus the NixOS
host they run on. Since 2026-09 that host is the same Hetzner server as the CI server itself:
one nixos-rebuild from this repo builds both, because this flake imports the CI server as a
module from the cc-ci repo.
| where | |
|---|---|
| Orchestrator loops, timers (weekly upgrader, hourly supervisor) | nix/modules/cc-ci.nix → nixosModules.cc-ci-orchestrator |
| The host contract those need (loops user, claude/opencode CLIs, opencode web UI) | nix/modules/orchestrator-host.nix → nixosModules.orchestrator-host |
The CI server (swarm, traefik, drone, runner, !testme bridge, dashboard, reports, acme-dns) |
cc-ci repo nix/modules/ → nixosModules.cc-ci-server (flake input cc-ci) |
| The machine: hardware, networking, tailscale, root keys | nix/hosts/cc-ci/ → nixosConfigurations.cc-ci |
| Plans, launch tooling, loop prompts, journal | cc-ci-plan/ (see AGENTS.md for roles) |
Skills the orchestrator runs (/upgrade-all, /recipe-upgrade, /cc-ci-status, …) |
.claude/skills/, .opencode/skills/ |
| How it used to be built (Pi → Incus VM → shared Hetzner box) | archive/ |
Secrets (.testenv, upgrader.env, .sops/, everything under /secrets) are gitignored — never
commit them.
Deploying a cc-ci host from scratch
This is the whole path from "nothing" to a working CI server + orchestrator on one Hetzner
server. It was last done on 2026-09-07 for 195.201.88.249 and is written so a person or an LLM
can repeat it. Read it once before starting; the order matters.
0. What you need in hand
- A Hetzner Cloud project you can create servers in (console login or an API token).
- SSH keys: yours, and the orchestrator's own key so the automation can reach the box. The
public keys that get root are tracked in
nix/hosts/cc-ci/ssh-keys(one per line). - Read access to
recipe-maintainers/cc-ci,recipe-maintainers/cc-ci-orchestrator(both public read) and the privaterecipe-maintainers/cc-ci-secrets(theautonomic-botdeploy key,autonomic-bot-gitea-ed25519, has it). - The out-of-band secrets listed in §4. If you are migrating, they come from the old host; if you are starting fresh you create them (each row says how).
- Control of the DNS zone (Gandi for
commoninternet.net) for the cutover in §7.
1. Provision the server on Hetzner (Debian image)
In the Hetzner Cloud console (or with hcloud server create):
| setting | value | why |
|---|---|---|
| Image | Debian 13 (any recent Debian/Ubuntu works with nixos-infect) | it is replaced by NixOS in §2 |
| Type | x86, 8 GB RAM, 4 vCPU — e.g. cpx32 (dedicated AMD) or cx33. Never cax* (ARM): the flakes are x86_64-linux. |
swarm + recipe deploys + 3–6 agent sessions; 4 GB is too small |
| Disk | the type's default 150+ GB NVMe | docker layers alone are ~60 GB after a few weeks |
| Network | public IPv4 required; IPv6 optional (leave enabled or not, NixOS config ignores it) | cc-ci serves 80/443 and DNS on 53 publicly |
| SSH keys | add every key from nix/hosts/cc-ci/ssh-keys you want to log in with, at least the orchestrator's |
nixos-infect carries /root/.ssh/authorized_keys over |
| Name | cc-ci |
becomes the hostname |
| Firewall | if a Hetzner Cloud Firewall is attached it must allow 22/tcp, 80/tcp, 443/tcp, 53/tcp, 53/udp in, and ICMP | the NixOS firewall is separate and is configured by the flake |
Check you can log in: ssh root@<ip> hostname.
2. Convert Debian → NixOS with nixos-infect
nixos-infect installs NixOS over the running Debian and reboots. Run it detached so the SSH session dropping does not kill it:
ssh root@<ip> 'cat > /root/infect.sh <<"EOF"
#!/usr/bin/env bash
set -euo pipefail
# Pinned nixos-infect revision (same one that built the previous cc-ci hosts).
INFECT_SHA="40f62a680bb0e8f2f607d79abfaaecd99d59401c"
export NIX_CHANNEL="nixos-26.05" # must match the nixpkgs channel in flake.nix
export PROVIDER="hetznercloud" # GRUB + Hetzner networking
export NIXOS_IMPORT="" # the real config comes from the flake in §5
# The Debian 13 cloud image mounts /tmp as tmpfs; nixos-infect makes a temporary swapfile
# there and swapon fails with "Invalid argument". 8 GB RAM needs no extra swap: skip it.
export NO_SWAP=true
curl -fsSL "https://raw.githubusercontent.com/elitak/nixos-infect/${INFECT_SHA}/nixos-infect" | bash -x
EOF
chmod +x /root/infect.sh
nohup /root/infect.sh > /var/log/nixos-infect.log 2>&1 &'
It downloads Nix, builds a NixOS system (5–10 min; follow with
ssh root@<ip> tail -f /var/log/nixos-infect.log), then reboots. The SSH host key changes:
ssh-keygen -R <ip> and confirm ssh root@<ip> nixos-version prints a 26.05 version.
2a. What went wrong on 2026-09-07, and the fixes (Debian 13 image, NixOS 26.05)
All three bit on the first attempt; the script above and §3 already include the fixes, this is so you recognise them if they come back in another form.
swapon: /tmp/nixos-infect.XXXX.swp: Invalid argumentright at the start, script exits. The Debian 13 cloud image mounts/tmpas tmpfs and a swapfile cannot live there. Fix:NO_SWAP=true(in the script above). An 8 GB box does not need the temporary swap.- The box never comes back after the reboot: it boots NixOS, but nearly every unit fails
(
dbus,systemd-logind,sshd, networking …) withCould not start dynamically linked executable: /usr/bin/dbus-daemonin the journal. nixos-infect leaves the old Debian root in place and relies on NixOS's first boot to move it to/old-root(/etc/NIXOS_LUSTRATE). With NixOS 26.05's systemd-based initrd that lustration did not happen, so Debian's/etc/systemd/system/*.servicefiles shadowed the NixOS units and started Debian binaries. Fix, from Hetzner rescue mode (enable_rescue+resetin the API/console, ssh in,mount /dev/sda1 /mnt/root): move everything exceptnix,boot,swapfile,lost+found,var/log,var/empty,etc/nixos,etc/resolv.conf,etc/NIXOS,etc/machine-id,etc/ssh/ssh_host_*,root/.nix-*,root/.sshinto/mnt/root/old-root, deleteetc/NIXOS_LUSTRATE, unmount,disable_rescue,reset. (/old-root, ~1 GB, can be deleted once the host is in service.) - Boots, units fine, but no network. The generated
networking.nixhasdefaultGateway = "172.31.1.1";— a bare string. Since NixOS 25.05 that yields no default route. Fix:defaultGateway = { address = "172.31.1.1"; interface = "eth0"; };(this is whatnix/hosts/cc-ci/networking.nixcarries). To apply it from rescue mode, chroot into the mounted root and rebuild the boot entry — the nix sandbox cannotpivot_rootinside a chroot, so turn it off for that one build:Thefor d in proc sys dev dev/pts; do mount --bind /$d /mnt/root/$d; done mount -t tmpfs tmpfs /mnt/root/run; cp -L /etc/resolv.conf /mnt/root/etc/resolv.conf chroot /mnt/root /nix/var/nix/profiles/system/sw/bin/bash -c ' export PATH=/nix/var/nix/profiles/system/sw/bin NIX_REMOTE= HOME=/root export NIX_PATH=nixos-config=/etc/nixos/configuration.nix:nixpkgs=/root/.nix-defexpr/channels/nixos ln -sfn /nix/var/nix/profiles/system /run/current-system nixos-rebuild boot --option sandbox false'journalctl -D /mnt/root/var/log/journal -b 0trick (reading the dead system's journal from rescue mode) is what told these apart.
Rescue mode without a console:
POST /servers/<id>/actions/enable_rescuewith your ssh key id, then…/actions/reset; afterwardsdisable_rescueand checkrescue_enabledis false before the nextreset, or it boots the rescue image again.scripts/recovery/hetzner.pywraps these (token in/srv/cc-ci/.hcloud-token).
3. Capture the machine-specific config into this repo
nixos-infect wrote /etc/nixos/{hardware-configuration,networking,configuration}.nix. Only the
first two matter; the flake replaces configuration.nix.
scp root@<ip>:/etc/nixos/hardware-configuration.nix nix/hosts/cc-ci/hardware.nix
scp root@<ip>:/etc/nixos/networking.nix nix/hosts/cc-ci/networking.nix
Then in nix/hosts/cc-ci/:
hardware.nix: keep as generated (GRUB EFI withefiInstallAsRemovable,/boot/efiby UUID,/dev/sda1root). Do not copy another host's file — the UUIDs are per machine.networking.nix: keep the static IPv4 + Hetzner gateway172.31.1.1. Make surenetworking.defaultGatewayhas bothaddressandinterface = "eth0"(§2a item 3). If the generated IPv6 block has an empty address, delete the IPv6 parts; a real global address (as on the 2026-09 box) can stay.configuration.nix: setcc-ci.publicIPv4to the server's IPv4 and checksystem.stateVersionis the release you installed (never change it later).ssh-keys: the root keys.
Commit on a branch; the rebuild in §5 can use the local checkout before the PR merges.
4. Stage the workspace and secrets on the new host
Everything in this section is outside git. Do it as root over SSH, in this order.
4a. Tailscale
# a reusable (or fresh) tailnet auth key from the tailscale admin console
install -m600 /dev/stdin /etc/ts-auth-key <<<'tskey-auth-…'
4b. The CI server's checkout and its one out-of-band secret
# root's deploy key for the private cc-ci-secrets submodule
install -d -m700 /root/.ssh
install -m600 <autonomic-bot-gitea-ed25519> /root/.ssh/autonomic-bot-gitea-ed25519
cat > /root/.ssh/config <<'EOF'
Host git.autonomic.zone
Port 2222
User git
IdentityFile /root/.ssh/autonomic-bot-gitea-ed25519
IdentitiesOnly yes
EOF
# the deployed checkout: nightly-sweep runs from it, sops reads secrets/secrets.yaml from it
git clone --recursive https://git.autonomic.zone/recipe-maintainers/cc-ci.git /etc/cc-ci
# the master (recovery) age key — the only sops recipient a fresh host can be
install -d -m700 /var/lib/sops-nix
install -m600 <master-age.txt> /var/lib/sops-nix/key.txt
/etc/cc-ci/secrets/secrets.yaml is encrypted to the master key and the old host's SSH host
key. That is enough to deploy. Afterwards (optional, tidier) add the new host as a recipient:
ssh-to-age < /etc/ssh/ssh_host_ed25519_key.pub, add it to secrets/.sops.yaml in cc-ci-secrets,
sops updatekeys secrets.yaml, push, git -C /etc/cc-ci submodule update --remote.
4c. The orchestrator's workspace (as the loops user — it exists after the first rebuild, so
run §5 once first if this is a fresh host, then come back)
sudo -iu loops
git clone --recursive https://git.autonomic.zone/recipe-maintainers/cc-ci-orchestrator.git /srv/cc-ci-orch
sudo ln -sfn /srv/cc-ci-orch /srv/cc-ci # every script and unit says /srv/cc-ci
cd /srv/cc-ci-orch
git clone https://git.autonomic.zone/recipe-maintainers/cc-ci.git cc-ci # Builder clone
git clone https://git.autonomic.zone/recipe-maintainers/cc-ci.git cc-ci-adv # Adversary clone
mkdir -p .cc-ci-logs .sops
Then the files below (install -m600 -o loops -g users):
| file | what | source |
|---|---|---|
/srv/cc-ci/.testenv |
TS_AUTH_KEY, GITEA_PASSWORD (autonomic-bot), DOCKERHUB_USERNAME/TOKEN, model API keys |
old host /secrets/files/cc-ci.testenv; fresh: create each credential |
/srv/cc-ci/upgrader.env |
LOOP_TIER, LOOP_MODEL, REPORT_MODEL for the weekly run (no secrets) |
old host, or copy the example in AGENTS.md |
/srv/cc-ci/.sops/master-age.txt |
the same master age key as 4b (skills that re-key secrets use it) | old host |
~loops/.ssh/cc-ci-root-ed25519 (+.pub) |
ssh cc-ci as root — to loopback on this host |
old host; fresh: ssh-keygen -t ed25519 and add the pub to nix/hosts/cc-ci/ssh-keys |
~loops/.ssh/autonomic-bot-gitea-ed25519 (+.pub) |
pushes recipe branches / PRs as autonomic-bot |
old host; fresh: new key added to the bot's Gitea account |
~loops/.ssh/tangled-ed25519 |
optional, tangled.org mirrors | old host |
~loops/.claude/ |
Claude Code auth + settings + the orchestrator session history | old host (rsync -a); fresh: claude auth login as loops (device code, interactive) |
~loops/.local/share/opencode/auth.json, ~loops/.config/opencode/ |
opencode provider auth (the weekly upgrader runs on opencode) | old host; fresh: opencode auth login |
/etc/nginx/oc-selfsigned.{crt,key}, /etc/nginx/oc-htpasswd |
the tailnet-only opencode UI; nginx refuses to start without them | old host, or generate (commands in nix/modules/orchestrator-host.nix) |
~loops/.ssh/config is written by the activation script on first rebuild (Host cc-ci →
127.0.0.1, git.autonomic.zone, tangled.org); it is not overwritten if present.
5. Build and activate
From the checkout with the §3 commit (root can build from the loops-owned checkout via sudo):
sudo -iu loops
cd /srv/cc-ci-orch && git checkout <branch>
sudo nixos-rebuild test --flake .#cc-ci # activates WITHOUT touching the bootloader
test first, always: if the activation breaks networking or sshd, a reboot from the Hetzner
console lands on the last known-good generation. Over an SSH session that itself rides tailscale,
run it detached (systemd-run --unit=ccci-sw --collect nixos-rebuild test --flake .#cc-ci) so the
tailscale restart during activation does not abort it.
The first activation takes a while: it pulls the traefik/drone/keycloak images, builds the bridge
and dashboard OCI images with Nix, initialises the swarm and runs the serialized reconcile
oneshots (swarm-init → deploy-proxy → deploy-drone → deploy-bridge → deploy-dashboard → deploy-reports, deploy-backupbot, warm-keycloak). Verify:
systemctl is-system-running # running — or list-units --failed and read journalctl -u <unit>
tailscale status | head -3
docker service ls # traefik app+socket-proxy, drone, bridge, dashboard, reports, backups: 1/1
systemctl status cc-ci-loops cc-ci-orchestrator opencode-web nginx acme-dns
systemctl list-timers 'cc-ci-*' nightly-sweep
sudo -iu loops tmux ls # cc-ci-orchestrator (+ loops sessions if a phase is active)
When it is healthy: sudo nixos-rebuild switch --flake .#cc-ci (same config, now also the boot
default). If you are migrating from another host, do §6 before letting it serve anything: right
after the first activation scale the !testme bridge to 0 and mask the two orchestrator timers so
the new box does not process PR comments or start a second weekly run while the old host is live:
docker service scale ccci-bridge_app=0
systemctl mask --now cc-ci-upgrade-all.timer cc-ci-upgrade-supervisor.timer
6. Migrating: restore state from the previous host
Over tailscale (rsync -aHAX --numeric-ids root@<old>:<path> <path>), with the matching service
stopped on the new host while its directory is copied:
| path | holds | notes |
|---|---|---|
/var/lib/cc-ci-reports |
the published weekly report pages (report.ci…) |
|
/var/lib/cc-ci-runs |
per-run artifacts the dashboard shows | |
/var/lib/ci-warm |
warm-canonical state + alerts | recipe warm volumes are caches: not copied, rebuilt by the Sunday sweep / first use |
/var/lib/acme |
the Let's Encrypt cert + account and acme-dns-accounts.json — the account the permanent _acme-challenge CNAME points at |
without it a fresh registration + a new CNAME at Gandi is needed (registration is disabled in acme-dns.nix) |
/var/lib/acme-dns |
the acme-dns zone DB | |
/var/lib/ci-certs |
the copy traefik is handed | then systemctl restart cc-ci-acme-traefik-handoff |
/root/.abra |
abra's per-app env files for the deployed stacks | |
Drone data volume /var/lib/docker/volumes/drone_ci_commoninternet_net_data |
Drone's DB: the Gitea OAuth grant, repo activation, build history | docker service scale drone_ci_commoninternet_net_app=0 on the new host, copy, scale back to 1. Otherwise run scripts/bootstrap-drone-oauth.sh (cc-ci repo) with the bot password and re-activate repos |
/srv/cc-ci-orch/.cc-ci-logs, /srv/cc-ci-orch/cc-ci-plan/upstream/, REBOOTS.md, JOURNAL.md |
orchestrator history, the upgrader's per-recipe release-note registry | as loops; do the final sync after stopping the orchestrator on the old host |
7. Cutover and verification
- DNS (operator, Gandi zone
commoninternet.net): A recordsci,*.ciandns-acme→ the new IPv4.acme NS ns-acmeand_acme-challenge.ci CNAME <account>.acme…stay as they are. Wait for propagation (dig +short ci.commoninternet.net). - Check the new host answers on the new IP before DNS moves:
dig @<new-ip> ns-acme.commoninternet.net(acme-dns),curl --resolve ci.commoninternet.net:443:<new-ip> https://ci.commoninternet.net/(dashboard, valid cert), same forreport.cianddrone.ci. - Old host:
docker service scale ccci-bridge_app=0 drone_ci_commoninternet_net_app=0;systemctl disable --now cc-ci-upgrade-all.timer cc-ci-upgrade-supervisor.timeron the old orchestrator. New host:docker service scale ccci-bridge_app=1;systemctl unmask cc-ci-upgrade-all.timer cc-ci-upgrade-supervisor.timer && systemctl startboth. - End to end: post
!testmeon an open recipe PR and watch it turn green on the new Drone; openhttps://ci.commoninternet.netandhttps://report.ci.commoninternet.net. - The orchestrator: as loops on the new host
cd /srv/cc-ci-orch && python3 cc-ci-plan/agents.py up orchestrator(or justsystemctl restart cc-ci-orchestrator), attach withclaude --resumeor from claude.ai/code. Its startup routine (AGENTS.md) reports phase + reboot count. - Keep the old host as a cold standby for a week, then delete it and its tailnet node.
8. Day 2
- Update the host (nixpkgs bump for both halves):
/cc-ci-orchestrator-update, which isnix flake update→nixos-rebuild test→ verify →switch→ PR. Thecc-ciinput follows this flake's nixpkgs, so the CI server is rebuilt on the same nixpkgs. - Update only cc-ci's code (harness/tests/modules): merge in the cc-ci repo, then
nix flake update cc-cihere and rebuild; alsogit -C /etc/cc-ci pull --recurse-submodulesso the deployed checkout the sweep runs from matches. - Something is down:
systemctl --failed,journalctl -u deploy-<x>,docker service ps <svc>; the cc-ci repo'sdocs/runbook.md. Host unreachable: Hetzner console → reboot lands on the lastswitched generation; rescue mode +nixos-enterfor anything worse (skill/hetzner-server-recovery).
Operating the orchestrator session
The steering session is a long-lived interactive Claude Code session under tmux with
--remote-control, so it can be watched and steered from claude.ai/code (or the mobile app).
cc-ci-orchestrator.service relaunches it on boot via cc-ci-plan/agents.py up orchestrator
(backend + model in cc-ci-plan/agents.toml).
# attach on the host
sudo -iu loops tmux attach -t cc-ci-orchestrator
# or resume the conversation by hand in a fresh tmux pane
claude --resume autonomous-orchestrator --remote-control "autonomous-orchestrator" --dangerously-skip-permissions
# already inside a live session and just want the web surface? /remote-control
--resume <name|id> selects the conversation to restore; the --remote-control "<name>" value is
only the web display label. Don't pass --fork-session unless you mean to branch.
Kick off / supervise the loops
cd /srv/cc-ci/cc-ci-plan
./launch.sh start # Builder + Adversary loops (interactive --remote-control in tmux) + watchdog
./launch.sh status # session + DONE state
./launch.sh logs builder|adversary|watchdog
./launch.sh stop
Full supervision guide, credential map and history are in cc-ci-plan/kickoff.md,
cc-ci-plan/plan.md §1.5 and cc-ci-plan/JOURNAL.md.