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

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

  1. 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.

#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.