Skip to main content

Kamal 2 Bet Against Kubernetes and Rails 8 Made It the Default: What SSH-Based Docker Deploy Gets Right (and Where It Runs Out of Room)

8 min readDora NodaDora Noda
Share

Rails 8 shipped with a deployment tool that refuses to talk to Kubernetes, and Basecamp's bet paid off enough that it's now the default bin/kamal in every new Rails app. Kamal 2 gets a Dockerized Rails app onto a bare VPS with a first deploy in 3-5 minutes, zero-downtime traffic swaps, and automatic Let's Encrypt TLS — no cluster, no control plane, no YAML dialect to learn. It's also, structurally, the wrong tool the moment you need a second sidekiq worker added dynamically instead of provisioned by hand. Here's exactly where that line sits, and what a Cluster-API-managed fleet does differently on the other side of it.

What Kamal actually does

Kamal is SSH with better manners. It has no agent running on your servers, no control-plane process, no cluster state stored anywhere but your config/deploy.yml and whatever's on the boxes themselves. When you run kamal deploy, it does four things over SSH, in order:

  1. Builds your Docker image locally (or on a designated remote builder for multi-arch).
  2. Pushes it to a registry — Docker Hub, GHCR, or a self-hosted one.
  3. Pulls it on every server listed under that role in deploy.yml.
  4. Swaps traffic via kamal-proxy, the purpose-built reverse proxy Kamal 2 shipped to replace Traefik: it starts the new container, health-checks it, routes new requests to it, drains in-flight requests from the old one, then kills it. Zero downtime, no external load balancer required for a single box.

First deploy takes 3-5 minutes end to end — building the image, pushing it, pulling it fresh on the server. Subsequent deploys drop to 60-90 seconds once Docker's layer cache is warm. That's the whole system. There's no scheduler deciding where a container runs; you decided that when you wrote the server's IP into the YAML file.

Where the bet is structurally right

Basecamp's argument, made explicitly in the Kamal 2 release notes, is that most apps don't need a scheduler because most apps don't have a placement problem — they have a fixed, small, slowly-changing list of things that need to run somewhere. A web role on two boxes, a worker role on one, Postgres and Redis as accessories. That list doesn't change week to week for the overwhelming majority of Rails apps Kamal targets.

For that shape of workload, Kamal wins on every axis that matters to a solo developer or small team:

  • No control plane to operate. Kubernetes' floor cost isn't compute, it's the etcd/apiserver/controller-manager triad you have to keep alive before a single workload pod runs. Kamal's floor cost is an SSH key.
  • Config you can read top to bottom. deploy.yml is a few dozen lines. There's no CRD graph, no admission webhook chain, no six files that together define one Deployment.
  • The whole system fits in one person's head. When something breaks, "SSH into the box and look" is still a valid debugging step, because the box is the whole runtime.

This is the same reasoning that put PocketBase and single-binary tools on the opposite end of the self-hosting complexity spectrum from a Cluster-API fleet — and for the same class of workload, it's the honest right answer, not a placeholder until you "graduate" to something heavier.

Where it runs out of room

The wall shows up at a specific, nameable moment, not a vague "at scale." Developer Piotr Ch described it concretely after running both: you want to add two more sidekiq processes to clear a backlog faster, or scale an accessory to two instances because one isn't keeping up. In Kubernetes that's a replica-count edit. In Kamal it's:

  1. Editing deploy.yml to add the new role/count by hand.
  2. Deciding which server the new process lands on — Kamal has no bin-packing scheduler, so that's your call, made by reading current server load yourself.
  3. Re-running kamal deploy to push the change out.

That's not a deploy problem, it's an absence: there is no reconciliation loop. Kamal's entire model is "run this command, and the described state exists until the next command changes it." Nothing is watching to confirm the described state still matches reality five minutes later. Concretely, this is where the gaps are:

CapabilityKamal 2What's missing
Service discovery across serversNone built-inYou wire it yourself or run one process per box
Load balancing across multiple serversExternal LB requiredKamal only balances traffic to a healthy container on the servers you list, not across servers dynamically
Autoscaling on CPU/request rateNoneEvery scale change is a manual deploy.yml edit + redeploy
Node failure recoveryNoneA dead server stays dead until a human notices and re-provisions it
Bin-packing (placement) across a fleetNoneYou decide which server each role runs on, statically

The node-failure gap is the one that actually costs sleep. If a Kamal-managed box dies at 3am, nothing detects it and nothing replaces it — the alert that wakes you up is the outage. There's no equivalent of a controller watching machine health and acting on it.

What a Cluster-API-managed fleet does instead

This is the exact gap Cluster API closes, and it closes it as a genuine reconciliation loop, not a script you re-run. A MachineHealthCheck resource continuously watches node conditions; when a machine fails its health check, Cluster API deletes it and creates a replacement automatically — no human paged, no manual re-provisioning. Scaling a workload is a replica-count field in a Deployment spec, and the scheduler bin-packs the new pods onto whichever nodes have room, across the whole fleet, without anyone picking a server by hand.

The corresponding row-by-row answer to the table above:

  • Service discovery — built into the cluster DNS and Service objects; every pod resolves every other role by name, automatically.
  • Load balancing across nodes — the Service/Ingress layer distributes traffic across however many pod replicas exist, on whichever nodes they landed on.
  • Autoscaling — a Horizontal Pod Autoscaler watching CPU or request-rate metrics, no manual YAML edit per scale event.
  • Node failure recoveryMachineHealthCheck + Cluster API's controller: unhealthy node detected, deleted, replaced, all declaratively, all without a page.
  • Bin-packing — the scheduler's job, running continuously, not a human reading htop across servers before editing a config file.

That's the price of the extra machinery: an etcd/apiserver/controller-manager control plane has to exist and be operated before any of this reconciliation runs. On a Cluster-API-based platform like bex, that control plane is the product's own job to run well — provisioning and self-healing Hetzner-backed clusters via CAPH so a tenant never has to stand up the reconciliation layer themselves, the same way SNCF runs its entire national-rail Kubernetes fleet on Cluster API as the reconciliation loop for continuous, drift-free updates across every cluster it operates.

"SSH to a box" and "reconcile a fleet" are different products

The framing that gets this wrong is a spectrum, with Kamal on the simple end and a full orchestrator on the complex end, as if you slide along one axis as you grow. That's not what's actually happening. Kamal and a Cluster-API fleet are solving different problems that happen to both be called "deployment":

  • Kamal answers: "put this container on this specific, known set of machines, with zero-downtime traffic swap." It does that extremely well, with almost no operational surface area.
  • Cluster API answers: "keep a declared desired state true across a fleet of machines whose individual identity doesn't matter, indefinitely, without a human in the loop." That's a fundamentally different guarantee — one Kamal was never designed to make and doesn't pretend to.

The decision isn't "how complex is my app" — it's a narrower, more answerable question: does the set of things that need to run, and where, change on a schedule faster than a human can safely react to it? If your worker count moves once a quarter and you have one or two boxes, Kamal's SSH-plus-proxy model is genuinely the correct engineering choice, not a stepping stone. If a node dying at 3am needs to self-heal before your on-call phone rings, or "add two workers" needs to be a number change instead of a server-picking exercise, you need a reconciliation loop underneath you — and at that point, the question stops being "Kamal or Kubernetes" and becomes "who operates that reconciliation loop for me."

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with a Cluster-API-managed control plane handling the node health-checks, bin-packing, and self-healing Kamal deliberately leaves as your job. Star the repo on GitHub or deploy your first app today.


Sources

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