LaunchKit
← All posts
· 14 min read · by The LaunchKit team · 0 views

Ruby on Rails hosting

Picking a host for a Rails app is a question about the disk, and almost every page that answers it talks about the CPU. The generated app writes to four SQLite files in one directory, the cheapest tier of every platform gives you a filesystem that is thrown away on the next deploy, and nothing in that combination raises an error. It answers 200, the health check is green, and the rows are gone.

Everything below was run on an M2 Max, 12 cores, macOS 26.5.1 arm64, Docker Desktop's engine 27.4.0, against an image built from the Dockerfile that rails new writes for Rails 8.1.3.1 on Ruby 4.0.5. CPU limits are Docker's --cpus, which on this laptop applies a CFS quota inside the Linux VM, so the absolute throughput numbers belong to this machine and the ratios between them are the part that transfers. Prices were read on 2026-09-27 and are dated in the section that carries them.

What a Rails 8.1 app needs from a host

One Unix process and one writable directory. That is the whole list for a generated app, and it is worth checking rather than assuming, because it is what decides which tier of which platform is even eligible.

Run in production, the app asserts it about itself:

def test_the_bundle_contains_no_redis_client
  names = Gem.loaded_specs.keys
  assert_empty names.grep(/redis/), "expected no redis gem, got #{names.grep(/redis/).inspect}"
end

def test_all_four_production_databases_are_files_under_storage
  configs = ActiveRecord::Base.configurations.configs_for(env_name: "production")
  assert_equal %w[primary cache queue cable].sort, configs.map(&:name).sort

  configs.each do |config|
    assert_match %r{\Astorage/}, config.database,
      "#{config.name} database is #{config.database.inspect}"
    assert_equal "sqlite3", config.adapter
  end
end

Rails.cache is a SolidCache::Store, the queue adapter is ActiveJob::QueueAdapters::SolidQueueAdapter, and config_for(:cable) returns solid_cable. There is no second service to rent, which is the argument for running without Redis and also the reason the cheap end of the market is now worth looking at: you are shopping for one box, not for a box and two managed add-ons.

What you are shopping for it to have is storage that survives the next deploy.

The disk is the whole decision

Every platform that deploys a container replaces the container. Here is what that does to the generated app, run with no volume at all. One row is written, the container is destroyed, and the same image is started again:

--- first container
Post.count=1
total 324
-rw-r--r-- 1 rails rails  28672 Sep 27 14:59 production.sqlite3
-rw-r--r-- 1 rails rails  36864 Sep 27 14:59 production_cable.sqlite3
-rw-r--r-- 1 rails rails  40960 Sep 27 14:59 production_cache.sqlite3
-rw-r--r-- 1 rails rails 225280 Sep 27 14:59 production_queue.sqlite3
--- replacing the container with the same image
Post.count=0
total 324
-rw-r--r-- 1 rails rails  28672 Sep 27 14:59 production.sqlite3
-rw-r--r-- 1 rails rails  36864 Sep 27 14:59 production_cable.sqlite3
-rw-r--r-- 1 rails rails  40960 Sep 27 14:59 production_cache.sqlite3
-rw-r--r-- 1 rails rails 225280 Sep 27 14:59 production_queue.sqlite3

Read the two listings rather than the two counts. They are identical: four files, same names, same byte sizes, created inside the same minute. bin/docker-entrypoint ran db:prepare, db:prepare found no database, built all four from the schema, and the app came up healthy on an empty one. The only evidence that anything was lost is the count, and the only thing that reads the count is a customer.

The same script with one flag added, -v ho-data:/rails/storage, prints Post.count=1 before and Post.count=1 after.

The platforms document this, in a sentence nobody reads before they need it. From Render's free tier page: "any changes to your web service's filesystem (uploaded images, local SQLite databases, etc.) are lost every time the service redeploys, restarts, or spins down".

So the first question to ask a host is not what it costs. It is whether the thing you get has a persistent disk attached, or a managed Postgres beside it, and every hosting decision below follows from the answer. A platform whose free tier is a stateless web service is offering you somewhere to run a demo.

