# Challenges building the Hetzner CI runner setup A retrospective on what actually went wrong while moving chromium-fork's build off a resource-starved local runner onto ephemeral Hetzner Cloud boxes. Written for whoever touches this next — most of these took real debugging time to nail down, and the fixes are non-obvious enough to regress if the reasoning behind them gets lost. ## Provisioning **Dedicated vCPU blocked by an account limit.** The original plan was a `ccx43` (dedicated vCPU, 64GB RAM). Hetzner rejected it with `resource_limit_exceeded` — new/unverified projects start with a dedicated-vCPU limit of 0 as an anti-fraud measure (dedicated vCPU and GPU servers are common targets for crypto-mining fraud via stolen payment methods), lifted only via a support ticket. Fell back to `cpx51` (shared vCPU, 32GB RAM, 360GB disk) instead, which was available immediately. **SSH key mismatch on the very first manual test.** Initially attached a pre-registered Hetzner SSH key ("Philip Macbook Pro M2") to a hand-created server — `Permission denied (publickey,password)`. The private half of that key lived on a *different* machine than the one running the commands. Fixed at the design level, not just for this one key: every server now gets a fresh ephemeral ed25519 keypair generated per run and uploaded to Hetzner as part of `up`, with no dependency on any pre-existing registered key. ## depot_tools as root **`python3_bin_reldir.txt` missing, breaking `install-build-deps.py`.** depot_tools' `python3` wrapper hard-requires a bootstrap file that's normally created by `update_depot_tools` — which has an explicit guard: ``` if [ "$USER" == "root" ]; then echo Running depot tools as root is sad. exit fi ``` Silently no-ops as root, which this box always is. `gclient sync` still worked because it goes through `vpython3` (bootstrapped separately), but any later direct `python3` invocation — like `install-build-deps.py`'s shebang — failed with `python3_bin_reldir.txt not found`. Fix: call the two functions `update_depot_tools` invokes *after* its root check (`cipd_bin_setup`, `bootstrap_python3`) directly, sidestepping the check entirely. Needed `set +u` around that block too — those are third-party scripts not written to be safe under `nounset`, and `cipd` itself references an unset variable with no default. **Missing `file` package.** `install-build-deps.py`'s `dev_list()` shells out to `file -L ` to detect ELF architecture. Not installed by default on the minimal Ubuntu cloud image. Small fix, but it took a full run reaching that exact line to surface. ## The SSH/PTY rabbit hole This was the single biggest time sink, in three layers, each one looking like the full fix until the next real run disproved it. **Layer 1 — PTY stdin doesn't deliver EOF reliably.** The original `sshExecScript` piped a script to `bash -s` via `session.Run()` with `Stdin` set to a `strings.Reader`, over a PTY (needed so the remote side line-buffers instead of block-buffering — see "buffering" below). It hung indefinitely — but only after the script's real work had already finished. The script would run to completion, print its own final output, and then the local process would just sit there for 10+ minutes until manually killed. Root cause: SSH channel EOF just means "not sending more data on this channel right now" — for a plain exec channel that maps onto closing the child's stdin pipe (a pipe cleanly returns EOF from `read()` once closed, unconditionally). For a PTY-backed session, the server is managing an *interactive terminal*, where a pause in input isn't supposed to mean "hang up" — so it doesn't reliably translate into an EOF condition the remote shell's `read()` ever sees. Confirmed as a known class of issue, not specific to this codebase: [golang/go#6088](https://github.com/golang/go/issues/6088) (identical `x/crypto/ssh` symptom) and [net-ssh/net-ssh#460](https://github.com/net-ssh/net-ssh/issues/460) (different language entirely, same symptom, and explicitly confirms removing `request_pty` fixes it). First fix attempt: switch to `Start()` + `StdinPipe()` + explicit `Close()`, and append `exit $?` to the script so bash terminates itself instead of depending on EOF at all. This fixed short scripts in testing. **Layer 2 — a real script still hung, differently.** Against a real `sync.sh` (7.4KB), the process hung again — but this time a live inspection (once the runner moved to mainframe, making its ephemeral SSH key locally reachable) showed `bash` blocked in `do_select()` on its stdin fd, with `/root/depot_tools` not even cloned yet — despite later script output (an `apt-get install`) having already reached the log. The script was being *truncated* mid-delivery, not just missing its termination signal. Consistent with a PTY's small, fixed-size canonical-mode input queue (historically 4KB on Linux) silently dropping the tail of a multi-KB single `Write()` call when it arrives faster than bash drains it. `exit $?` didn't help because it was itself part of what got dropped. Real fix: stop sending script content through a PTY's stdin at all. Upload the script as a file first (over a plain, non-PTY session — a pipe has no such queue-size limit), then execute it as a file with no stdin of its own. No script content ever touches a PTY; the PTY is still used for the *execution* (live output streaming), but that session has nothing written to its stdin, so there's no EOF ambiguity left to have. **Layer 3 — the fix for layer 2 had the same bug as layer 1.** Building the upload mechanism (`uploadFile`, piping a file's bytes to `cat > path`), the very first real test of it hung — because `sshSession` was requesting a PTY *unconditionally*, and `uploadFile` inherited it for a plain `cat`. Fixed by making PTY allocation opt-in per session: on for anything running a real command whose output should stream live, off for raw file transfer. (A PTY's canonical-mode line discipline is actively wrong for binary-safe transfer anyway, independent of the hang — it can reinterpret control bytes like Ctrl-D/Ctrl-C that happen to appear in otherwise-arbitrary content.) **Buffering, separately.** Even once EOF/truncation were fixed, build output over a plain (non-PTY) pipe arrives block-buffered on the remote side instead of line-buffered — a multi-hour build's output would show up in laggy multi-KB chunks instead of streaming as it happens. That's the reason a PTY is used for command *execution* at all, despite all of the above: it makes `ninja`/`gclient`/`python` think they're talking to a real terminal, so they line-buffer. ## Cleanup correctness **`down()` deleted the local key even when the remote delete failed.** If the Hetzner API call to delete the server errored for any reason, the code still unconditionally removed the local ephemeral private key — meaning a failed teardown could both leave the server running *and* destroy the one thing that could get back into it to inspect or manually clean up. Found by hitting exactly this: a server still alive, its key already gone, no way back in except the Hetzner web console (which turned out to be VNC-over-websocket, not worth building a client for on the spot) or an account-level password reset (which doesn't apply to the already-running OS instance without a reboot, which would have destroyed the exact state being investigated). Fixed: only remove the local key once the server delete is confirmed to have succeeded. **Ordinary step failure vs. Go-level error, cleanup-wise.** `exec` (used for each CI phase against an already-provisioned box) deliberately doesn't tear down on an ordinary nonzero remote exit code — that's left for the workflow's own separate, later `down` step, so one failing build phase doesn't nuke a box other phases might still want to inspect. But a genuine Go-level error partway through (upload failure, SSH connection failure, a panic) previously fell through to nothing until that separate step ran. Added a `defer`-based cleanup specifically for that case, careful to distinguish it from a normal nonzero exit code — verified both paths against a real box (`exit 3` from the remote command leaves the server alive and propagates the code; an upload failure against a nonexistent local file tears the server down immediately). ## The runner host itself **Migrated off a non-cloud machine.** The `self-hosted-ubuntu` label (used by chromium-fork's CI *and* all six `mirror-sync/sync-*.yml` jobs) was originally served by some separate, apparently non-cloud machine — inferred from a `/Volumes/ExtremeSSD/...` workdir path in chromium-fork's CI history, a macOS volume mount. It restarted mid-job at least once, silently killing an in-flight run and orphaning a Hetzner box with no graceful cleanup at all (the runner process just vanished). Fixed by adding a new, dedicated `services.gitea-actions-runner` instance on `mainframe` itself (the same box already running Forgejo, already using Podman for other services) — reliable, cloud-hosted, and kept as its own instance rather than merged into the existing `ubuntu` one so it never contends with unrelated repos' CI for runner capacity. **Registration token scope.** Needed an org-scoped (Cold-Air-Networks) runner registration token, not an instance-wide admin one — Forgejo's API exposes this via `GET` (not `POST`, despite it being a token-*creation* action) on `/orgs/{org}/actions/runners/registration-token`. **agenix wiring has two separate places to update, not one.** `secrets/default.nix` only configures *encryption recipients* for the `agenix` CLI tool — it does **not** by itself expose a decrypted secret's path to the rest of the NixOS config. That requires a second, separate `age.secrets. = { file = ...; owner = ...; }` declaration elsewhere (this repo's convention: in `linux.nix`, in the same block as the other secrets). Missing this second declaration means `config.age.secrets..path` simply doesn't exist as an option — easy to miss since the `.age` file itself decrypts and evaluates fine either way. **New files need `git add` before Nix will see them, even with a dirty tree.** `nix eval`/`nix build` against a local flake includes *modifications* to already-tracked files in a dirty working tree, but silently ignores brand-new, not-yet-staged files entirely — surfaced as a confusing "Path ... is not tracked by Git" error deep in an unrelated evaluation trace (agenix trying to read a secret file that, from Nix's perspective, didn't exist yet). **Stale macOS Keychain git credentials.** `git push` to `forge.quinefoundation.com` intermittently failed with a plain `403` using the credential the `osxkeychain` helper had cached, for reasons never fully root-caused (likely a rotated/stale stored credential). Explicit `https://user:pass@host/...` credentials in the push URL worked every time; the keychain-stored path was eventually abandoned entirely for this remote. ## The three-hour mystery Two CI runs (#62, #64) died with *zero* trace: no error in the job log, no OOM in `dmesg`/kernel journal, nothing in the runner service's own systemd journal (which, at `info` level, only ever logs one line per *task* — dispatch — never per-step detail). Run #62 left Gitea/Forgejo showing "running" forever after the process and its box had already vanished; run #64 got marked "failure" within seconds of the log's last line, mid-compile, with healthy throughput right up to the cutoff. First hypothesis, based on the symptom shape alone: a known upstream act_runner/Gitea desync bug ([go-gitea/gitea#35645](https://github.com/go-gitea/gitea/issues/35645), [#35956](https://github.com/go-gitea/gitea/issues/35956)) — the runner and server losing sync on task state. Plausible, but unverifiable from outside without better visibility, so two diagnostic tools went in *before* trying to fix anything further: - **`act_runner` debug logging** (`settings.log.level = "debug"` in the NixOS module) — `info` level was structurally incapable of ever explaining this, since it doesn't log per-step detail at all. - **An independent local log for `hetzner-runner` itself** (`~/hetzner-runner-debug.log` on the runner host, written on every lifecycle event) plus a 30-second heartbeat during any in-flight remote command — decoupled entirely from Gitea's own log capture, which had by then been directly observed lagging real execution state on two separate occasions. That paid off immediately on the very next real run (#65). The local heartbeat log showed the process alive and healthy right up to a precise timestamp, then nothing — no error, no signal-caught line, just stops. Debug-level journal for that exact window had the real answer directly from act_runner itself: ``` ❌ Failure - Main Build chrome/browser (~5-20min) this step has been cancelled: signal: killed ``` Not a desync bug at all. Run #65's total elapsed time from `up` to kill: **2h59m58s** — matching `act_runner`'s default `runner.timeout: 3h` to the second. A cold build (fresh box every run, no cache) genuinely takes longer than the default job timeout; nothing was actually wrong. Fixed by doubling *both* independently-configured 3h defaults to 6h — `act_runner`'s own `runner.timeout`, and Forgejo's `ENDLESS_TASK_TIMEOUT` (`app.ini`, `[actions]` section — not managed by the NixOS flake, lives directly on the data volume). The runner's own generated config explicitly warns that either one firing independently has the same effect, so both had to move together. **A second-order finding from the same investigation:** a timeout-triggered cancellation aborts the whole job immediately rather than continuing on to later steps — meaning the dedicated `if: always()` teardown step never got a chance to run, and the orphaned box from run #65 sat alive for 6 hours before being noticed and manually deleted. The cloud-init self-destruct failsafe (`max-lifetime`) would have caught it eventually, but at its then-default 8h it left only 2 hours of margin over the newly-doubled 6h job timeout — uncomfortably tight given provisioning/retry overhead on top. Widened to 10h. ## Running theme Almost every one of these was invisible until proven otherwise by running the *actual* thing against a *real* box, for a *realistic* duration, with *real* output volume — short synthetic tests ("does `run -- echo hi` work?") passed cleanly at every layer of the PTY bug and would never have caught it. Cost real time and real Hetzner spend to work through, and every orphaned server or key found mid-way got cleaned up as its own explicit step, not deferred to "later" — worth preserving as a habit for whatever gets built on top of this next.