Tuning VPS Web Hosting in Bottleneck Order: Disk, CPU Steal, PHP, Network

Most tuning guides hand you a list of sysctls and stop there. On a real web host, the order in which you apply changes decides whether you learn anything. This playbook fixes a measurement sequence — disk, CPU steal, PHP and database, then network — so that each change is isolated and attributable. Every step starts with a command that produces a number, defines a threshold that justifies acting, and ends with a re-measurement that either confirms the gain or tells you to revert.

Rule zero: freeze the baseline before touching anything

Run your full measurement set on the untouched server during a representative hour. Capture uptime, vmstat 1 60, iostat -xz 1, ss -s, mpstat -P ALL 1 and a five minute pidstat -dru 5 to a file. Load-test with a fixed tool and rate so results are comparable: wrk -t4 -c200 -d60s --latency https://host/ gives p50/p99 you can diff later.

Write down one primary metric — usually p99 latency at fixed request rate, or requests per second at a fixed p99 budget. Without a single number to defend, every later change looks like an improvement because some other metric moved. Store the baseline text files in the repo alongside your config so the comparison survives the next on-call rotation.

Step one: disk latency and queue depth

Disk is first because it is the most common cause of a web host that “feels slow” and the easiest to measure unambiguously. Check iostat -xz 1 for r_await/w_await above 2 ms on a local NVMe tier, or above 10 ms on network storage, and %util near 100 sustained. Then confirm with a short fio --name=randread --rw=randread --bs=4k --iodepth=32 --numjobs=4 --size=1G --runtime=30 --direct=1 run.

If random 4k read IOPS land under roughly 3,000 on a tier advertised as NVMe, you are either throttled by a burst credit cap or sharing a saturated backend. Compare r/s during the load test against the fio figure; if application read rates sit far below drive capability, the disk is not your bottleneck and you move on. Record the gap so step four’s network work does not get blamed later.

Step two: CPU steal and per-core saturation

CPU steal is the only metric that measures your provider rather than your code. vmstat 1 reports it in the st column; sustained values above 2–3 percent on an idle-ish guest mean the hypervisor is oversubscribed. Cross-check with mpstat -P ALL 1 for a single hot core while others idle — that pattern is usually a single-threaded PHP handler or a lock, not a host problem.

Only act on steal by moving providers or resizing; no guest-side tuning fixes it. If steal is clean but one core pegs at 100 percent, the next step is perf top -p $(pgrep -d, -f php-fpm) to see whether time goes to userspace functions or to __schedule and spinlocks. High scheduler time with moderate CPU total points to excessive runqueue pressure from too many workers.

Step three: PHP workers, OPcache and the database

Once the platform is clean, tune the application tier. Set pm.max_children from measured per-worker RSS, not from a blog formula: ps -o rss= -C php-fpm8.2 | awk '{s+=$1; n++} END {print s/n" KB, "n" workers"}'. Divide available memory minus 1 GB of headroom by that average, and cap so MySQL keeps its buffer pool. Check OPcache hit rate and opcache.max_accelerated_files for evictions.

On the database side, look at SHOW GLOBAL STATUS LIKE 'Threads_running' and slow query counts before adding RAM. A Threads_running above double your core count during load means queries are queuing, and no amount of PHP tuning helps. Enable log_slow_verbosity briefly, fix the top two statements, and re-run the same wrk test to isolate the gain.

Between steps: is it saturation or contention?

Before the network, one more platform check: contention inside the guest. Compare vmstat‘s r column with core count and read /proc/pressure/cpu and /proc/pressure/io. A some avg60 above 20 with flat application CPU means processes wait on a lock or a shared resource, and adding workers will make it worse rather than better.

Use bpftrace -e 'tracepoint:syscalls:sys_enter_futex /pid == 1234/ { @[comm, ustack(3)] = count(); }' for thirty seconds to find which call site holds the lock. This distinguishes a genuinely underpowered plan from a badly configured pool, and it belongs before any network work because queued connections are a symptom of a stalled app, not a network fault.

Step four: network, TLS and connection handling

Finally measure the path between client and server. ss -s shows total sockets and time-wait counts; a large tw count with low throughput suggests short-lived connections and a keepalive problem rather than bandwidth. Use curl -w 'dns:%{time_namelookup} conn:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer}\n' -o /dev/null -s https://host/ to split the request into phases and find where the milliseconds hide.

If TTFB dominates while disk and CPU are idle, check for a slow upstream proxy, a DNS resolver stall, or a net.core.somaxconn drop counted in netstat -s | grep -i listen. Treat connection reuse as a tuning lever: enabling HTTP/2 keepalive on nginx often cuts p99 more than any kernel knob. Re-run wrk with --latency and compare p99 directly against the frozen baseline.

Report the result honestly

Keep the test rig identical across steps: same client machine, same concurrency, same URL, same cache state. A cold OPcache or a cold page cache can move p99 by more than most tuning changes, so warm the target with three discarded runs before the measured one. Run each measurement three times and report the median, not the best.

When a step yields a gain, hold it for a full day of real traffic before moving on. Production request mixes contain outliers your synthetic test never generates, and a change that helps p99 at 200 concurrent connections can hurt throughput at the long tail. The daily sar -u -r -d archive is enough to catch a regression before your users do.

At the end, produce a single table: baseline, after disk, after CPU, after app, after network, with p99 and RPS for each. If a step produced no measurable change, say so and revert the setting — an unverified sysctl is debt, not progress. This is also how you justify a resize or a provider move: the steal column and the disk gap are the two facts a support ticket cannot argue with.

Document the accepted thresholds in the runbook next to the commands so the next incident follows the same ladder instead of restarting from guesses. For hardware classes that make step one and step two boring, our VPS comparison table lists the CPU and disk tiers worth paying for, and the plan breakdown pairs each tier with a realistic concurrent request count. Publish the baseline table with the runbook and the next engineer inherits measurements instead of folklore.

Leave a Reply