Engineering
Why we built our own CI — in Go
FastYoke Engineering · 9 min read · May 23, 2026
- Go
- CI/CD
- Dagger
- Sovereignty
We host our own source control. CI followed the same logic.
FastYoke runs on self-hosted Forgejo — our own git forge, our own issues, our own pull requests, on infrastructure we control. That decision wasn't incidental. Sovereignty is a value we sell to customers, and it's one we hold ourselves to: we don't want a third party sitting between our commits and our ability to ship.
CI was the next domino. Once your source of truth is self-hosted, "what runs your tests and builds your binary" becomes a question with the same stakes. And our build is not small. FastYoke's backend is a large Rust dependency graph — a single-binary monolith with a wide surface of crates, and Rust compile times scale with that surface. Build caching isn't a nice optimization here; it's the difference between a CI run that finishes in minutes and one that finishes in the length of a lunch break. We'd already watched hosted CI runners choke and run out of memory trying to compile the whole tree cold, because they don't have a fast disk sitting next to a warm cache the way a machine we own does.
So we wanted CI we own, CI that can reuse a cache aggressively, and — just as important — CI whose pipeline definition we can run on a laptop and get the identical result to what runs in production. This post is about the tool we chose to make that possible, and why the layer we wrote around it is Go rather than Rust.
Why not just buy a CI SaaS
The obvious move is to point Forgejo at a hosted CI product and move on. We didn't, for reasons that all come back to the same root cause: a hosted CI SaaS asks you to describe your pipeline in its YAML dialect, run it on its runners, and trust that what happens on push matches what you tested on your laptop. In practice it rarely does. The runner image has different tool versions than your machine. The caching behavior is a black box tuned for their infrastructure, not yours. And if the vendor has an outage, or changes their pricing, or discontinues the tier you're on, your ability to merge code is now downstream of someone else's business decisions — the exact dependency we'd just spent effort removing from source control.
There's also a more fundamental issue: reproducibility. A pipeline that only runs correctly inside a vendor's proprietary runner environment isn't really a pipeline you understand — it's a black box you've agreed to trust. The reproducible-builds movement exists because "it built successfully in CI" and "we know exactly what that build did and can get the same result again" are different claims, and only the second one is worth much when you're debugging a flaky test or auditing what shipped. We wanted the second claim to be true for our own pipeline, not just for the artifacts it produces.
The choice: pipelines as code, plus a small gateway to drive them
We settled on two pieces working together.
The pipeline definitions themselves run on Dagger, which lets us describe a build as ordinary code — in our case, Go, using Dagger's Go SDK — rather than as YAML interpreted by someone else's runner. A Dagger pipeline containerizes each step, so the same function that builds the Rust binary, runs the web build, or runs the security sweep executes identically whether it's invoked from a developer's laptop or from our own infrastructure on every pull request. There is no second, subtly-different "CI-only" version of the pipeline to keep in sync with the one engineers run locally — it's the same code path both times. If a build fails in our automation, we can run the exact same Dagger function locally and watch it fail the exact same way, instead of guessing at what a remote runner's environment looked like.
Around that, we wrote a small CI gateway — a lightweight Go service that listens for Forgejo's webhooks (a pull request opened, a push to the main branch, a tag created), works out which pipeline that event should run, prepares an isolated workspace checkout of the repository at that commit, and hands it to the right Dagger function. When the pipeline finishes, the gateway reports the result back to Forgejo as a commit status, the same mechanism GitHub-style status checks use to gate a merge. It's deliberately unglamorous: webhook in, workspace prepared, pipeline invoked, status posted back out. All of the interesting engineering — the actual build logic, the caching strategy, the container definitions — lives in the Dagger pipeline, not in the gateway. The gateway's whole job is to be small, legible plumbing that we can read start to finish in one sitting.
Why Go for this layer, and not Rust
Rust is FastYoke's product. The multi-tenant engine, the FSM runtime, the API — that's all Rust, deliberately, for the memory-safety and performance guarantees we've written about separately. CI tooling is not the product. It's the ops plane that builds and ships the product, and it has a different set of constraints.
A webhook gateway and a build-orchestration layer are, fundamentally, glue — stitching together HTTP calls, subprocess invocations, container lifecycles, and a bit of state tracking. That's a domain where Go's strengths line up better than Rust's: fast compile-and-iterate cycles when you're reshaping how a webhook handler routes events, a standard library that already speaks HTTP and JSON without ceremony, and a deployment model — a single static-ish binary in a small container — that fits an ops service perfectly. Dagger itself is Go-native at its core, so writing our pipeline definitions in Go via its SDK meant working with the grain of the tool instead of fighting an FFI boundary or a secondary language runtime bolted onto a Rust process.
This isn't a retreat from Rust — it's polyglot by layer, on purpose. The product core, where correctness and performance under multi-tenant load actually matter to a paying customer, stays Rust. The orchestration layer that builds and deploys that core is written in whatever lets us iterate on it fastest, which for container-native glue code is Go. Using the right tool per layer is a design decision, not a compromise — we'd make the same call in reverse if the ops plane needed Rust-grade guarantees and the product logic needed rapid iteration instead.
What it bought us
Reproducibility. Because the pipeline is Dagger code rather than a vendor's YAML dialect, "run the pipeline" means the same thing on a developer's laptop and on our own infrastructure. Debugging a CI failure doesn't require reasoning about a runner environment you can't inspect.
Control and sovereignty. The whole chain — git hosting, webhook delivery, pipeline execution, status reporting — runs on infrastructure we operate. No vendor outage, pricing change, or policy shift can block a merge or a deploy.
Build speed. A large Rust dependency graph is exactly the case where build caching matters most, and running our own build infrastructure means we control the caching strategy end to end instead of hoping a hosted runner's cache heuristics happen to fit our project.
Polyglot by layer. Rust stays the product core. Go runs the ops plane that builds, tests, and ships it. Neither language is doing the other's job, and each is doing the job it's actually good at.
Try it yourself
If you're building on FastYoke, none of this CI machinery is something you need to think about — it's the thing that makes sure what you build actually ships reliably. But if you're curious how the pieces underneath FastYoke fit together, or you want to build against the platform directly, head over to Developers and take a look.