The cost of taking the volume route: a volume is attached to one machine, so the app that uses it runs in one copy. You have traded horizontal scaling for a bill of a few cents, and the day you want two instances you are moving to Postgres anyway. That trade is right for most first deployments and wrong for any app that already has traffic to spread.

Scale to zero also scales your cron to zero

Free and near-free tiers are sold on sleep. Render's free web services spin down after 15 minutes without inbound traffic, and Heroku's devcenter marks Eco as the only dyno type with a tick in the Sleeps column, after "no web traffic in a 30-minute period". Both documents describe what happens to web requests. Neither mentions what happens to the work that was not started by a web request.

The run below answers it. The app has a recurring task declared in config/recurring.yml:

production:
  stamp_every_minute:
    command: "Post.create!(title: 'recurring', body: Time.now.utc.iso8601)"
    schedule: every minute

It was booted with SOLID_QUEUE_IN_PUMA=1 and a volume, left awake for 150 seconds, given a job scheduled 40 seconds out, stopped for three minutes, and started again:

awake at 15:16:05
--- after 150 s awake, 15:18:35
["2026-09-27T15:17:00Z", "2026-09-27T15:18:00Z"]
enqueued, scheduled_at=2026-09-27T15:19:16Z
--- stopped at 15:18:37 (scale to zero)
--- starting again at 15:21:37
awake again at 15:21:38
recurring rows: 2
["2026-09-27T15:17:00Z", "2026-09-27T15:18:00Z"]
["scheduled_for=+40s from 2026-09-27T15:18:36Z ran_at=2026-09-27T15:21:39Z"]

Two rows before the sleep, two rows after. The minutes 15:19, 15:20 and 15:21 produced nothing and were not replayed when the scheduler came back. That is not a bug: a SolidQueue::RecurringTask computes the next occurrence forward from now, which is what this asserts and why nothing catches up.

def test_a_recurring_task_only_ever_looks_forward
  task = scheduled_tasks.find { |candidate| candidate.key == "stamp_every_minute" }
  assert task.next_time > Time.current, "next_time #{task.next_time} is not in the future"
  assert task.next_time < 61.seconds.from_now, "next_time #{task.next_time} is more than a minute away"
end

The delayed job is the more interesting half. It was due at 15:19:16 and ran at 15:21:39: two minutes and twenty-three seconds late, one second after the container finished booting. On a host that sleeps, "run this in an hour" means "run this the first time somebody visits after an hour", and for a nightly billing job on a low-traffic app those are different days.

If the app sends a receipt, retries a webhook, expires a trial or emails a digest, a sleeping web service is not a cheap version of hosting. It is a different product.

The forks that died behind a green health check

The first attempt at the run above produced no rows at all, and nothing about the app looked wrong. Puma served, /up answered 200, and SolidQueue::Process.pluck(:kind) returned ["Supervisor(fork)"] and nothing else. The log said why, once per second, forever:

SolidQueue-1.7.0 Error registering Dispatcher (2.9ms)  pid: 40, name: "dispatcher-e2075b5558a5bc61b45c", error: "ArgumentError wrong number of arguments (given 2, expected 1)"
SolidQueue-1.7.0 Error registering Worker (3.5ms)  pid: 44, name: "worker-60f7abd6814eb89b351d", error: "ArgumentError wrong number of arguments (given 2, expected 1)"
SolidQueue-1.7.0 Error registering Scheduler (2.8ms)  pid: 48, name: "scheduler-d7b60a8883baadbc27ee", error: "ArgumentError wrong number of arguments (given 2, expected 1)"
SolidQueue-1.7.0 Replaced terminated Dispatcher (8.5ms)  pid: 40, status: 1

The cause is not Solid Queue and not the host. Line 25 of activesupport-8.1.3.1/lib/active_support/json/decoding.rb reads data = ::JSON.parse(json, options), and line 296 of json-3.0.2/lib/json/common.rb reads def parse(source, on_load: nil, object_class: nil, array_class: nil, **options). The second argument stopped being positional somewhere in json 3. Every read of a serialized JSON column raises, and Solid Queue registers each fork by writing one.

