Skip to main content

Kamal 2.0's kamal-proxy: How Thin Can a Production PaaS Get Before It Needs Kubernetes?

8 min readDora NodaDora Noda
Share
On this page

Kamal 2 runs HEY and Basecamp in production for 37signals on nothing but Docker, a custom-built reverse proxy called kamal-proxy, and a YAML file. No dashboard. No platform database. No background daemon burning resources beyond the app itself. Kamal 2 also shipped bundled with Rails 8 in late 2024 as the framework's default deploy option, which means "no PaaS required" is now the out-of-the-box pitch for every new Rails app.

That's a real production system, not a toy — 37signals doesn't publish exact subscriber counts for HEY, but the company's own posts put tens of thousands of paying customers on the service within its first few months back in 2020, and it's been running continuously since. The question worth asking isn't whether Kamal works. It's exactly what's in the box that makes it work, and exactly where that box's walls are — because "how thin can the layer between git push and a running container get" only has a useful answer if you can point at the specific line where thin stops being enough.

What's actually in the box

Kamal 1 fronted your containers with Traefik and stitched together zero-downtime swaps through Traefik's dynamic config. Kamal 2 replaced that with kamal-proxy, a purpose-built reverse proxy 37signals wrote from scratch — and the rewrite bought a 1:1 mapping between kamal commands and proxy behavior instead of translating through a general-purpose tool that wasn't built for this one job.

Here's what kamal-proxy actually does, concretely, today:

FeatureWhat it does
Zero-downtime deployBoots the new container, polls its health-check path until it returns 200, atomically switches proxy traffic to it, then retires the old container
Automatic TLSRequests and renews Let's Encrypt certificates per host with no separate cert-manager step
Host-based routingRoutes multiple apps on one server by hostname, no manual nginx/Traefik config
Maintenance modekamal app maintenance drains in-flight requests over a configurable drain_timeout, then serves a 503 page until kamal app live brings it back
Pause / resumeStops and restarts traffic to an app in a few seconds, independent of a full deploy
Canary / gradual rolloutShifts a percentage of traffic to a new version before a full cutover

And the deploy mechanism underneath all of it is SSH. kamal deploy connects to every server listed in deploy.yml, pulls the image, starts the new container, and drives kamal-proxy's swap — no agent installed on the host, no control plane polling anything, no separate database tracking desired state. The entire system of record is the YAML file on the operator's laptop and the containers actually running on each box. That's the whole platform. It's also, per 37signals' own account, enough to run a real email product at real scale.

Where the floor ends

None of that is a criticism — it's a description of what Kamal was built to do, which is deploy your app to your servers. The gaps only show up once you ask a different question: not "can I run my app," but "can I run many other people's apps, on shared infrastructure, without each tenant having to trust the others." Kamal was never trying to answer that question, so it doesn't, in five specific ways.

No cross-node scheduler. deploy.yml assigns roles to specific, named servers — web runs on these three boxes, worker on those two. Kamal doesn't decide where a container goes based on current load or available capacity; the operator decided that when they wrote the file. There's no bin-packing, no placement algorithm, nothing that looks at fleet-wide resource pressure and picks a target.

No self-healing. If one of those named servers goes down, Kamal doesn't notice and doesn't react. Nothing reschedules that server's containers onto a healthy box. The next kamal deploy will fail against the dead host; recovery is a human SSHing in, or editing deploy.yml to route around it.

A push, not a control loop. Kamal's orchestration model is an operator's machine running SSH commands against a fixed list of hosts, once, when invoked. There's no process continuously comparing "what's declared" against "what's actually running" and correcting drift in between deploys. State only moves when a human (or CI) runs the command.

A single-tenant deploy.yml. The config file describes one app's fleet — its servers, its roles, its environment variables, its one set of TLS certs. There's no isolation boundary in the format for "app A's config must never leak into app B's containers," because Kamal was never asked to run app A and app B for two different customers on shared hardware.

Kamal-proxy's zero-downtime swap is a single-host mechanic. The boot → health-check → atomic-switch → retire sequence happens on one server, for one server's container. Rolling that same app out across five servers is five sequential single-host swaps, fanned out over SSH one host at a time, with no fleet-wide surge budget, no "halt the whole rollout if host 3 fails its health check," and no coordination between what host 1 is doing and what host 3 is doing. Each host's zero-downtime guarantee is real and local; there's no equivalent guarantee at the fleet level.

Kamal cares about your servers and little else — that's a direct quote from how the project describes its own philosophy, and it's accurate. The five gaps above aren't bugs. They're the edge of what "care about your servers" was ever meant to cover.

What a Cluster-API PaaS builds on top

A self-hosted PaaS that runs many tenants' apps on shared, owned hardware has to answer the exact five questions Kamal leaves open — and each one maps to a specific, named Kubernetes/Cluster API mechanism, not a vague "add more orchestration":

Kamal's gapWhat closes it
No cross-node schedulerThe Kubernetes scheduler places every pod based on current node capacity and constraints — a bin-packing decision made per-deploy, not baked into a config file ahead of time
No self-healingMachineHealthCheck watches node health; when a Machine goes unhealthy, MachineDeployment replaces it and the scheduler reschedules its workloads onto surviving nodes automatically
Push, not a control loopA Cluster API controller runs continuously, reconciling declared state against actual state on every loop — not just when an operator types a command
Single-tenant config formatNamespace isolation plus RBAC gives every tenant its own boundary — separate config, separate secrets, separate network policy, enforced by the API server rather than by the discipline of one YAML file per app
Single-host zero-downtime swap onlyA Deployment rolling-update strategy (maxSurge/maxUnavailable) plus PodDisruptionBudgets and readiness probes coordinates the swap across every node running that app at once — with an automatic halt if any replica fails its health check anywhere in the fleet, the multi-node equivalent of kamal-proxy's single-host atomic switch

Managed, per-tenant TLS follows the same pattern: cert-manager plus Gateway API's HTTPRoute/TLSRoute types issue and attach a certificate to each tenant's custom domain automatically, the same job kamal-proxy's built-in Let's Encrypt step does for one app's one hostname — just parameterized across however many tenants and domains the fleet is running, instead of one.

Worth naming honestly: this piece doesn't cover every gap between Kamal's floor and a full platform. Secrets rotation, built-in metrics/observability, and autoscaling based on load are all real capabilities neither Kamal nor the mechanisms above hand you for free — they're separate concerns a platform still has to build, layered on top of scheduling, self-healing, and multi-tenant isolation rather than solved by them.

The actual line

None of the five mechanisms above are complexity for its own sake — each earns its place by closing a specific gap a multi-tenant fleet actually hits. That's the useful frame, and it cuts both ways.

If you're one team deploying one app to a handful of servers you already know by name, Kamal is the correct floor, not a stepping stone to something bigger. HEY runs there. Skipping straight to Kubernetes for that workload buys you a scheduler and a control loop you'll never stress, at a real operational cost you'll carry every day.

The moment the workload changes — not "my app on my servers" but "N tenants' arbitrary apps on shared servers I own, where one tenant's failure can't take down another's, and a dead node has to heal itself before a human notices" — every one of Kamal's five gaps becomes a requirement instead of a non-issue. That's not a knock on Kamal. It's a description of a different problem, one Cluster API's scheduler, MachineHealthCheck, reconciliation loop, RBAC, and rolling-update primitives exist specifically to solve.

Bex.co is the open-source, AI-native Render alternative built on exactly that Cluster-API floor — push a git repo, get a running HTTPS service on machines you own, with the scheduling and self-healing a multi-tenant fleet needs already handled underneath. 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