Skip to main content

Kamal 2.0 Runs HEY and Basecamp Without a Control Plane: The Overhead It Skips, and Where It Runs Out

8 min readDora NodaDora Noda
Share
On this page

Two years ago, 37signals ran HEY.com and Basecamp on rented cloud infrastructure like everyone else. Today they run on hardware they own, deployed with a tool built in-house, and — this is the part that gets platform engineers' attention — there is no Kubernetes control plane anywhere in the picture. No etcd cluster to back up. No API server to keep patched. David Heinemeier Hansson has said Kamal now deploys every application in the company's two-decade product heritage, old and new, through one uniform path.

That's a real production result, not a demo. It's also a narrower claim than the "escape Kubernetes entirely" framing it usually gets packaged in. Kamal 2's SSH-and-Docker model genuinely eliminates a specific, measurable chunk of infrastructure overhead — and it genuinely cannot do a specific, well-documented set of things that a Cluster API-managed fleet does by design. Both halves of that sentence are concrete enough to put numbers on, so here they are.

What Kamal Actually Skips: The Control-Plane Tax, Priced Out

Before a Cluster API-managed cluster runs a single tenant workload, it has already spent resources standing up the machinery that makes it declarative. The official kubeadm minimums are the floor, not the comfortable number: 2 CPUs and 2GB of RAM per control-plane node. For high availability you need a minimum of three control-plane nodes, and if etcd is stacked on those same nodes — the common choice for smaller fleets, since running etcd on dedicated machines adds its own overhead — the recommended spec per node climbs to 4 CPU cores and 16GB of memory to leave etcd enough headroom alongside the API server, scheduler, and controller manager. A small dedicated three-node etcd cluster on its own is commonly sized at two cores, 8GB RAM, and 20GB of fast disk with 3,000+ IOPS, per node.

Add it up: a minimally-HA Cluster API control plane wants on the order of three machines at 4 vCPU/16GB each before it schedules anything for a tenant. At current Hetzner dedicated pricing that's roughly the cost of three mid-tier boxes running purely as overhead — paid every month, whether the fleet has one workload or fifty.

Kamal's number for the equivalent line item is zero. There's no control plane to provision because there's no control plane, period. Kamal is an SSH orchestration script, not a reconciling system — it connects to the hosts in your config/deploy.yml, runs Docker commands, and disconnects. Every CPU cycle and gigabyte of RAM on every box you own goes to running your app and kamal-proxy, not to keeping a distributed key-value store and an API server alive on your behalf. For a team running one to five servers, that's not a rounding error — it's often close to doubling the machine count you'd need just to reach parity with what Cluster API demands before the first tenant pod lands.

How Kamal Gets Zero-Downtime Without a Scheduler

The mechanism matters here, because "no control plane" could just as easily mean "no zero-downtime deploys either" — and that's not what Kamal ships. Kamal 2 replaced its original reliance on Traefik with a purpose-built reverse proxy, kamal-proxy, specifically because Traefik's declarative label-based discovery model fought Kamal's imperative one: container labels are immutable, so there was no clean way to tell Traefik "stop routing to this container" without forcing a health check to fail out from under it.

kamal-proxy instead exposes an explicit, imperative command surface that maps one-to-one with what kamal deploy needs to do: boot the new container, poll it until it passes its health check, atomically switch traffic to it, then drain and stop the old one. If the new container never passes its health check, the old one just keeps serving traffic — the deploy fails safe, not open. That single mechanism is also what unlocked kamal-proxy's newer features: maintenance mode (pause routing without stopping containers), canary releases, and running multiple independent apps behind one proxy on a single host with automatic Let's Encrypt certificates per app. None of that required a scheduler, a distributed consensus store, or a declarative reconciliation loop — it required one proxy that does what it's told, when it's told.

Where Kamal Structurally Runs Out of Road