One line reproduces it, and Ruby 4.0.5 prints the whole diagnosis:

$ ruby -rjson -e 'puts JSON::VERSION; JSON.parse("{}", {})'
/Users/mehdifarsi/.rvm/gems/ruby-4.0.5/gems/json-3.0.2/lib/json/common.rb:296:in 'JSON.parse': wrong number of arguments (given 2, expected 1) (ArgumentError)

    caller: -e:1
    callee: /Users/mehdifarsi/.rvm/gems/ruby-4.0.5/gems/json-3.0.2/lib/json/common.rb:296
    |   def parse(source, on_load: nil, object_class: nil, array_class: nil, **options)
            ^^^^^
    from -e:1:in '<main>'
3.0.2

Whether you meet it depends on Bundler. Inside the same container, ruby -e 'puts JSON::VERSION' printed 2.18.0, the default gem that ships with Ruby 4.0.5, and the same expression through bundle exec printed 3.0.2, because that is what the lockfile had resolved.

Pinning gem "json", "~> 2.21" in the Gemfile, which resolved to 2.21.2, ended it and is what every measurement in the section above was run on. The reason it belongs in a hosting article rather than a Solid Queue one: the web app was healthy for the entire time it was running no jobs. A load balancer probing /up sees a process that booted, which is all that endpoint promises, and no hosting plan on any provider notices that the queue in the same container has been dying and restarting since the deploy.

A cold start is Rails booting, and your CPU quota sets its price

Time from docker run to the first HTTP 200, polled in a tight loop. Two runs at each quota, plus three with no limit at all:

CPU quota First 200
none 1.927 s, 1.642 s, 1.655 s
--cpus=4 1.714 s, 2.396 s
--cpus=1 1.662 s, 1.672 s
--cpus=0.5 3.244 s, 3.332 s
--cpus=0.25 6.976 s, 6.369 s

Four cores are not faster than one, which fits what boot is: one process requiring files. Half a core doubles it and a quarter of a core quadruples it, which is the number to hold onto, because the plans that sleep are the plans that sell you a fraction of a core.

The entrypoint is not the problem. bin/docker-entrypoint runs db:prepare on every start, and with the four databases already present on a volume the same boot took 1.522 s and 1.524 s at --cpus=1, against 1.662 s and 1.672 s from an empty directory. Creating four SQLite databases from the schema costs about 140 ms. Rails booting costs the rest.

What happens to a request that arrives during those seconds is decided by the proxy, and inside the image the proxy is Thruster, which binds port 80 immediately and forwards to a Puma that is not listening yet:

{"level":"INFO","msg":"Server started","http":":80"}
{"level":"INFO","msg":"Unable to proxy request","path":"/up","error":"dial tcp [::1]:3000: connect: connection refused"}
{"level":"INFO","msg":"Request","path":"/up","status":502,"dur":0,"method":"GET","cache":"miss"}

One run logged 157 of those before the first 200. A container that is waking up refuses traffic, it does not hold it, so whether your visitor waits or sees an error page is a property of whatever sits in front of the container: the platform's edge, or kamal-proxy, neither of which is Thruster.

What a quarter of a vCPU buys

ab -n 600 -c 4 against a scaffolded index page rendering 20 rows, after a 300-request warmup, same image, same machine, only the quota changing:

CPU quota Requests/s Median 99th
--cpus=2 535.85 7 ms 13 ms
--cpus=1 476.83 7 ms 26 ms
--cpus=0.5 187.46 9 ms 77 ms
--cpus=0.25 67.66 84 ms 113 ms

Zero failed requests at every step, so the small plan does not break. It gets slow in the way that is hardest to notice from a dashboard: the median request at a quarter of a core is 84 ms against 7 ms at one core, while the throughput number still reads as three digits.

The prices I could read, and the ones that are not in the page

