How your code is built with Suga, and why we wrote our own BuildKit frontend
What we needed from a build service
Every Suga deploy has four steps: clone, build, push, roll out. The build dominates the deploy time. It has the most operational surface, and it's the part we want to own the least.
The decision split into two questions: what will run the build, and how does user source get built automatically. The first question has off-the-shelf answers. The second is where most of the interesting work happened.
Whatever ran the build had to:
- Keep a warm layer cache across builds and across tenants, so the second deploy of a service that only changed one line took seconds instead of minutes.
- Produce OCI images, because that's what the rest of our stack already speaks.
- Not require us to babysit a BuildKit cluster of our own.
- Isolate tenants from each other, because the build input is arbitrary code we didn't write.
Running our own multi-tenant BuildKit cluster wasn't a serious option. Mature hosted equivalents already exist, and operating one ourselves would absorb engineering effort we needed elsewhere. Cloud-specific services like Google Cloud Build and AWS CodeBuild assume the build, registry, and deploy target all live inside one cloud's ecosystem. Adopting one would tie the build path to a single provider, and adopting several would mean parallel integrations against each. CI-runner products like GitHub Actions aren't really built for production deploy paths. The fit was a managed remote BuildKit fleet (the space Depot, Namespace, and BuildBuddy sit in).
Depot
Of the managed BuildKit fleets, Depot was the closest fit. It runs BuildKit on hardware we don't manage, so the daemon and the VMs behind it stay outside our operational footprint. We point the depot CLI at it with an API token, and builds run on warm remote builders.
Two things sold it.
The first was the persistent layer cache. It's scoped per Depot project and survives across builds. This means after the first build, subsequent builds only take a few seconds.
The second was that we could lean on BuildKit's native git context. For Dockerfile builds, we hand the builder a repo URL, a commit SHA, and an auth token, and the clone happens on the build VM. Our infrastructure doesn't have to hold the source in memory or stream it through our network at all. That's a BuildKit primitive, not a Depot one, but Depot's CLI and API expose it cleanly enough that we actually use it. For a platform that handles private repos, that's a smaller blast radius for a whole class of mistakes, not just an optimisation.
Depot is a managed service, so picking it means accepting a vendor dependency. We're okay with that because the input is a Railpack plan or a Dockerfile. Both are portable. If we ever need to move, the contents of the build don't change, only where it runs.
Auto-build by default
In modern platforms, auto-builds are expected. When a user pushes a Node service or a Python API they should expect a build to happen without writing a Dockerfile. This idea was popularised by Heroku years ago, Cloud Native Buildpacks have been carrying the torch since, and the recent crop of OSS planners (Nixpacks, Railpack) build on the same shape. In our case, we wanted a buildpack-style step that could figure out the runtime, the install command, the build command, and the start command, then emit a plan so we can deploy their project.
For this we chose Railpack. The plan format is clean and it understands the languages our users actually deploy. Users who want full control can drop in their own Dockerfile. Otherwise, our workers can shallow-clone the repo and run railpack prepare against the tree to produce a plan. It would then invoke depot build with that directory as the build context. On the builder, Railpack's BuildKit frontend executes the plan. The source flows GitHub to our worker, then worker to Depot.
Where it stopped scaling
That shape carried us comfortably for a long time. On a typical project the extra hop costs a few seconds, and our architecture never had to be more than this. On a monorepo of a few hundred thousand files, the build itself would account for less than half the total deploy time and the bulk of deployment being the transfer of source code: GitHub to our Hatchet worker, then the worker to Depot.
Railpack runs in two phases, with both needing access to the source code. railpack prepare analyses the source to produce a plan, then the BuildKit frontend executes that plan against the source. Our setup ran those phases on different machines. The Hatchet worker cloned the repo to run prepare, then handed the full source to Depot for the build. The extra network hop was a symptom. Moving the same code twice was the cost.
Sugapack
We wrote Sugapack when the time saved on monorepo deploys clearly outweighed the time to build it. It's a small custom BuildKit gRPC frontend that wraps Railpack with native git support, so the source fetch moves from our worker onto the remote builder. We've open-sourced it at github.com/nitrictech/sugapack.
Instead of sending source as context, we send Depot a tiny JSON config naming the repo, the ref, and the auth secret. The remote builder runs Sugapack: it does the git fetch via llb.Git(), runs Railpack inline against the freshly fetched tree, and executes the resulting plan. Our worker never holds the source, and the context directory we pass to the CLI is literally empty.
The Depot invocation:
const args = [
"build",
"--save",
"--build-arg",
"BUILDKIT_SYNTAX=ghcr.io/nitrictech/sugapack:latest",
"--file",
"-",
"--metadata-file",
metadataFile,
"--platform",
"linux/amd64",
"--secret",
"id=GIT_AUTH_TOKEN",
];
BUILDKIT_SYNTAX tells BuildKit to use Sugapack as the frontend. --file - makes the CLI read the build spec from stdin, where we pipe a JSON config like { repo, ref, authSecret: "GIT_AUTH_TOKEN" }. That's the whole input from our side. No tarball, no archive, nothing packaged on our end.
BuildKit's frontend mechanism doesn't care what your "Dockerfile" actually contains. Once Sugapack is set as the frontend, the bytes via --file - are whatever Sugapack wants them to be (in our case, JSON). Sugapack embeds Railpack as a Go library rather than shelling out to it, so the git fetch, plan generation, and build execution all compile into a single LLB graph on the builder.
Once we rolled this out, that monorepo deploy landed roughly 2x faster, and other large repos saw similar gains. Smaller projects were already mostly time-in-build, so the absolute change is smaller there. None of the speedup comes from anything clever inside the build itself.
Where this leaves us
A deploy now flows like this: a task lands on our workers, they send Depot a JSON config naming the repo and ref, the Depot builder runs Sugapack to clone the source and build the image, and Keel picks the image up and rolls it. The source never touches our systems. Most of the deploy time is the build itself, and the build doesn't get slower as the repo grows.
If you're running a platform that builds user code, and you're trying to work out whether Depot is enough on its own or whether you'd need to write something like Sugapack for it to fit, we'd be happy to compare notes on Discord.
Deploy something in the next 3 minutes.
Free tier, no credit card, just bring your repo.