Most tuning advice fails for a simple reason: it is applied before the bottleneck is identified. The ordering below is derived from the constraint that actually limits a small web host — RAM for worker processes, then disk for the database, then CPU for TLS and dynamic rendering. Work through the layers in order, measure after each change, and revert anything that does not move p99 latency or throughput by more than measurement noise.
The target environment is a 2 vCPU, 4 GB instance running nginx, PHP-FPM, MariaDB, and Redis on the same machine. That is the common shape for a self-hosted production site, and it is a shape where careless buffer sizing causes more outages than traffic does. Larger premium cloud solutions plans simply scale the same numbers up.
1. Establish a measurement baseline you can trust
Capture five minutes of idle metrics before changing anything, then capture the same set under representative load. Without this, every later comparison is guesswork.
vmstat 5 60 > /tmp/baseline.vmstat— watchr,si/so,wa,st.iostat -xz 5 60 > /tmp/baseline.iostat— watchr_await,w_await,aqu-sz.cat /proc/pressure/{cpu,memory,io}— PSI gives you percentage of time stalled, which is far more actionable than load average.pidstat -u -r -d 5 60— attribute consumption to specific processes before deciding what to tune.
Load-test with wrk -t2 -c100 -d120s --latency http://127.0.0.1/ to avoid contaminating results with network variance. Record p50, p99, and error count for every configuration you try.
2. Budget RAM by subtraction, not addition
On 4 GB total, allocate in this order: OS and page cache, then MariaDB, then PHP-FPM, then Redis, then nginx. Anything left over is spare capacity for cache, which is exactly where you want it.
| Consumer | Allocation | Notes |
|---|---|---|
| OS + page cache | ~600 MB | Never below 400 MB |
| MariaDB | 1,100 MB | innodb_buffer_pool ~768 MB |
| PHP-FPM | 1,400 MB | ~30 children × 45 MB |
| Redis | 400 MB | maxmemory 320 MB + overhead |
| nginx + misc | 200 MB | Worker buffers, TLS sessions |
Verify the PHP-FPM figure with real data instead of assuming 45 MB: ps -o rss= -C php-fpm8.3 | awk '{s+=$1;n++} END {printf "%d MB avg over %d procs\n", s/n/1024, n}'. If the average is above 60 MB, find out why before raising pm.max_children — oversized opcache or a leaking extension is cheaper to fix than more RAM.
3. Database: cap the buffer pool and the connection count
MariaDB’s default max_connections of 151 with per-connection buffers can allocate well over a gigabyte in worst case. Set it explicitly.
[mysqld]
innodb_buffer_pool_size = 768M
innodb_buffer_pool_instances = 1
innodb_log_file_size = 256M
innodb_flush_method = O_DIRECT
innodb_flush_log_at_trx_commit = 2
max_connections = 60
table_open_cache = 800
tmp_table_size = 32M
max_heap_table_size = 32M
performance_schema = OFF
innodb_flush_log_at_trx_commit = 2 trades at most one second of committed transactions for a large reduction in fsync pressure. On a single NVMe-backed VPS with application-level retries, that is usually the right trade. Verify with SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool%': a read-hit ratio above 99.5% means the pool is sized correctly; below 99% means either the pool is too small or a query is scanning tables it should not.
4. PHP-FPM: static pools with measured limits
pm = static with a fixed child count removes fork overhead and makes memory usage predictable. Size it from measured RSS, not from tutorials:
pm = static
pm.max_children = 30
pm.max_requests = 500
request_terminate_timeout = 30s
opcache.memory_consumption = 192
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.jit = off
pm.max_requests = 500 bounds the damage from slow memory leaks while avoiding respawn churn. Disabling validate_timestamps eliminates a stat call per request but requires an explicit reload on deploy — wire it into your release script. Keep the JIT off for typical WordPress or Laravel workloads; on 4 GB with a busy pool it consumes more memory than it returns in CPU.
5. nginx: buffers, keepalive, and file serving
worker_processes auto;
worker_connections 2048;
keepalive_timeout 30;
keepalive_requests 5000;
client_body_buffer_size 16k;
client_max_body_size 16m;
output_buffers 2 32k;
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
Set open_file_cache only if you serve many static files; it avoids repeated stat() calls and shows up clearly in strace -c -p <worker> workloads. Confirm you are not hitting worker limits with nginx -T | grep worker_connections and check the error log for worker_connections are not enough before raising the value.
6. Kernel: a short list that actually moves counters
Resist the temptation to paste a hundred sysctls. These four address the failure modes a small host actually experiences: connection backlogs, ephemeral port exhaustion, swap thrash, and dirty-page stalls.
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.ip_local_port_range = 10240 65000
net.ipv4.tcp_tw_reuse = 1
vm.swappiness = 10
vm.dirty_ratio = 10
vm.dirty_background_ratio = 5
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
Apply with sysctl --system and measure the effect of each group separately. somaxconn only matters when you see SYN drops in netstat -s | grep -i listen. tcp_tw_reuse only matters when ss -s shows thousands of TIME_WAIT sockets. Changing parameters without the corresponding symptom is how people end up with mysterious regressions.
7. Caching order: page cache before object cache
A page cache eliminates the entire request path for anonymous traffic; an object cache only shortens database calls. Measure the anonymous/cached hit ratio in your access log first. If more than 70% of requests are anonymous and cacheable, nginx FastCGI caching or a full-page cache plugin delivers far more per unit of engineering time than Redis.
Redis remains valuable for logged-in sessions and shared fragment caching. Cap it honestly: maxmemory 320mb with maxmemory-policy allkeys-lru, and monitor INFO stats for evicted_keys. Evictions rising steadily means the cache is thrashing — either raise the limit or reduce TTLs to keep the working set inside the budget. If you are also running containers on this host, the resource-cgroup patterns described in the managed versus unmanaged server operations comparison determine who enforces these limits when the host is under pressure.
8. Close the loop with a regression check
- Re-run the same
wrkcommand and compare p99 against the baseline file. - Re-check PSI memory pressure; it should fall, not merely shift.
- Confirm
dmesg -T | grep -i oomis empty after 24 hours. - Log the final config in version control so a future upgrade does not silently reset it.
Tuning is only complete when the numbers are recorded and the changes are reproducible. Anything else is a configuration change waiting to be undone by the next distro upgrade.



Leave a Reply
You must be logged in to post a comment.