$osscostcalc.comSelf-hosting cost intelligence

Right-Sizing CPU and RAM: The 80/20 Rule for OSS Apps

9 min read·1,720 words·Operations

TL;DR

Most self-hosted OSS apps run on boxes two sizes too big because operators panic at the first 80% CPU spike. The four metrics that actually matter are load average normalized by vCPU, RSS as a fraction of RAM, p99 request latency, and swap-in rate per second. This guide shows how to read them on Hetzner CX, DigitalOcean Basic, and Linode Nanode/Shared plans, and when each one says "buy a bigger box" versus "you're fine."

Most self-hosted OSS deployments are running on a VM at least one tier larger than they need. The reason is almost always the same: someone saw a CPU graph touch 90% during a backup, panicked, and bumped the box from a Hetzner CX22 (€4.51/mo) to a CX32 (€7.05/mo) or worse. Multiply that across a fleet of Gitea, Vaultwarden, Plausible, Mastodon, and Postgres instances and you are paying double for headroom you never use.

The fix is not a dashboard. It is knowing which four metrics actually predict saturation and which ones are theater.

The four metrics that actually matter

Forget the 30-pane Grafana wall. For a single-VM OSS app, you need exactly four signals to make a sizing decision:

Metric What it tells you Tool
Load avg / vCPU count Is CPU truly the bottleneck? uptime, top
RSS / total RAM Are you about to OOM? ps, smem, cgroup memory.current
p99 latency Does the user actually feel pain? app metrics, mtr, blackbox probe
Swap-in rate (pages/sec) Are you silently thrashing? vmstat 1, sar -W

Everything else, including the famous "CPU utilization %" gauge in your provider's console, is a lagging or misleading indicator. CPU percent in particular lies on burstable instances, which is most of what Hetzner, DigitalOcean Basic Droplets, and Linode Shared CPU plans actually sell.

Load average, normalized

A 1-minute load average of 4.0 means absolutely nothing without context. On a 4-vCPU CX32 that is 100% saturation. On a 1-vCPU CX22 that is a fire. The rule I use:

  • Load / vCPU < 0.7 for any sustained 5-minute window: you have headroom. Do nothing.
  • 0.7 to 1.0: healthy busy. Watch p99 latency; if it is flat, do nothing.
  • 1.0 to 2.0 sustained: you are queuing. Either tune the app (worker count, connection pool) or go up one tier.
  • > 2.0 sustained: you are already in user-visible pain. Resize now.

The "sustained" word is doing real work here. A 30-second spike during apt upgrade or a Borg backup is not a sizing problem. A 15-minute plateau at load 3 on a 1-vCPU box is.

RSS, not "memory used"

The Linux free command's "used" column is famously confusing because of page cache. What you actually care about for sizing is resident set size of your app processes plus a safety margin for the kernel and page cache.

Rule of thumb for a single-tenant OSS box:

  • Sum the RSS of your top 5 processes (ps aux --sort=-rss | head).
  • If that sum exceeds 70% of total RAM, you are one traffic spike or one Postgres autovacuum away from swap.
  • If it exceeds 85%, resize before the next deploy.

A Vaultwarden + Caddy + SQLite stack will sit at 80-150 MB RSS forever and runs happily on a Nanode 1GB ($5/mo) or CX22. Mastodon with Sidekiq, Puma, and a local Postgres will eat 2-3 GB minimum and belongs on at least a CX22 (4GB) or a DigitalOcean Basic 2GB ($12/mo) droplet — and honestly should have Postgres on a separate box once you have more than a handful of users.

p99 latency is the only user-facing truth

CPU and RAM are inputs. p99 latency is the output your users actually feel. If your p99 page load is under 300ms and stable, your box is correctly sized even if CPU is at 85%. If p99 has crept from 200ms to 900ms over a month while CPU sits at 40%, you have a different problem (disk IOPS, lock contention, GC pressure) and a bigger VM will not fix it.

Most OSS apps expose Prometheus metrics or at least an access log. A 20-line script that bucket-counts request durations from nginx or Caddy logs is sufficient. You do not need Datadog.

Swap-in rate, not swap usage

This is the metric almost nobody watches and it is the one that catches silent degradation. swap used being non-zero is fine — Linux will page out cold memory and leave it there forever. What hurts is pages being swapped back in, because that means hot working-set memory is being read from disk on every access.

Run vmstat 1 for 30 seconds. Look at the si column (swap-in, KB/sec):

  • si = 0 consistently: you are fine, ignore swap usage.
  • si occasionally non-zero: a cron job or backup is paging things back; tolerable.
  • si > 100 sustained: you are thrashing. Latency is already terrible. Resize or kill a process.

On a Hetzner CX22 with 4GB RAM and a 2GB swapfile, I have watched a misconfigured Postgres shared_buffers setting cause 5MB/s of sustained swap-in. The CPU graph looked fine. The disk light did not. p99 was 4 seconds.

Anti-pattern 1: over-provisioning "to be safe"

The most expensive habit in self-hosting is sizing for the worst minute of the worst day. A Gitea instance for a 10-person team has a true steady-state load of maybe 0.05 vCPU and 300 MB RAM. It does not need a 4 vCPU / 8 GB droplet ($48/mo). It needs a CX22 (€4.51/mo) and you will never notice the difference, because the bottleneck for Git over HTTPS is almost always your home upload speed, not the server.

The rough cost of "one tier up, just in case" across the three providers:

Provider Right size One up Annual waste
Hetzner CX22 €4.51 CX32 €7.05 €30
DigitalOcean s-1vcpu-1gb $6 s-1vcpu-2gb $12 $72
Linode Nanode 1GB $5 Shared 2GB $12 $84

For a single hobby box that is lunch money. For a fleet of 15 services it is a meaningful fraction of your hosting bill. Run the numbers in the osscostcalc calculator on this site if you want to see what your specific stack costs across all three vendors at the same tier.

Anti-pattern 2: ignoring burst CPU credits

Hetzner CX/CPX, DigitalOcean Basic (Regular and Premium Intel/AMD), and Linode Shared plans are all "burstable" in practice — you share a physical core with neighbors. The pricing assumes you are not pegged 24/7.

Hetzner is the most honest about this: their shared vCPU lines (CX22, CX32, etc.) have no hard CPU cap and they will not throttle you for normal bursty workloads, but sustained 100% on all vCPUs for hours will eventually get noticed. DigitalOcean's Basic droplets and Linode's Shared CPU instances behave similarly.

What this means in practice:

  • Do not size for sustained 100%. If your steady-state requires that, you want a dedicated-CPU plan (Hetzner CCX, DO CPU-Optimized, Linode Dedicated CPU) which costs roughly 2-3x more.
  • Do size for bursty spikes. A nightly backup that hits 100% for 10 minutes is exactly what these plans are built for.
  • Watch the steal time. top shows %st — if it is consistently above 5%, your neighbor is hammering the host. Move regions, rebuild the VM, or accept it.

The mistake is going to a CCX-class instance because you saw a 100% spike, when what you needed was a CX one tier up with the same shared model.

Anti-pattern 3: undersized or absent swap

The fashion of "swap is bad, disable it" came from databases on dedicated metal where any paging meant catastrophe. On a €5/mo VM running mixed workloads, no swap means a single memory spike causes the OOM killer to murder your Postgres or your Sidekiq workers. That is worse than 200ms of latency.

A reasonable default for OSS boxes:

  • 1-2 GB RAM: 1 GB swapfile, vm.swappiness=10.
  • 4 GB RAM: 2 GB swapfile, vm.swappiness=10.
  • 8 GB+: 2-4 GB swapfile, vm.swappiness=1.

Low swappiness means the kernel only swaps when it has no other choice, which is exactly the safety-net behavior you want. Combined with watching si from vmstat, swap becomes a shock absorber rather than a performance drag.

Hetzner and Linode let you create a swapfile in seconds (fallocate, mkswap, swapon, add to fstab). DigitalOcean Droplets ship without swap by default — add one as part of your provisioning script, always.

Putting it together: a sizing decision tree

You have a running app and you want to know if you should resize. Walk this in order:

  1. Is p99 latency acceptable? If yes, stop. You are fine. Save your money.
  2. Is load/vCPU > 1.5 sustained? If yes, go up one CPU tier (or move to a dedicated-CPU plan if it is constantly pegged).
  3. Is swap-in rate > 100 KB/s sustained? If yes, go up one RAM tier.
  4. Is RSS / total RAM > 80%? If yes, go up one RAM tier even if swap-in is currently zero.
  5. Is steal time > 5%? Rebuild the VM in a different AZ before resizing.

Notice what is not on the list: "CPU was at 95% for two minutes during the backup window." That is what burst plans are for.

Decision checklist

  • Install sysstat (sar, iostat) and htop on every box. Five minutes of work.
  • Add a swapfile sized per the table above. Set vm.swappiness=10.
  • Set up one alert per host: p99 latency over your SLO for 10 minutes. Nothing else pages you.
  • Once a quarter, run vmstat 1 60 and ps aux --sort=-rss | head during peak hours. Write the numbers down.
  • Before resizing up, run the four-metric checklist above. Before resizing down, do it twice.
  • Plug your current and proposed specs into the comparison tool on this site to see whether the same tier is meaningfully cheaper on a different provider — Hetzner is usually 40-60% cheaper than DO/Linode at the same RAM, but egress and region matter.

The 80/20 of right-sizing is this: most OSS apps are I/O- and latency-bound, not CPU-bound. Watch the four metrics, trust the burst plans, keep a swapfile as insurance, and stop paying for headroom you will never spend.

right-sizingcapacity-planninghetznerdigitaloceanlinodeobservability