One line in an Unreleased changelog — "Replaced Fly.io, Render, and Heroku deployment with Kamal" — just deleted three hosted deploy targets from a production Rails boilerplate in a single commit. Nine files touched: fly.toml, render.yaml, the Procfile, a GitHub deploy workflow, and the Dockerfile generator config gone; one deploy.yml, one Dockerfile, and one secrets file rewritten around a self-hosted Postgres container in their place. Three vendor dashboards, three bills, and three sets of free-tier asterisks collapsed into a single SSH-and-Docker config pointing at one virtual machine.
Here is the verdict before the why: this receipt proves Kamal is the smallest stack that still counts as self-hosted production for one app on one box — and it proves nothing past that boundary. The same changelog carries the warning label in its Security section: a Faraday SSRF patch (CVE-2026-25765) the maintainer still had to apply by hand, because leaving a PaaS never outsources patching — it just moves the pager from the vendor to you. This post itemizes the receipt (what one config now covers, priced three ways against one VPS), names the honest boundary list of what Kamal does not do, and places the move on the exit ladder between swapping PaaSes and running a Cluster API fleet.
The one-line diff that deleted three PaaSes
The repo is moneygun, Yaroslav Shmarov's Rails 8 multi-tenant SaaS boilerplate ("ship your next app fast" — organizations, memberships, subscriptions, the boring consequential parts of B2B SaaS). Its CHANGELOG.md [Unreleased] section, in Keep-a-Changelog format, lists under Changed the single line that is this whole story: "Replaced Fly.io, Render, and Heroku deployment with Kamal."
The commit behind it is admirably legible. Out: fly.toml, render.yaml, the Procfile, the fly-deploy.yml GitHub workflow, and the Dockerfile-generator config — the entire multi-vendor deploy surface. In: config/deploy.yml, a hand-maintained Dockerfile, and .kamal/secrets, following Kamal conventions with PostgreSQL as an accessory. Nine files, one deploy target, zero vendors.
Why this receipt matters beyond one boilerplate: moneygun is a starting point other people clone. Every deploy target it carries is a path its users can walk without thinking, and every one it drops is a maintenance surface its maintainer stops testing. Consolidating three PaaS paths into Kamal is a maintainer betting, in public, that the default answer to "where does my new Rails app run?" should be a box you rent — not a platform you subscribe to. That bet is worth auditing line by line, because it is the same bet every team leaving Render or Heroku is being asked to make.
The receipt, itemized: what one Kamal config now covers
Two artifacts, kept strictly separate — because the most common lie in exit-PaaS math is summing bills nobody ever paid together. A boilerplate supports three PaaSes; a user pays one. So the config surface collapses three-to-one, but the honest cost read is one-PaaS-bill versus one-VPS, priced three ways as alternatives.
Artifact 1: the maintainer burden, before and after.
| Before (3 targets) | After (Kamal) |
|---|---|
fly.toml + render.yaml + Procfile (3 vendor dialects) | config/deploy.yml (1 file) |
fly-deploy.yml workflow + Dockerfile-generator config | Stock Dockerfile + .kamal/secrets |
| 3 dashboards, 3 CLIs, 3 secret stores | SSH + kamal CLI, secrets in one file |
| 3 bills, 3 free-tier expiry asterisks | 1 VPS invoice |
| Postgres/Redis as 3 different managed add-ons | Postgres/Redis as accessories on the same box |
Every row is toil removed, and the last two are the ones that compound: no managed-add-on pricing to re-check each renewal, no free-tier clock (Render's free Postgres famously expires after 30 days) silently converting a side project into a bill.
Artifact 2: the cost, pinned to a checkable spec. Reference workload: a small-prod Rails app — one web process at 512MB–1GB RAM plus a starter managed Postgres. Each PaaS row is the September 2026 list price for that spec on that platform, an alternative, never summed — against the same single Hetzner-class VPS running the app plus Postgres and Redis accessories on the one box.
| Platform | Pinned-spec price (web + starter Postgres) | Shape |
|---|---|---|
| Render | ≈$14/mo ($7 Starter web + $7 Postgres) | Sleeps nothing, but the bill scales per service |
| Heroku | ≈$30/mo ($25 Standard-1X + $5 Postgres Mini) | The production floor; Eco is cheaper, Standard is honest |
| Fly.io | ≈$10–15/mo (shared-cpu web + small Postgres Machine) | Usage-shaped; the cheapest PaaS row and the fiddliest Postgres |
| One VPS + Kamal | ≈$5–6/mo (€5-class Hetzner box, app + Postgres + Redis) | Flat. No per-service meter, no add-on ladder |
Two honest footnotes, because a receipt with fine print beats a slogan. First, the VPS row buys no managed Postgres: backups, replication, and failover are yours now (the boundary list below prices that omission). Second, bandwidth and add-ons sit outside every row — PaaS egress meters and the VPS's generous-but-finite transfer allowance are a second comparison, not part of this one. With those caveats stated, the shape of the answer is stable across all three alternatives: the box costs roughly a third to a sixth of any one PaaS bill, and the delta is the managed-database and per-service-meter premium you are choosing to stop paying.
What Kamal actually is: SSH, Docker, and a proxy
The mechanism fits in three paragraphs, which is the entire point — the mental model is small enough to hold in your head.
Push deploys over SSH. Kamal (37signals, MIT, ~14k stars) builds your image, pushes it to a registry, then SSHes into each host in deploy.yml and rolls containers with Docker. No control plane, no agents, no YAML operator to install on the cluster — because there is no cluster. kamal setup turns a fresh Linux box into an app server; kamal deploy swaps the running containers.
kamal-proxy does zero-downtime and TLS. Kamal 2 replaced the launch-era Traefik integration with a purpose-built proxy: gapless version switches, automatic Let's Encrypt HTTPS, maintenance mode, and multiple apps per host. One config value (ssl: true) covers what used to be the most confusing page of the Kamal 1 docs, and it is why a single box can now front several services without a separate reverse-proxy project.
Accessories are Postgres and Redis as sidecars. The moneygun commit's key move is making PostgreSQL an accessory: a plain container on the same box, on the same Docker network, managed with kamal accessory boot rather than kamal deploy. Deploys never touch it. That separation is what lets one VPS absorb the whole reference workload — app, database, and job queue on a single invoice.
One context line explains why this receipt appears in a Rails boilerplate first: Rails 8 ships Kamal 2 preconfigured, with Thruster in the default Dockerfile and kamal setup as the documented path from rails new to production. "No PaaS required" is not a blog slogan; it is the framework's default deploy story since the 8.0 release. Moneygun's changelog is downstream of DHH's bet, not independent of it — which cuts both ways. It means the Kamal path in a Rails repo is the best-tested Kamal path anywhere, and it means non-Rails teams reading this receipt should discount the smoothness by the maturity of Kamal support in their own stack.
What the receipt doesn't prove: the honest boundary list
Kamal's docs and its handbook are unusually candid about what the tool is not. Here is the boundary list, each item mapped to the concrete 3 a.m. scenario it becomes — because "no scheduler" is abstract and "the box died and nothing rescheduled anything" is a pager.
| Kamal does not… | …so when this happens | …you do this |
|---|---|---|
| Schedule or reschedule | The host dies, Docker daemon wedges, OOM-killer takes the app | Notice (your monitoring), SSH in, restart or reprovision by hand |
| Autoscale or provision | Traffic spikes 10×; you need a second box | Provision it yourself, add it to deploy.yml, redeploy |
| Balance across servers | You add that second box | kamal-proxy balances containers per host only — cross-server LB is an external load balancer you bring |
| Reconcile state | You remove a service from deploy.yml | Its containers keep running until you remove them; config is intent, not enforcement |
| Back up accessories | The Postgres container's disk corrupts | Restore from the backup job you wrote, tested, and monitor |
| Fail over state | Postgres or Redis falls over | Restart it; there is no replica unless you built one |
The sharpest edge is the stateful row, and reviewers keep converging on it: Kamal will happily run Postgres, but it does nothing for backups, replication, or failover — that part is entirely yours. A managed Postgres Mini at $5/mo is not $5 of disk; it is $5 of somebody else's tested restore drill. The VPS row in the cost table above looks like a 3–6× saving until you price the Saturday you spend proving pg_restore works from the cron job you wrote. Some teams should absolutely pay that Saturday instead of the meter — just book it on the calendar instead of discovering it during an outage.
And then there is the warning label the changelog itself provides. Directly below the Kamal line, under Security: "Updated faraday to 2.14.1 to fix SSRF vulnerability (CVE-2026-25765)" — a protocol-relative-URL host override (//evil.com redirecting Faraday requests to an arbitrary host, CVSS 5.8, fixed in 2.14.1, published February 2026). Dependency patching follows the app wherever it runs; no deploy tool, Kamal included, applies your gem updates. The PaaS exit removes the vendor's pager for their CVEs (buildpack base images, managed Postgres) and hands you the whole list. Read the two changelog lines together and they rhyme: one line celebrates the freedom, the next prices it.
The exit ladder: where this receipt sits
"Leave the PaaS" is not one decision; it is a ladder, and moneygun's receipt is rung 2. Each rung names what changes and what it costs, so a team can find itself and see the next step before the current one breaks.
| Rung | Move | What changes | What it costs |
|---|---|---|---|
| 1. Swap PaaS | Heroku → Render, Render → Railway/Fly | One bill replaces another; DX stays managed | Migration toil; the meter keeps running |
| 2. Kamal on one box | This receipt | One config, one VPS invoice, you own patching/backups | Ops Saturdays; single-host fate-sharing |
| 3. Fleet-lite | Kamal multi-host + external LB, or a single-box panel (Coolify/Dokploy/Dokku) | A UI or a second host; still push-based, still no reconciler | Panel maintenance or DIY multi-host glue |
| 4. Cluster API fleet | Declarative machine lifecycle on owned hardware | Machines provision, heal, and upgrade from manifests; the second-machine story exists | Kubernetes-shaped complexity, run on your own metal |
Rung 3 deserves its one-line warning: panels like Coolify and Dokploy solve "manage the box you already have" with a nicer surface than SSH, but they stop at the same seam Kamal does — no fleet-wide provisioning, no declarative machine lifecycle. They are rung 2 with a dashboard, not rung 4 with training wheels.
The trigger for rung 4 is concrete, and it is the question to ask before celebrating rung 2: what happens when you need a second machine? Not a bigger box — a second one, for failover, for separating the database from the app tier, for surviving a host failure without a restore drill. Kamal can deploy to N hosts, but it cannot provision the Nth, cannot balance across them, cannot notice one died. A Cluster API fleet is the rung where "add a machine" is a manifest change the platform reconciles instead of a weekend project. Moneygun, a boilerplate whose users deploy one app each, is correctly on rung 2 — its users' second-machine day, if it ever comes, is the day they outgrow the receipt rather than the day the receipt was wrong.
The smallest credible self-hosted stack
Step back from the tables and the durable read is simple. There is now a documented, framework-blessed floor for self-hosting a web app in production: one VPS, one deploy.yml, Postgres as an accessory, TLS and zero-downtime from the bundled proxy — roughly $5/mo and one SSH key. Anything smaller (a PaaS free tier with an expiry clock, a bare VPS with hand-rolled systemd units and no deploy story) is either rented or improvised. Kamal 2 is the first rung that is owned and repeatable, and moneygun's changelog is the receipt proving a real maintainer now treats it as the default rather than the adventure.
The honest corollary is that the floor is also a ceiling with a known height: one box, push deploys, you as the reconciler. The ladder above it — panels, multi-host glue, then declarative fleets — exists precisely because every production app eventually asks the second-machine question. Know which rung you are on, price the Saturdays honestly, and let the receipt be what it is: proof of the smallest credible stack, and a map of everything past it.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Kamal proves rung 2; bex is the bet that rung 4 — declarative fleets with agent-first operations — should be just as boring. Star the repo on GitHub or deploy your first app today.



