Cutting a ~$4,000/year hosting bill to ~$120 — with more than twice the memory behind it
A production Rails application moved off Heroku onto self-hosted infrastructure — production and staging both replaced, in roughly 30 hours across one week. The saving came from one observation: the single most expensive line on the bill existed to buy 2.5 GB of RAM.
- Client
- Book-inventory & resale operations platform
- Scope
- Infrastructure migration, solo
- Delivered
- August 2026 · one week, ~30 hours
At a glance
The bill was concentrated in one line item
A book-inventory and resale operations platform. Staff scan books, the system prices them against market data, routes them through a sorting pipeline, and fulfils orders across several marketplaces. Rails 6.1, roughly 50,000 SKUs under management, with a handful of marketplace and carrier integrations. Six scheduled jobs, two of them running every minute.
Its defining constraint is the background workload: large, frequent Sidekiq jobs that walk tens of thousands of records while waiting on third-party HTTP APIs. That work is bounded by memory, not CPU — which is what made Heroku's pricing model such a poor fit.
| Component | Specification | Monthly | Share |
|---|---|---|---|
| Web dyno — Standard-2X | 1 GB RAM, shared compute | ~$50 | 15% |
| Worker dyno — Performance-M | 2.5 GB RAM, dedicated compute | ~$250 | 76% |
| Redis — Premium tier | Managed, with HA / failover | ~$30 | 9% |
| Total | 3.5 GB application memory | ~$330 | — |
The worker dyno was 76% of the spend, bought almost entirely for 2.5 GB of RAM. On a VPS, RAM is the cheapest thing you can buy. That asymmetry is the whole business case.
Cost
Before any infrastructure code I produced a written capacity estimate: the baseline itemised off the Heroku dashboard, per-process memory modelled from the Puma and Sidekiq configuration, thread capacity derived from that budget, and candidate tiers costed side by side. What that document established is that nominal RAM is not usable RAM. The OS, Redis and the reverse proxy take roughly 0.9 GB off the top before the application gets anything — so the 4 GB tier lands at about 3.1 GB usable, *below* the 3.5 GB already running in production. It would have been a cost win and a capacity downgrade at the same time. Netting that overhead out, rather than comparing sticker figures, is what selected the tier.
The estimate was explicit that its throughput projections were modelled rather than measured — there was no APM baseline to work from. The same caution applies to every multiple on this page: they describe allocation and configuration — dyno sizes, container caps, Puma workers × threads — not measured throughput. More memory and more threads is headroom the workload previously did not have, which is not the same claim as a benchmark.
The client is unnamed and the figures rounded on purpose — a case study shouldn't double as a disclosure. Costs are converted to USD from an annual invoice in CAD. The memory-per-dollar comparison is ~$330/month ÷ 3.5 GB ≈ $94 per GB per month before, against ~$120/year ÷ 12 ÷ 8 GB ≈ $1.25 after, so roughly 75×; it counts application memory only, since the earlier figure bought 3.5 GB of it, and counting the self-hosted Redis would push it past 90×. Two notes on reading this honestly: the Heroku figure is production only — a separate staging application existed whose cost was never itemised, so the true like-for-like saving is larger than stated, but I won't quantify what wasn't measured. And an annual rate should be checked against the renewal price rather than a promotional first term.
Capacity was reallocated, not just increased
Heroku forced the split across two fixed dynos that could not lend memory to each other: 1 GB for web whether it needed it or not, 2.5 GB for the worker and no way to go further without buying the next tier for both. One pooled box let both roles grow at once, and grow by different amounts — web tripled so Puma can run clustered instead of as a single process, the worker doubled so the heavy jobs stop meeting a ceiling.
| Resource | Before | After | Change | Note |
|---|---|---|---|---|
| Web memory | 1 GB | 2.5–3 GB | 3× | Fixed dyno size → container cap |
| Worker memory | 2.5 GB | 5 GB | 2× | Was a hard platform ceiling |
| Web processes × threads | 1 × 5 | 3 × 5 | 3× | Single-mode Puma → clustered |
| Worker threads | 10 | 12 | 1.2× | Bounded by memory, not CPU |
| Redis | Managed add-on | 1.3 GB on-box | — | noeviction, so a full queue fails loudly |
| Database connections | 22 | 32 | 1.45× | Throttled during the transition |
| Application memory | 3.5 GB | 8 GB | 2.3× | Web and worker combined |
That worker figure is the one that matters. The heavy jobs walk ~50,000 SKUs one record at a time while waiting on third-party APIs, and they were previously bounded by 2.5 GB — a limit set by what the platform sold, not by what the work needed.
Staging, at no additional server cost
Staging runs as a second deploy target on the same machine, with its own isolated data stores and resource caps, sharing only the hardware and the reverse proxy. On Heroku it was a separately billed application. Its caps are set low enough that a runaway staging container is stopped by the kernel long before it can starve a production job — staging is the thing that gives way under pressure, which is the correct trade on shared hardware.
One machine is one failure domain
Kamal gives zero-downtime application deploys, not host redundancy. A failed host is downtime until it is rebuilt. That was accepted deliberately at this price point rather than discovered later, and it is the honest counterweight to the cost figure: part of the saving is paid for in resilience, not conjured from nowhere. At a tenth of the old bill there is ample room to buy redundancy back if the risk stops being acceptable.
Findings worth writing down
Things that cost real time, written down so the next person doesn't have to rediscover them.
A published Docker port bypasses the firewall completely
Publishing a container port makes Docker write DNAT rules into nat/PREROUTING, and the traffic is then evaluated through Docker's own chain in FORWARD — so the firewall's INPUT rules never see it. A default-deny policy can look entirely intact while a published port sits wide open behind it, and the rules list gives you no hint. This is the argument for host-based routing through a reverse proxy: nothing is published, so there is nothing to bypass.
docker image prune -af destroys your rollback path
Kamal owns tagged-image retention, and that retention is what makes kamal rollback possible; a host-side prune deleting tagged images races it. --filter until= is not a safeguard either — it reads the image's creation date, not when it arrived, so an image built on an older base can be deleted the day it is pulled. Observed directly with a freshly pulled test image. The weekly host job prunes dangling images and build cache only.
A single em dash in ~/.ssh/config breaks the deploy
Kamal talks SSH through net-ssh, which reads that file using Ruby's external encoding. One non-ASCII character raises invalid byte sequence in US-ASCII anywhere no UTF-8 locale is set — cron, CI, systemd. Works interactively, fails in automation. Verified in both directions.
jemalloc was installed but never actually active
Installing libjemalloc2 does nothing without preloading it. glibc's allocator fragments under Ruby's allocation pattern and does not return freed memory to the OS, so a long-running Sidekiq worker ratchets its memory upward across jobs even with no leak. The one-line LD_PRELOAD is what makes the package do anything at all.
kamal deploy does not boot accessories — only kamal setup does
Stand a new environment up with deploy alone and its accessories never start, so any role that depends on one exits immediately and the restart policy retries it forever. The deploy can still report green: a liveness check that only proves the process is serving will answer perfectly well while a second role is crashlooping beside it. Read every role's logs after bringing up a new environment, not just the one the proxy is watching.
Kamal cannot go in the Gemfile on Rails 6.1
Kamal 2.x requires activesupport >= 7.0, so on a Rails 6.1 application it cannot be a Gemfile dependency at all. Bundler resolves a single dependency graph across every group, so the conflict fires even in group :development with require: false — the development group is not an escape hatch. Kamal has to be installed globally, outside the bundle. Most published Docker and Kamal guidance targets 7.1+ too, and quietly assumes features 6.1 does not have, SECRET_KEY_BASE_DUMMY among them.
On the timeline
The written estimates ran from a 24-hour optimistic floor up to 55–80 hours depending on the labour assumption. The work came in at roughly 30 hours across one week, with production and staging fully cut over — just above the optimistic floor rather than anywhere near the pessimistic one. That gap is AI-assisted execution against a plan that had already done the thinking, not raw speed: the design work, the measurements, the capacity analysis and the failure-mode reasoning were done up front and in writing, so the implementation was fast because the decisions were already made and recorded. The 733-line decision record that came out of it is why the next person to touch this infrastructure can find out why every value is what it is.
One item remains open by design: the memory guards are set against modelled figures, pending long-run observed usage.