GitOps for People with Three VPSes and No Kubernetes
My control plane deploys itself: a git push becomes a build on the target box, a pause at a human gate, an atomic swap, and a health check with rollback — one approval, one cent, zero LLM calls. Here is how that fell out of an agent platform, and how it compares to ArgoCD and Jenkins.
Yesterday I wrote about the roam stack — a hosted runtime where agents schedule other agents, every model call is metered, and dangerous commands park for a one-tap human verdict. Today’s post is about the moment I realized the LLM was optional.
Everything roam-hub actually does — webhook triggers, a pull-based work queue, an append-only journal, a confirm-gate that reaches your phone, per-run billing, staleness reclaim — is execution-agnostic. The LLM only ever lived inside the executor. So I added a second executor: kind: script. An ordered list of shell steps, run deterministically, with no model anywhere. A green run bills the flat fee: one cent, because there is nothing to meter.
The pipeline
The first consumer is the hub itself. A push to main fires a GitHub webhook at the hub; a worker on the target box claims the run and executes:
fetch git fetch; report new vs deployed SHA
checkout git checkout -f --detach origin/main
build machin build on the box (one compiler binary, gcc)
smoke boot the candidate on a spare port, curl /_health
swap-restart install + mv (atomic), cmp-verify, systemctl restart ← PARKS HERE
health curl the live service, 5 tries — or roll back and fail loudly
record write the deployed SHAThe swap-restart step is where autonomy stops. The run parks: the journal records needs_human with the exact command, an email lands with signed Approve/Deny buttons, and the worker polls for the verdict. Nothing holds a lock, nothing burns an executor slot — a parked run is a row in SQLite and a polling loop. Approve, and the swap is atomic (mv, not cp — “Text file busy” taught us that the hard way, producing a false “deployed — healthy” we had to make impossible with a byte-compare verification step). The rollback path deliberately needs no approval: restoring known-good should never wait for a human.
The part that makes it GitOps
The step list itself is not stored in some mutable server config. The spec carries a steps_url pointing into the repo, and the hub fetches it fresh at claim time. I proved it the fun way: added an announce step to the JSON, pushed, touched nothing else — and the very next run executed eight steps instead of seven, the new one first. Pipeline changes are commits. They get review, history, and blame like everything else. Fetch failures fail the run loudly (steps_unresolvable in the journal) — there is no “quietly deploy with yesterday’s pipeline” path.
And because a cron interval on the same spec is just a reconcile loop (step one exits “in sync” when there is no drift), you get the ArgoCD property — desired state in git, converged on schedule, webhook for immediacy — without an operator in sight.
Versus ArgoCD
ArgoCD is the real thing and I am not pretending otherwise: if you run Kubernetes, use ArgoCD. But it requires Kubernetes — controllers, CRDs, a cluster to reconcile into. Its sync waves and manual-sync approvals map to my gate; its drift detection maps to my reconcile tick. The difference is the floor: my “cluster” is three VPSes running systemd, and the entire control plane is one static binary and a SQLite file. There is no YAML operator between me and the machine — a pipeline is seven lines of JSON, and the human checkpoint is an email button rather than a dashboard sync pane. ArgoCD answers “how do I converge a cluster.” This answers “how do I ship to boxes I can name, with a veto.”
Versus Jenkins
Jenkins is the general-purpose butler, and it can of course do all of this — after you stand up the JVM, the plugin tree, the credentials store, and the agent fleet, and keep patching all four. Three contrasts that matter to me. Approvals: a Jenkins input step blocks inside a running pipeline, holding its context; my park is inert state — the worker polls a decision endpoint, and the verdict arrives from a phone tap, a CLI, or another agent hitting the same API. State: Jenkins accumulates config the way attics accumulate boxes; here the pipeline is in git, the spec is four fields, and the server keeps only runs and journals. The customer: Jenkins is operated by humans clicking; roam-hub’s API was designed for machines first — my coding agent registers a deploy pipeline with one POST and reads the journal back as JSON. Jenkins is free like a puppy is free. A hosted run here is a cent.
Honest scoping, though: this is not a CI farm. No build matrix, no artifact store, no PR status checks — GitHub Actions keeps that job. This covers the leg CI systems are worst at: touching a production box you can name, with a human gate and an audit trail.
The twist the others can’t copy
The same run-plane runs LLM agents. A script spec can declare on_failure: agent — deterministic steps while everything works, and only when a step fails does a model get the failing output and shell access to diagnose. In my test it found the broken step, fixed the cause, re-ran the step to prove exit 0, and wrote a one-line incident summary — for about four cents. For deploys I keep it on_failure: abort: release engineering should fail honestly, not improvise. An LLM in the happy path of CI is slow, expensive, and flaky — which is precisely why this pipeline contains none. But an exception handler that can read logs? That is where a model belongs, priced per use.
Receipts
This is all live, not a diagram. The hub at hub.roam.intrane.fr is currently running a binary that its own pipeline built, smoke-tested, gate-parked, swapped, and health-verified — including one deploy where the hub restarted underneath its own completion report, which taught the worker to retry the report through the restart window. Every run is an auditable journal: queued → steps_resolved → claimed → step × N → needs_human → approved → done → billed(receipt). The whole stack is MIT: roam-hub, roam, roam-panel, all built in machin.
If you have a box, a repo, and a healthy fear of unattended restarts: curl -s https://hub.roam.intrane.fr/llms.txt. Push. Get an email. Tap approve. That’s the whole ceremony.