Collect 7-14 days of CPU (run queue, steal), memory (MemAvailable, swap-in, OOM kills), disk (IOPS, await latency, queue size) and network (peak Mbit/s, monthly TB). Size each resource for its busy-hour peak plus headroom: CPU at or below 70%, memory at or below 80% with no sustained swap-in, disk at or below 70% full after a year of growth.
Assumptions, and when this guide does not apply
The method assumes a Linux server (physical, VM or container host) that already runs the workload, or a staging copy you can load-test. It assumes you have shell access and can install the sysstat package, which provides sar, iostat and pidstat.
It does not apply directly when:
- There is no workload yet. Start on a small size, load-test with realistic traffic, then apply this guide to the test results.
- The load is very spiky (peak more than about 3x the quiet level). Sizing one VPS for peak will leave it idle most of the time; read Shared vs VPS vs cloud vs bare metal first.
- The workload is GPU-bound. Use GPU memory for LLM inference.
The method in five steps
- Turn on history. Install
sysstatand enable its collector (on RHEL-family systemssystemctl enable --now sysstat; on Debian and Ubuntu setENABLED="true"in/etc/default/sysstat). It samples every 10 minutes by default, which is enough for sizing. - Capture a busy window. Keep at least 7 days, 14 if you have a weekly cycle, and include a known busy period (campaign, month-end, release).
- Find the busy hour. For each resource, take the 95th percentile of the busiest hour, not the monthly average. Averages hide the hour that hurts.
- Apply headroom. Divide each busy-hour peak by its target utilization from the headroom table below.
- Round up to a real size and check the next constraint. A size with enough CPU but too little memory is the wrong size. The binding resource sets the plan.
The VPS sizing tool does steps 4 and 5 from the numbers you collect.
CPU: run queue, steal and per-process use
CPU percentage alone misleads on virtual machines. Read four numbers together:
- Run queue (
rinvmstat). Processes running or waiting for a CPU. Ifris regularly above the vCPU count at peak, work is queuing. - Load average. On Linux it counts runnable tasks and tasks in uninterruptible sleep, usually waiting for disk. A load of 8 on a 4 vCPU server with idle CPU is a storage problem, not a CPU problem.
- Steal (
stinvmstat,%stealinsar -u). Time your vCPU was ready to run but the hypervisor ran something else. As a rule of thumb, sustained steal above about 5% deserves a question to the provider; above about 10% means you are not getting the CPU you size for, and a larger instance of the same class may not fix it. - Per-process CPU (
pidstat -u). Tells you whether one single-threaded process is pinned at 100% of one core while the rest idle. Adding vCPUs does not help a single-threaded bottleneck.
Also read /proc/pressure/cpu. The kernel's pressure stall information reports the share of time at least some tasks were waiting for CPU over 10, 60 and 300 seconds. A some avg60 that climbs during the busy hour is direct evidence of CPU shortage.
Memory: available memory, page cache, swap and OOM
Linux uses free memory as page cache, so "free" is usually small and that is healthy. Size from these instead:
MemAvailablein/proc/meminfo(theavailablecolumn offree -m): the kernel's estimate of memory available for new work without swapping. Peak used memory isMemTotal - MemAvailable.- Process RSS.
ps -eo pid,comm,rss --sort=-rsslists resident memory per process. RSS counts shared pages in every process that maps them, so adding up RSS for 20 PHP-FPM workers overstates their total. For a fairer figure use PSS from/proc/PID/smaps_rollup. - Page cache you depend on. Databases and file servers rely on the cache to avoid disk reads. If the hot data set is 12 GB and only 4 GB is left for cache, reads go to disk and latency rises with no error message.
- Swap activity, not swap used. Swap used with
si/so(invmstat) near zero means cold pages were parked there, which is fine. Sustained non-zero swap-in at peak means the working set does not fit. - OOM kills.
journalctl -k | grep -iE 'out of memory|oom-kill'. One OOM kill in the sample window is a sizing failure, whatever the averages say.
Disk: IOPS, latency, queue size and capacity
Run iostat -x 5 during the busy hour and read these columns for the data volume:
r/sandw/s: read and write operations per second. Compare the peak with the volume's stated IOPS limit, which is Confirmed at quote for any plan.r_awaitandw_await: average milliseconds per request including queue time. Local NVMe usually serves small requests well under a millisecond; network block storage takes longer. What matters is the trend: if await rises at peak whiler/sandw/sflatten, you have hit a limit.aqu-sz: average queue length. A queue that grows at peak confirms saturation.%util: treat with care. The iostat manual notes that for devices serving requests in parallel, such as RAID arrays and modern SSDs, it does not reflect their performance limits. 100% util on an NVMe drive can still have spare capacity.
%iowait is CPU idle time while I/O is outstanding. It hints at storage waits but drops to zero when the CPU is busy with other work, so confirm with await and /proc/pressure/io. Use pidstat -d 5 to find which process does the I/O.
For capacity, measure growth per month from df history and project 12 months. Add room for the things that temporarily double data: database dumps before upgrades, table rewrites, log bursts and restore staging.
Network: peak rate, monthly egress and connections
sar -n DEV 5 shows received and transmitted kB per second per interface. You need two numbers: the busy-hour peak (to size the port) and the monthly outbound total (to check the transfer allowance).
Worked example: from Mbit/s to TB per month
1 Mbit/s = 0.125 MB/s. A 30-day month has 30 x 86,400 = 2,592,000 seconds. So 1 Mbit/s sustained for a month = 0.125 x 2,592,000 = 324,000 MB = 0.324 TB.
A server averaging 25 Mbit/s outbound sends 25 x 0.324 = 8.1 TB per month. Its busy-hour peak may be 3-4 times the average, which is what the port must carry.
For connections, ss -s summarizes TCP states. Thousands of connections in TIME-WAIT are normal for busy HTTP servers; a growing count of established connections that never close points to a client or keep-alive problem rather than a sizing problem.
Commands to collect the evidence
Run these during the busy hour. Each samples every 5 seconds for one minute; increase the count for a longer view. History from the sysstat collector is read with sar and a -f file for past days.
# CPU: run queue (r), iowait (wa), steal (st)
vmstat -w 5 12
sar -u 5 12 # %user %system %iowait %steal
sar -q # load average history for today
pidstat -u 5 12 # CPU per process
cat /proc/pressure/cpu
# Memory: available, swap activity, biggest processes, OOM kills
free -m
grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo
sar -W 5 12 # pages swapped in and out per second
ps -eo pid,comm,rss --sort=-rss | head -15
journalctl -k --since "-14 days" | grep -iE 'out of memory|oom-kill'
cat /proc/pressure/memory
# Disk: IOPS, latency, queue, per-process I/O, capacity
iostat -x 5 12
pidstat -d 5 12
df -h
cat /proc/pressure/io
# Network: throughput per interface, TCP summary
sar -n DEV 5 12
ss -s
Label each saved output with the date and the event it covers (for example "campaign launch, 19:00-21:00").
Reading the signals
| Signal | Healthy at busy hour | Investigate when | Usual action |
|---|---|---|---|
| CPU busy (user + system) | At or below 70% of vCPUs | Above 85% for 15 minutes or more | More vCPUs, or fix the hot code path |
Run queue r | At or below vCPU count | Above 2x vCPU count | More vCPUs, or fewer workers |
| Steal | Under about 2% | Sustained above about 5% | Ask the provider; dedicated vCPU class or bare metal |
| MemAvailable | At least 20% of RAM | Under 10% | More RAM, or cap worker and cache sizes |
Swap-in (si) | Near zero | Sustained non-zero | More RAM |
| OOM kills | None | Any | More RAM and explicit memory limits |
| Disk await | Stable as IOPS rise | Rises while IOPS flatten | Faster storage tier or local NVMe |
| Disk used | At or below 70% after 12 months' growth | Above 80% today | Larger volume before the next quarter |
| Network peak | At or below 50-60% of port speed | Above 80% | Larger port, CDN offload or compression |
Headroom rules
Headroom covers what the sample did not see: growth, a heavier day, a deploy, a backup running in the busy hour. These are planning defaults; tighten them only when you have long history and an easy resize path.
| Resource | Target at busy hour | Why |
|---|---|---|
| CPU, latency-sensitive (web, API) | 70% of vCPUs | Queueing delay grows sharply as utilization nears 100% |
| CPU, batch or background | 85% of vCPUs | Throughput matters more than per-request latency |
| Memory | 80% used, no sustained swap-in | Leaves page cache and room for deploy overlap |
| Deploys | Room for two copies of the app | Rolling restarts run old and new processes together |
| Disk capacity | 70% after 12 months of growth | Dumps, upgrades and restores need temporary space |
| Disk IOPS | 70% of the volume limit | Backups and maintenance add I/O at unplanned times |
| Monthly transfer | 80% of the allowance | Overage terms often cost more than a larger allowance |
Worked example: WordPress and WooCommerce
Worked example: a WooCommerce store on one VPS
Measured (example values): busy-hour peak of 15 uncached PHP requests per second (cart, checkout, account pages; product pages served from a page cache). Average PHP time per uncached request 0.25 s, of which 0.12 s is CPU. PHP-FPM worker PSS about 80 MB. Database (MariaDB) data 1.5 GB. Redis object cache 256 MB.
PHP workers. By Little's law, concurrent requests = arrival rate x time in system = 15 x 0.25 = 3.75. Allow for bursts at 3-4x the average concurrency: set pm.max_children to 16.
CPU. 15 req/s x 0.12 CPU-s = 1.8 vCPU busy from PHP. Add about 0.5 vCPU for the database and web server: 2.3. At a 70% target: 2.3 / 0.7 = 3.3, so 4 vCPU.
Memory.
- PHP-FPM: 16 x 80 MB = 1,280 MB
- InnoDB buffer pool sized to hold the 1.5 GB data set: 2,048 MB
- Redis 256 MB, web server 100 MB, OS and agents 500 MB
- Total 4,184 MB. At an 80% target: 4,184 / 0.8 = 5,230 MB, so 8 GB (6 GB is the tight minimum).
Disk. 1.5 GB database, 40 GB media growing 2 GB a month: (1.5 + 40 + 24) / 0.7 = 94 GB, so 100 GB or more, on SSD or NVMe because checkout writes are small and synchronous.
Result: 4 vCPU, 8 GB RAM, 100+ GB fast storage. Re-check before each campaign; if campaign peaks are 3x normal, plan a resize window rather than paying for 12 vCPU all year.
Worked example: a Node.js or Python API
Worked example: a stateless JSON API
Measured (example values): 300 requests per second at the busy hour, 4 ms of CPU per request (from pidstat CPU time divided by the request count), average response 8 KB, process RSS 180 MB. The database runs elsewhere.
CPU. 300 x 0.004 = 1.2 vCPU busy. A Node.js process runs JavaScript on one main thread, so run several processes (the cluster module or a process manager): 3 processes keep each one under about 40% of a core. At a 70% target: 1.2 / 0.7 = 1.7, plus garbage collection and TLS overhead, so 2 vCPU minimum, 4 vCPU comfortable. For Python with Gunicorn, the Gunicorn documentation suggests starting at (2 x cores) + 1 workers and tuning from measurements.
Memory. 3 x 180 MB = 540 MB. During a rolling restart old and new processes overlap: 1,080 MB. OS and agents 500 MB. Total 1,580 MB / 0.8 = 1,975 MB, so 2 GB minimum, 4 GB comfortable.
Network. 300 x 8 KB = 2,400 KB/s = 19.2 Mbit/s at peak. If the monthly average is 40% of peak: 19.2 x 0.4 = 7.7 Mbit/s, and 7.7 x 0.324 = 2.5 TB per month outbound.
Result: 4 vCPU, 4 GB RAM, small disk, a transfer allowance of at least 3.1 TB (2.5 / 0.8). If traffic varies 5x across the day, this tier is a good candidate for cloud autoscaling instead.
Worked example: a PostgreSQL database
Worked example: a dedicated PostgreSQL server
Measured (example values): 60 GB on disk, of which about 12 GB is hot (estimated from buffer hit ratios and pg_buffercache). max_connections 100, with up to 40 active at the busy hour. Peak 3,000 read IOPS and 800 write IOPS in iostat. Growth 3 GB a month.
Memory. The PostgreSQL documentation suggests a starting shared_buffers of 25% of RAM on a dedicated server, and notes that more than 40% is unlikely to help because PostgreSQL also relies on the OS cache. The goal is for the hot 12 GB to sit in shared_buffers plus page cache.
- 16 GB RAM: shared_buffers 4 GB.
work_memapplies per sort or hash operation (hash operations may use 2x by default), so 40 active queries x 2 operations x 16 MB = 1.3 GB worst case. OS and other processes 1 GB. Left for page cache: about 9.7 GB. Hot set 12 GB does not fit; expect disk reads at peak. - 32 GB RAM: shared_buffers 8 GB, the same 1.3 GB for work_mem, 1 GB maintenance and 1 GB OS. Page cache about 20.7 GB. The hot set fits with room for growth. Choose 32 GB.
CPU. Measure busy-hour CPU from pidstat on the postgres processes. Example: 3.1 vCPU busy, so 3.1 / 0.7 = 4.4, choose 8 vCPU to leave room for autovacuum and index builds.
Disk. (60 + 36 GB of growth) / 0.7 = 137 GB, so 160 GB or more. IOPS: (3,000 + 800) / 0.7 = 5,430, so a volume rated for at least about 5,500 IOPS with low write latency, because commits wait for WAL writes.
Result: 8 vCPU, 32 GB RAM, 160+ GB of low-latency storage. Back it up with WAL archiving, not only nightly dumps; see Backup and disaster recovery.
Sizing checklist
sysstathistory enabled and at least 7 days collected, including a busy period.- Busy-hour 95th percentile recorded for CPU, memory used, disk IOPS and await, and network.
- Steal checked; any sustained value above about 5% noted.
- No OOM kills and no sustained swap-in in the window, or the cause understood.
- Per-process CPU and memory known for the main services (workers, database, cache).
- Deploy overlap included in the memory budget.
- Disk growth per month measured and projected 12 months.
- Monthly egress in TB calculated from the average Mbit/s.
- Headroom applied per resource, and the binding resource identified.
- Numbers entered in the VPS sizing tool and attached to the quote request.
Common mistakes
- Sizing from monthly averages. A 20% monthly average can hide a 95% busy hour. Size from the busy hour.
- Reading "free" memory. Low free memory is normal on Linux. Read MemAvailable and swap-in instead.
- Summing RSS. Shared pages are counted in every process, which overstates PHP or Python worker memory. Use PSS.
- Trusting %util on SSDs. It shows time with requests in flight, not how close the device is to its limit.
- Adding vCPUs to a single-threaded bottleneck. If one process sits at 100% of one core, more cores do nothing.
- Forgetting temporary disk needs. Major version upgrades, table rewrites and restores can need as much free space as the data itself.
Frequently asked questions
How many vCPUs does a WordPress site need?
It depends on uncached PHP requests, not page views. Multiply uncached requests per second at the busy hour by CPU seconds per request, then divide by 0.7. Many small stores land on 2-4 vCPU once a page cache serves anonymous traffic.
Is some swap use a problem?
Not by itself. Swap used with near-zero swap-in means idle pages were moved out. Sustained swap-in during the busy hour means the working set does not fit in RAM and you need more memory.
What is CPU steal and can I fix it myself?
Steal is time your virtual CPU was ready but the hypervisor ran another guest. You cannot fix it inside the VM. Report sustained values above about 5% to the provider, or move to dedicated vCPUs or bare metal.
How long should I collect data before sizing?
At least 7 days, 14 if traffic has a weekly pattern, and always including a known busy period. The sysstat collector keeps history so you can read past days with sar.
Should I size for future growth now?
Size disk for 12 months of growth because resizing storage is the most disruptive change. Size CPU and memory for today plus headroom, and resize when the busy-hour numbers approach the targets.
What should I send with a VPS quote request?
The busy-hour numbers for each resource, the monthly egress in TB, disk growth per month and the size you calculated. BroadNet Cloud confirms the plan, storage tier and transfer terms in the written quote.
Sources
- vmstat(8) manual page, Linux man-pages project
- iostat(1) manual page, Linux man-pages project (sysstat)
- sar(1) manual page, Linux man-pages project (sysstat)
- pidstat(1) manual page, Linux man-pages project (sysstat)
- ss(8) manual page, Linux man-pages project
- The /proc filesystem (meminfo, MemAvailable), Linux kernel documentation
- PSI: Pressure Stall Information, Linux kernel documentation
- PostgreSQL documentation: Resource Consumption, PostgreSQL Global Development Group
- PHP-FPM configuration, The PHP Group
- Gunicorn design: how many workers?, Gunicorn
- Node.js documentation: Cluster, OpenJS Foundation