The honest accounting has to include the other half. Kamal is explicit — both in its own docs and in how its maintainers describe it — about being a deployment tool, not an orchestration platform, and the gap between those two categories shows up in specific, reproducible ways:

  • No automatic rescheduling on node failure. If a host running your containers goes down, Kamal does not detect that and relaunch the affected services elsewhere. A Cluster API-managed fleet's MachineHealthCheck + Kubernetes scheduler combination does this by design — a dead node's pods get rescheduled onto healthy capacity without a human in the loop.
  • No cross-node load balancing. kamal-proxy load-balances across containers on a single host. Spreading traffic across multiple hosts running the same service is left to you — a separate load balancer, DNS round-robin, or another tool entirely. Kubernetes Services and cloud load balancer integrations solve this as a first-class primitive.
  • Manual server inventory instead of a declarative Machine fleet. Kamal's server list lives in a YAML file you edit by hand and target with role tags. Cluster API's MachineDeployment/MachineSet objects are reconciled continuously against a declared desired state — add a Machine, and the provider (Hetzner, AWS, bare metal) provisions it, joins it, and starts scheduling onto it, no manual SSH-target editing required.
  • No fleet-wide rolling Kubernetes-style upgrades. There's no Kubernetes version to upgrade in a Kamal fleet, which is a feature until you need declarative, staged, automatically-rolled-back OS or runtime upgrades across dozens of hosts — the exact workflow Cluster API's KubeadmControlPlane rollout mechanics exist to provide.
  • Independent per-service scaling gets manual fast. Need your API layer at 10 replicas, your background workers at 2, and your scheduler at 1, shifting continuously with load? Kubernetes' per-Deployment replica counts and HPA handle that natively. Kamal can target specific server roles with specific service counts, but there's no autoscaler watching metrics and adjusting it for you — every change is a config edit and a redeploy.

None of these are bugs Kamal will eventually fix; they're the direct consequence of not running a scheduler or a reconciliation loop at all. That's the tool working as designed, not a shortfall — but it's exactly the overhead a Cluster API fleet is buying with those extra control-plane machines.

The Money Behind the Migration

It's worth being clear about why 37signals built Kamal in the first place, because the tool is downstream of a bigger bet. DHH's own numbers: the company's cloud bill ran at a $3.2 million/year rate before the 2022 exit, dropped to $1.3 million/year by 2024, and the combined savings from leaving AWS compute and (eventually) S3 storage are now projected to top $10 million over five years — after fully recouping the roughly $700,000 spent on owned Dell hardware within the first year. That's the return that makes "no cloud, no control plane, no managed Kubernetes bill" worth engineering a whole deploy tool around, rather than a preference for owning hardware as an end in itself.

It also explains why Kamal is deliberately unambitious about orchestration. A team repatriating from the cloud to save seven figures a year isn't looking to recreate a hyperscaler's control-plane bill on-premises with a self-managed Kubernetes cluster — that would claw back a meaningful slice of the savings in dedicated overhead machines, the same three-plus boxes priced out above. Kamal's entire design center is "give back as much of that owned hardware as possible to the app," which is precisely why it has nothing resembling a scheduler.

Where the Line Actually Falls

Put the two sides next to each other and the trade stops being philosophical and becomes a sizing question. Below roughly five servers, running one app or several, the multi-machine control-plane tax dominates: you'd be paying for three-plus dedicated overhead boxes to manage a fleet a single SSH script and a YAML file already manage correctly, and you almost certainly don't have the failure modes — nodes dying under live traffic, wildly uneven per-service scaling needs — that a scheduler earns its keep solving. That's Basecamp and HEY's own territory for a meaningful chunk of their fleet, and it's most solo developers' and small teams' territory, full stop.

Past that point, the calculus flips one gap at a time. The day you need a dead node's workload to reschedule itself at 3 a.m. without a human editing a YAML file, or you need one service at 10 replicas and another at 1 with traffic-driven autoscaling, or you're rolling an OS patch across forty hosts and want a staged, health-checked, auto-rollback rollout instead of a script run forty times — that's the day the control-plane overhead stops being a tax and starts being the thing you were missing.

This is precisely the gap a Cluster API-native self-hosted platform is built to close without asking a team to hand-run kubectl on day one: the declarative Machine fleet, the health-check-driven rescheduling, the rolling upgrades — provided as a git-push deploy workflow instead of raw cluster administration. Bex.co is the open-source, AI-native Render alternative built on exactly that model: push a git repo, get a running HTTPS service on machines you own, with the Cluster API guarantees underneath instead of a hand-maintained server list on top. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex