How much VPS a Rails app needs
The question underneath "ruby on rails vps" is a sizing question, and most of the answers to it are recollections of an application somebody ran years ago on a Ruby that no longer exists. A useful answer needs a number with its conditions attached, so this page is one Rails 8.1 application put under a hard memory cap and squeezed until it died, with the cap written down at every step.
What it is not is a deployment walkthrough. Getting the code onto the box is a Kamal problem and the image it ships is a Dockerfile problem. This page only answers how much machine to point those at.
The rig, and what it cannot tell you
A VPS is a virtual machine with a fixed memory allowance and a fixed vCPU allowance, and the thing
that enforces both on Linux is the same mechanism a container uses. So the measurements here run a
real Rails application inside a container with -m and --cpus set, which gives a hard, inspectable
ceiling that I can move a megabyte at a time. A rented 1 GB box does not let you do that, and it
certainly does not let you find the exact point where the kernel starts killing things.
The application is a rails new with one scaffold and 50 seeded rows, built with the Dockerfile
Rails generated, unedited. The page under load is /posts, a scaffold index rendering those 50 rows
through the default layout, 22,047 bytes of HTML. Everything below was produced by this command, with
only the -m, --cpus and -e values changing between runs:
docker run -d --name vps1 -m 1g --memory-swap 1g --cpus=1 -p 8099:80 \
--mount type=tmpfs,destination=/rails/tmp,tmpfs-mode=1777,tmpfs-size=67108864 \
--mount type=tmpfs,destination=/rails/storage,tmpfs-mode=1777,tmpfs-size=134217728 \
--mount type=tmpfs,destination=/rails/log,tmpfs-mode=1777,tmpfs-size=16777216 \
-e RAILS_MASTER_KEY=$MK vpsdemo
The three tmpfs mounts are there because the Docker VM's disk was full, and they are the one place
this rig diverges from a VPS in a way that matters: they put the SQLite file and the bootsnap cache
in memory. That is the 19,165,184 bytes reported as shmem in every memory.stat below, and it is
why the cgroup total runs about 19 MB above what the same app would use with those directories on
disk. The per-process resident numbers are unaffected, which is why the argument below is built on
those.
Versions, because half the value of a memory number is knowing what produced it: Ruby 4.0.5 on
aarch64-linux, Rails 8.1.4, Puma 8.0.2, Thruster 0.1.26, propshaft 1.3.2, sqlite3 2.9.6, on Debian
13 trixie from ruby:4.0.5-slim. The host is an Apple M2 Max with 12 cores, and the Docker VM is
6.10.14-linuxkit with 12 CPUs and 7.653 GiB. The image is 492 MB.
What this rig cannot tell you is what a request costs on a 1 vCPU x86 VPS from any particular vendor. An M2 Max core throttled to one CPU's worth of quota is faster than most of what you can rent, and the arm64 build is not the x86 build. Take the memory numbers as transferable, since they are mostly Ruby object heap and mapped gem code, and take the throughput numbers as a ratio between configurations rather than a forecast of your bill.
One Rails process is 180 MB before it does anything useful
Booted in production, warmed with 200 requests, single mode, nothing else on the box:
=== PROC ===
1 13308 /usr/local/bundle/ruby/4.0.0/gems/thrust
15 180512 puma 8.0.2 (tcp://0.0.0.0:3000) [rails]
=== CGROUP ===
194084864
anon 166559744
file 19165184
shmem 19165184
Two processes, because the generated Dockerfile's CMD is ["./bin/thrust", "./bin/rails",
"server"]. Thruster is the Go proxy that does gzip and X-Sendfile in front of Puma, and it costs
13 MB, which is a rounding error you can stop thinking about.
Puma is 180,512 kB. That is one process, three threads, zero workers. On the same rig, read after
boot with nothing but /up requested, it was 158,376 kB, so roughly 22 MB of that total is heap the
first couple of hundred requests grew and did not give back. It keeps climbing under sustained load:
183,100 kB after 5000 requests at concurrency 8. Raising RAILS_MAX_THREADS to 16 and running the
same 5000 requests at concurrency 16, on a container given two vCPUs instead of one, took it to
204,616 kB. Ruby returns memory to the operating system reluctantly, so the number to size against is
the one after a few thousand requests, not the one after boot.
The practical reading of 180 MB is that a 512 MB VPS runs this application with room for something else, and a 1 GB VPS runs it with room for a database. A 256 MB VPS runs it with nothing to spare and a kill waiting, which the section on the floor below measures to the megabyte.
WEB_CONCURRENCY=1 costs 41 MB and Puma tells you so
Setting WEB_CONCURRENCY=1 looks like the conservative choice on a small box, and on this machine it
is the worst value in the range. One sweep, same rig, same warm-up, 500 requests at concurrency 4:
WEB_CONCURRENCY |
memory.current |
anon |
req/s |
|---|---|---|---|
| unset (single mode) | 194,084,864 | 166,559,744 | 364.82 |
| 1 | 238,620,672 | 207,888,384 | 360.10 |
| 2 | 307,236,864 | 276,905,984 | 310.92 |
| 3 | 372,580,352 | 338,792,448 | 257.96 |
| 4 | 422,752,256 | 387,719,168 | 219.47 |
41 MB of anonymous memory for a throughput difference inside the run-to-run noise. The reason is in
the boot banner, grepped out of docker logs, which says it out loud:
[16] Puma starting in cluster mode...
[16] * Min threads: 3
[16] * Workers: 1
[16] * Listening on http://0.0.0.0:3000
[16] Use Ctrl-C to stop
[16] ! WARNING: Detected running cluster mode with 1 worker.
[16] ! Running Puma in cluster mode with a single worker is often a misconfiguration.
[16] ! Consider running Puma in single-mode (workers = 0) in order to reduce memory overhead.
[16] ! Set the `silence_single_worker_warning` option to silence this warning message.
At two the banner gains a line the one-worker run never printed:
[16] Puma starting in cluster mode...
[16] * Min threads: 3
[16] * Workers: 2
[16] * Preloading application
[16] * Listening on http://0.0.0.0:3000
That is Puma::Configuration#set_conditional_default_options, which reads, in full:
@_options.default_options[:preload_app] = !@_options[:prune_bundler] &&
(@_options[:workers] > 1) && Puma.forkable?
workers > 1, not >= 1. At one worker the master forks a child that loads the application again
for itself, so you pay for the boot twice and share nothing. At two the master loads it once and
both children inherit those pages copy-on-write. The process table shows the difference in the PSS
column rather than the RSS one: at one worker the two Ruby processes reported 101,982 kB and
113,027 kB proportional set size, at two workers three Ruby processes reported 88,241, 94,166 and
93,088.
Six assertions that pin the worker rules
Puma's resolution of WEB_CONCURRENCY is worth a test rather than a memory, because it is the
setting most likely to be changed by somebody editing a compose file. Six examples against the
generated config/puma.rb, loading it through Puma::Configuration with the environment a deploy
would supply:
def options(env)
conf = Puma::Configuration.new({ config_files: [ CONFIG ] }, {}, env)
conf.load
conf.clamp
conf.final_options
end
def test_web_concurrency_one_is_cluster_mode_without_preload
opts = options({ "WEB_CONCURRENCY" => "1" })
assert_equal 1, opts[:workers]
refute opts[:preload_app], "one worker must not preload, so the app is loaded twice"
end
6 runs, 10 assertions, 0 failures, 0 errors, 0 skips
One of those six failed on the first attempt, and the failure is the useful part.
test_rails_max_threads_sets_both_ends_of_the_pool passed {"RAILS_MAX_THREADS" => "16"} in the
same hash as everything else and got Expected: 16, Actual: 3. WEB_CONCURRENCY is read by Puma,
from the env hash you hand Puma::Configuration. RAILS_MAX_THREADS is read by the generated
config/puma.rb, through ENV.fetch("RAILS_MAX_THREADS", 3), which means the real process
environment and nothing else. Two knobs in the same file, two different readers.
Workers three and four are 55 MB each and made this slower
From WEB_CONCURRENCY=2 to 4 the anonymous total went from 276,905,984 to 387,719,168, which is
55.4 MB per added worker against roughly 157 MB of resident set each. That gap is copy-on-write
doing its job, and it is the number to budget with: the first worker costs a boot, the rest cost
about a third of one.
Throughput went down the whole way, because the container had one vCPU and Puma was already using it.
Repeating the ends of that table with ab -n 1000 -c 8, three runs each: single mode gave 349.84,
322.01 and 326.97 requests per second, and WEB_CONCURRENCY=4 gave 173.35, 226.77 and 250.22. The
direction survives the noise, and the noise is real: three runs of the identical single-mode
configuration at -n 500 -c 4 gave 283.55, 332.53 and 364.82, so treat anything inside 15 percent
as the same number.
What the right worker count is on a box with more than one vCPU is a different question and this
page is the wrong one for it. That measurement exists on this site already, taken on the same laptop
with no CPU cap at all, in the performance comparison, where
WEB_CONCURRENCY=4 took one page from 27.75 to 70.61 requests per second. The point here is only
that workers are how a small box runs out of memory, and that they buy nothing at all until there is
a second core to put them on.
One full ApacheBench run, single mode, 1 GB and 1 vCPU, pasted whole so the shape of the latency distribution is visible:
Concurrency Level: 4
Time taken for tests: 1.504 seconds
Complete requests: 500
Failed requests: 0
Total transferred: 11515052 bytes
HTML transferred: 11023500 bytes
Requests per second: 332.53 [#/sec] (mean)
Time per request: 12.029 [ms] (mean)
Time per request: 3.007 [ms] (mean, across all concurrent requests)
Transfer rate: 7478.60 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.1 0 2
Processing: 4 12 4.5 10 34
Waiting: 3 12 4.5 10 34
Total: 4 12 4.5 10 34
Percentage of the requests served within a certain time (ms)
50% 10
90% 21
99% 26
100% 34 (longest request)
The floor is between 160 and 192 MB, and the box does not warn you
Walking the cap down, boot first and then ab -n 400 -c 8:
-m |
boot | under load |
|---|---|---|
| 512m | 200 in 4s | 400 complete, 0 failed |
| 256m | 200 in 3s | 400 complete, 0 failed |
| 192m | 200 in 4s | 400 complete, 0 failed |
| 160m | 200 in 3s | killed, exit 137 |
| 128m | never answered 200 in 45s | n/a |
192 MB is fine. 160 MB boots, serves a curl, and looks like a working deploy right up to the moment
real traffic arrives:
Concurrency Level: 8
Time taken for tests: 0.402 seconds
Complete requests: 400
Failed requests: 359
(Connect: 0, Receive: 0, Length: 359, Exceptions: 0)
Non-2xx responses: 8
Total transferred: 945606 bytes
945,606 bytes over 400 requests is 2,364 bytes each against a 22,047-byte page, so most of those
bodies were never finished. ApacheBench counts a short body as a failure by length rather than by
status, which is why Failed requests is 359 and Non-2xx responses is 8; that counting is
explained at more length in the performance comparison.
docker inspect is unambiguous about what happened:
exited exit=137 OOMKilled=true
The eight non-2xx are the interesting ones. Thruster is a separate process and it outlived Ruby by a fraction of a second, so it answered the requests still in flight itself:
{"time":"2026-09-27T08:36:18.534284222Z","level":"INFO","msg":"Unable to proxy request","request_id":"023c18bc-af7d-460f-b9e5-de9d8536195d","path":"/posts","error":"dial tcp [::1]:3000: connect: connection refused"}
{"time":"2026-09-27T08:36:18.534465472Z","level":"INFO","msg":"Request","request_id":"023c18bc-af7d-460f-b9e5-de9d8536195d","path":"/posts","status":502,"dur":89,"method":"GET","req_content_length":0,"req_content_type":"","resp_content_length":0,"resp_content_type":"","remote_addr":"172.17.0.1:58538","user_agent":"ApacheBench/2.3","cache":"miss","query":"","proto":"HTTP/1.0"}
A 502 with connection refused behind it, on a box you sized by watching it idle, is what running
out of memory looks like from the outside. Nothing in docker logs for that container matches "oom"
or "out of memory" case-insensitively. The last line it wrote is Thruster's Server stopped, because
the process that died was not the one keeping the log.
Swap converts the kill into a 50 percent slowdown
Same 160 MB container, same 400 requests, with 160 MB of swap added by changing --memory-swap 160m
to --memory-swap 320m:
Complete requests: 400
Failed requests: 0
Requests per second: 184.13 [#/sec] (mean)
Time per request: 43.448 [ms] (mean)
Nothing died. memory.swap.current read 111,214,592 bytes afterwards, so 106 MB of a 160 MB working
set spent the run on disk, and pgmajfault counted 344. The cost is in the throughput line: 184.13
against 364.82 for the same application with enough memory, and a mean request time of 43 ms against
- That is the honest trade and it is worth taking on a small box, because a slow page is a page and a killed worker is a 502. It is not a substitute for buying the next size up, and the swap it used here was a file on a local SSD, which is the friendly case.
I did not create a swapfile on a Linux host for this page. The fallocate, mkswap and swapon
sequence everybody writes down is not printed above, because I did not run it; what I ran is the
cgroup equivalent, which is the same mechanism the kernel uses to decide whether to reclaim or to
kill. If your provider's image ships with no swap, and most of them do, that sequence is the thing to
go and read in man swapon rather than to copy from here.
jemalloc did not pay, three times
The Dockerfile that rails new writes installs libjemalloc2 and preloads it:
# Set production environment variables and enable jemalloc for reduced memory usage and latency.
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
On a box where memory is the binding constraint, "reduced memory usage" in that comment is the highest-leverage claim in the whole image. So I measured it, expecting to confirm it, and did not.
Three runs of each configuration, 16 threads, 5000 requests at concurrency 16, resident set of the Puma process read afterwards:
| allocator | run 1 | run 2 | run 3 | mean |
|---|---|---|---|---|
jemalloc (LD_PRELOAD as generated) |
204,616 kB | 201,940 kB | 204,952 kB | 203.8 MB |
glibc (LD_PRELOAD=) |
196,100 kB | 197,024 kB | 195,608 kB | 196.2 MB |
glibc with MALLOC_ARENA_MAX=2 |
177,352 kB | 175,148 kB | 173,860 kB | 175.5 MB |
jemalloc was consistently about 4 percent worse than doing nothing, and capping glibc's arenas at
two was 14 percent better than jemalloc. Setting MALLOC_ARENA_MAX=2 alongside jemalloc changed
nothing, 204,828 kB, which is expected since the variable is glibc's and jemalloc never reads it.
At the default three threads the difference between jemalloc and glibc was inside the noise
altogether: 183,280 kB against 181,040 kB after 5000 requests.
Read that narrowly. It is one application, one workload, one architecture, one Ruby. The glibc arena
problem that jemalloc is famous for fixing scales with thread count and with allocation churn, and a
scaffold index rendering 50 rows sixteen at a time is a weak version of both. What the result does
justify is refusing to treat the LD_PRELOAD line as free money: it is a claim about your
application that you can check in twenty minutes with two containers and ab, and on the application
I checked it was wrong. Heroku, for what it is worth, reaches for the other lever: its Ruby buildpack
sets MALLOC_ARENA_MAX=2 in /app/.profile.d/ruby.sh, which is
visible on a running dyno and in nothing you configured.
auto reads a different number inside a container than on the box
WEB_CONCURRENCY=auto is documented in the generated config/puma.rb and it resolves through
Puma::Configuration#parse_workers, which calls Concurrent.available_processor_count. That is not
the machine's core count. It reads the cgroup CPU quota:
--cpus=1: cpu.max=100000 100000 processor_count=12 available=1.0
--cpus=2: cpu.max=200000 100000 processor_count=12 available=2.0
--cpus=4: cpu.max=400000 100000 processor_count=12 available=4.0
no limit: cpu.max=max 100000 available=12.0
processor_count stayed at 12 in every one of those, which is the host's core count. With no --cpus
at all, cpu.max read max 100000 and available_processor_count fell back to the same 12, so the
quota is the only thing that moves it. The case where auto surprises you is therefore a container
with a CPU limit set somewhere you are not looking, an orchestrator or a compose file somebody added
a year ago: the number of Puma workers your application boots with is then decided by a line nobody
associates with Puma, and on a small box that line is choosing how much memory you use.
The asset build is not what sizes the box
The advice you will find for small boxes is to add swap before the asset build, because the asset build is what runs them out of memory. On a default Rails 8 app that is no longer true. Running the build stage's own command under a cap:
-m |
bin/rails assets:precompile |
|---|---|
| 512m | exit 0 |
| 256m | exit 0 |
| 160m | exit 0 |
| 96m | exit 137 |
Propshaft copies files and writes a manifest. There is no bundler, no minifier and no Node process,
because the default pipeline is propshaft plus importmap. The version of that advice that is still
correct applies to an app with --javascript=esbuild or a package.json, where a Node process with
its own heap runs inside the build. I did not test that here and will not claim a number for it.
The thing that actually sizes the box is the steady state: 180 MB for the first process, about 55 MB for every worker after the second, and whatever the database wants if it lives on the same machine.
What I would buy
For one Rails 8 application with the database on the same box, 2 GB. That is not the smallest thing that works, and the measurements above are the argument that 1 GB works: 185 MiB for the app in single mode leaves most of a gigabyte for PostgreSQL and the page cache it wants. The reason to buy the second gigabyte anyway is that the failure mode measured above is a kill with no warning, and that the margin disappears the first time one request allocates a few hundred megabytes. A CSV export measured on this site took a Puma worker from 108 MB to a peak of 663 MB inside one request, which on a 1 GB box running two workers is the whole machine.
Below 1 GB, run single mode, put swap on it, and accept that you have bought a box that will be slow rather than dead under pressure. Above 2 GB, the memory stops being the constraint and the vCPU count starts being it, and the worker table above inverts.
What would change my mind: a measurement, on the application in question, showing the resident set settling somewhere far from 180 MB. Application memory is dominated by what you loaded, and a Gemfile with twice the gems is a different number entirely. The method in this page transfers even where the numbers do not, and it is four commands.
What this page does not cover
The database's share of the box, which for most people is the other half of the sizing question and
which I did not measure: nothing here runs PostgreSQL, because the test app is on SQLite, and a
PostgreSQL number measured on macOS would not have been worth printing. Nothing about which provider
to rent from, their disk or network performance, or their prices. Nothing about running Rails on the
VPS without a container, which is a real choice and a different memory profile, because the rig here
is containers all the way down. Nothing about the nginx or Caddy in front, since the generated
image already ships Thruster and I left it there. And nothing about getting the code onto the machine,
which is Kamal's job and has its own page.
The numbers came from one sweep, timestamped in the Thruster log line above. They reproduce with
rails new, the generated Dockerfile, the docker run line at the top and ApacheBench, which is
the point of printing the rig rather than the conclusion.
Comments
No comments yet. Be the first.