Four pricing pages were fetched for this article on 2026-09-27. Hetzner's cost-optimized page, DigitalOcean's Droplet pricing doc and Render's pricing page compute their tables in the browser, so the HTML contains the plan names and none of the numbers. Hetzner's page lists CX23 as 2 vCPU, 4 GB, 40 GB NVMe and 20 TB of traffic with the price column empty. No figure from any of the three appears below, because printing one would mean recalling it.

Heroku publishes its table as HTML, in dyno types:

Plan Memory CPU share $/Month Sleeps
Eco 0.5 GB 1x $5 flat yes
Basic 0.5 GB 1x $7 no
Standard-1X 0.5 GB 1x $25 no

The $5 flat is not per dyno: it buys "a pool of 1000 hours shared by all Eco dynos in an account". On the database side, Heroku Postgres plans gives Essential-0 as 4,000 tables, 1 GB of storage and 20 connections, and carries no price at all, which is worth knowing before you count on that document.

Fly.io publishes the constants instead of the table, at docs.fly.io/about/pricing: $0.00000075 per shared vCPU-second, 0.25 GB of RAM included per shared vCPU, $0.00000193 per additional GB-second, and 2,592,000 seconds in a billing month. That arithmetic is checkable, so here it is run:

shared-cpu-1x  1 vCPU  0.25 GB  $0.00000075/s  $0.0027/h  $1.94/30 days
shared-cpu-1x  1 vCPU  0.50 GB  $0.00000123/s  $0.0044/h  $3.19/30 days
shared-cpu-1x  1 vCPU  1.00 GB  $0.00000220/s  $0.0079/h  $5.70/30 days

Same page: volumes are "$0.15/GB per month of provisioned capacity", and "Each 1GB of rootfs for a Machine stopped for 30 days is $0.15". A Rails app on a stopped machine with a 1 GB volume is therefore about twenty cents a month, and the 256 MB preset is the interesting row because a Rails process measured under a hard cap sat at 180,512 kB resident and died somewhere between 160 and 192 MB. The cheapest preset is the smallest one that works, with no margin at all.

What I would rent

One small Linux box, with the database on it, deployed with Kamal. Not because it is the cheapest line on a price list, but because it is the only shape in which the three failures above stop being yours to work around: the disk persists because it is a disk, the process does not sleep so the recurring task fires, and the cold start happens on deploys rather than on visits.

The cost of that choice is real and it is not money. You are the one who patches the kernel, watches the disk fill, and restores the database when you drop the wrong table, and none of those has a support ticket attached. What a managed platform sells is somebody else doing them. How much more it charges for that is a number I will not put here, because three of the four price pages I opened contain no prices, and the one that does prices dynos rather than boxes. If you know you will not do the patching, buy the platform, and buy the tier that does not sleep.

For an app with no users yet, a machine that scales to zero with a volume attached is the honest cheap answer, at the $1.94 and $0.15 computed above from Fly's own constants, as long as you have read that section and know that your cron is asleep with the machine. What I would not do is put anything I intend to keep on a free tier whose database expires, and Render's free Postgres expires 30 days after creation, which the Render deploy page covers in more detail.

What would change my mind: an app that needs two machines. The volume is what makes one box cheap, and the moment you need a second instance you are running a managed Postgres, at which point the platforms' prices and the VPS's stop being comparable in the way this page compares them.

What this page does not cover

No provider was deployed to. Every number here was produced by containers on one laptop, and what a given platform's edge does with a request while your machine is waking, how long its own scheduler takes before the container is even started, and what its disks do under load are all things this rig cannot see. The two pages that do go onto a provider are Heroku and Render, and they contradict nothing here because they measure different things.

Also absent: bandwidth pricing, which is where a platform bill can double without the compute line changing; managed Postgres against self-hosted Postgres, which this page treats as one decision and is really several; Active Storage, which on a stateless web service needs S3 for exactly the reason the disk section gives; and anything about PaaS options other than the two named, including whether any of them will still exist when you read this, which is the one risk a benchmark cannot measure.

The image, the app and the test file are a rails new away: the app is the generated one with a scaffolded Post, a json pin, and a config/recurring.yml task, and the 18-example Minitest file runs against it in 13 milliseconds.

#rails #deployment

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.