Every self-hosted PaaS eventually hits the same awkward question: where do container images get built? The classic answer — spin up a Docker-in-Docker pod per build, mount the socket or nest a whole dockerd, tear it down — works, but it hands every build pod the keys to the kingdom.
Kapibara, the open-source deployment tool from Orcinus Tools, just shipped a reference design for the alternative: when KAPIBARA_INCLUSTER_BUILD=1, the control plane clones the repo, generates a plan with railpack prepare, drives an in-cluster BuildKit daemon over TCP with buildctl, and has BuildKit push the image straight to the in-cluster registry. No Docker on the client. No Docker on the server. One privileged daemon replaces a privileged daemon per build.
The shape of the design in 60 seconds
Here is the whole path, end to end:
git push → control plane clones repo → railpack prepare (plan JSON)
→ buildctl → buildkitd (tcp://buildkitd.orcinus-build.svc:1234)
→ push to in-cluster registry (registry/<scope>/kapibara/<proj>-<app>:<sha>)
→ cluster pulls it back through the public gateway → deployThe commit that introduced it (orcinustools/kapibara@8ece3eb, "server-side, Docker-less Git builds") touches exactly the files you would expect: a new deploy/buildkitd.yaml manifest, a Frontend path in pkg/build/build.go, config flags, and a Dockerfile that bundles git plus a pinned railpack binary plus buildctl into the runtime image. Two details make the design hang together. First, push and pull share one org-scoped repo path, so no push auth or containerd reconfiguration is needed — BuildKit pushes to the in-cluster registry address, and the cluster pulls the same path back through the public gateway. Second, builds target linux/amd64 by default regardless of host architecture, so the image always matches the cluster.
That is the thesis in one paragraph: collapse N per-build Docker daemons into one long-lived BuildKit daemon, pin the toolchain in one image, and let the registry — not the builder — be the handoff point. The rest of this post walks the design step by step, inventories what it removes from the classic DinD setup, and gives an honest accounting of what it still costs.
What the classic DinD build pod costs you
Before appreciating the replacement, it helps to itemize what the DinD pattern actually charges. There are two common flavors — true Docker-in-Docker (a nested dockerd inside a privileged pod) and Docker-out-of-Docker (mounting /var/run/docker.sock from the node) — and both carry the same structural taxes:
| Cost | DinD / DooD build pod | In-cluster buildkitd |
|---|---|---|
| Privilege scope | privileged: true on every build pod, or a host socket mount with effective root | privileged: true on one long-lived Deployment |
| Daemon lifecycle | Cold-start a dockerd per build (storage driver init, image pulls) | Warm daemon with persistent /var/lib/buildkit cache |
| Toolchain pinning | Builder image drifts per pipeline unless pinned everywhere | One runtime image: git + pinned railpack + buildctl |
| Build isolation | Builds share a dockerd; cache poisoning and noisy neighbors | BuildKit's DAG scheduler with per-build sessions |
| Blast radius | Any build pod compromise reaches the node/container runtime | One daemon to harden, one address to firewall |
The security point deserves emphasis because it is the one teams underweight. A DinD pod needs --privileged to function — the official docker:dind image documentation says so outright — which disables essentially every container isolation boundary for that pod. Multiply that by every concurrent build, on shared node pools, running untrusted tenant Dockerfiles, and the build farm becomes the softest target in the fleet. The standard replacements the ecosystem points at — Kaniko, BuildKit, Podman, Buildah — all exist precisely to delete the nested daemon. Kapibara picked BuildKit, and the interesting part is how it wires BuildKit into a git-push flow rather than a CI pipeline.
Walking Kapibara's build path step by step
The design has five concrete steps, each with a real name you can grep for in the repo.
Step 1: Flip the flag. Setting KAPIBARA_INCLUSTER_BUILD=1 (plus KAPIBARA_BUILDKIT_ADDR, KAPIBARA_BUILD_PLATFORM, and KAPIBARA_RAILPACK_FRONTEND) switches the deployer from the client-side docker/railpack path to the new Builder.Frontend path. Configuration flows through pkg/config into the API server and deployer, and the deployer computes the shared push/pull refs from the org scope, project, app, and commit SHA.
Step 2: Clone and detect. The control plane clones the repo itself and decides between two frontends: if there is a Dockerfile, it uses BuildKit's built-in dockerfile.v0; otherwise it runs railpack prepare to auto-detect the stack. Note the deliberate exclusion: nixpacks is unsupported in this mode. That tracks the ecosystem — Railway shipped Railpack in beta in March 2025 as the successor to Nixpacks, Nixpacks is in maintenance mode, and Railpack's BuildKit-graph approach produces roughly 38% smaller Node images and up to 77% smaller Python images. Betting the new path on the maintained builder is the right call.
Step 3: Generate the plan. For railpack builds, the control plane runs:
railpack prepare <context> \
--plan-out /tmp/railpack-plan/railpack-plan.json \
--info-out /tmp/railpack-plan/railpack-info.jsonThis is the "frontend" half of the split: language detection and build-plan generation happen in the control plane, producing plain JSON. The plan directory is a temp dir, cleaned up after the build. Keeping detection out of the daemon means the daemon never needs to know about runtimes — it just executes a graph.
Step 4: Drive the daemon. The control plane shells out to buildctl against tcp://buildkitd.orcinus-build.svc:1234, handing the context plus plan to the ghcr.io/railwayapp/railpack-frontend image over the gateway.v0 protocol (or dockerfile.v0 for Dockerfiles). The exporter is type=image,push=true, so BuildKit pushes the result itself — the built bytes never transit back through the control plane. That single choice removes an entire class of "large image through the API server" problems.
Step 5: Push once, pull through the gateway. The push target is the in-cluster registry (registry.orcinus-registry.svc:5000), while the cluster later pulls the same repo path through the public gateway. Because both sides agree on registry/<scope>/kapibara/<proj>-<app>:<sha>, neither push credentials on the builder nor containerd mirrors on the nodes are required. It is a small naming-convention trick that deletes a surprising amount of credential-plumbing code.
What this removes
Mapped against the DinD cost table, the design deletes three things outright and improves a fourth:
- No Docker socket, anywhere. Neither the control plane nor the build path touches
/var/run/docker.sock. The DooD variant's "mount the host daemon and hope" disappears completely. - No privileged daemon per build. The per-build blast radius collapses to an unprivileged control-plane process shelling out to
buildctl. Privilege concentrates in one place — the buildkitd Deployment — which is exactly where you want it for auditing. - Reproducible toolchain pinning.
git, therailpackbinary, andbuildctlship in one runtime image, and the railpack frontend image is pinned alongside the railpack version. A build from last month reruns with the same toolchain, not whatever the builder image happens to contain today. - Architecture independence. The
linux/amd64default means builds produce cluster-correct images even when the control plane runs on ARM hardware — a quiet footgun in mixed-arch fleets, gone by default.
What it still costs
No honest reference design ends at "and then everything was easy." Here is the remaining bill, straight from the manifests:
One privileged Deployment to harden. deploy/buildkitd.yaml sets privileged: true on the buildkitd container — BuildKit needs it to set up its OCI build sandboxes. The comment in the manifest says so plainly. Concentrating privilege is strictly better than spraying it across build pods, but this Deployment is now the highest-value target in the build namespace: it deserves a dedicated namespace (Kapibara uses orcinus-build), a NetworkPolicy that admits only the control plane on port 1234, and resource limits so a malicious build cannot starve the node.
A plain-HTTP registry exception, in two places. The in-cluster registry serves plain HTTP, so the exception appears both in buildkitd.toml ([registry."registry.orcinus-registry.svc:5000"] with http = true and insecure = true) and in the exporter flags (registry.insecure=true). Scope this tightly: it must cover exactly the in-cluster registry hostname, never a wildcard, and the upgrade path to TLS (or a pull-through cache with proper certs) should be on the roadmap from day one.
An unauthenticated TCP control channel. buildkitd listens on tcp://0.0.0.0:1234 with no mTLS in this manifest, and the readiness probe is buildctl debug workers. Anyone who can reach that port can drive builds. In-cluster Service DNS plus NetworkPolicy is the minimum; mTLS on the BuildKit control API is the follow-up for multi-tenant fleets.
Ephemeral build cache. The cache volume is an emptyDir on /var/lib/buildkit, so a pod reschedule wipes the layer cache and the next builds go cold. For a build farm with real traffic, that wants to become a persistent volume (or registry-backed cache with --export-cache type=registry) before the first busy Monday.
Nixpacks stays behind. Teams with nixpacks.toml files must migrate to railpack.json or a Dockerfile before using this path. Given Nixpacks' maintenance-mode status, that migration is coming regardless — but it is a real adoption cost to name.
None of these is disqualifying; all of them are cheaper than per-build DinD. But a reference design that hides its costs is a trap, and this one is admirably legible about them.
Borrow-it checklist for your own build-node pool
If you run a Cluster API fleet — say, CAPH-provisioned Hetzner machines — and want this pattern for your own git-push builds, the portable core is small:
- Stand up buildkitd as infra, not per-build. One Deployment (or DaemonSet, once you outgrow a single daemon) in a dedicated
buildnamespace, with the config in a ConfigMap so the insecure-registry exception is reviewable in Git. - Taint a build-node pool. Builds are bursty and cache-hungry; keep them off workload nodes with a taint/toleration pair so a big
npm cinever steals CPU from tenant pods. - Firewall the daemon. NetworkPolicy: only the control plane talks to :1234. If tenants ever get direct build access, add mTLS first.
- Pin the toolchain image. One image with git, a pinned railpack, and buildctl; bump it deliberately, with the frontend image version bumped in lockstep.
- Persist the cache. Swap
emptyDirfor a PV or registry-backed cache export before traffic arrives — cold-cache builds are the latency complaint you will otherwise debug at 2am. - Plan the rootless follow-up. Upstream BuildKit ships a
rootlessimage variant and recommends it for the Kubernetes examples; Kapibara's privileged starting point is pragmatic, but rootless is the direction of travel. Track it as tech debt with a name, not a vague aspiration.
The deeper lesson is architectural: the build farm stops being "a bunch of pods that each contain a Docker daemon" and becomes "one daemon plus a registry plus a naming convention." That is a system a small team can reason about, audit, and harden — which is precisely what a self-hosted PaaS needs its build story to be.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.



