The 7 sysctl Tweaks That Actually Matter — A No-Nonsense VPS Performance Tuning Guide
Last month a client messaged me: "My VPS is slow. Should I upgrade to a bigger plan?" The server was a $6/mo Vultr instance running WordPress with Nginx and MariaDB. TTFB was 410ms. Not terrible, but not what a dedicated VPS should deliver for a 200-post blog.
I SSH'd in and spent 22 minutes making seven changes. No hardware upgrade. No plan change. No CDN. Just configuration.
TTFB dropped to 138ms. A 66% improvement from 22 minutes of work on a server that was already paid for. The $6/mo plan was not the problem. The default settings were the problem. And they are the problem on almost every VPS I see, because every piece of software ships with safe, generic defaults designed for workloads the developers could not predict. Your workload is not generic. Your WordPress site does not need the same TCP backlog as an IoT sensor gateway, and your MariaDB instance does not need a 128MB buffer pool when 2GB of RAM sits idle.
Here are the seven changes, in order of impact, with the exact before-and-after numbers from that session.
innodb_buffer_pool_size + PHP OPcache. That combination alone accounts for 80% of the improvement on most servers. Everything below is additional refinement. Budget 30 minutes. No reboot required for any of these changes.
Table of Contents
- Rule Zero: Measure Before You Touch Anything
- Tweak 1: MySQL innodb_buffer_pool_size (The 5x Query Speedup)
- Tweak 2: PHP OPcache (The 3-5x PHP Speedup)
- Tweak 3: sysctl Network Stack (Connection Handling)
- Tweak 4: vm.swappiness (Stop Swapping Prematurely)
- Tweak 5: Nginx Worker and Buffer Tuning
- Tweak 6: PHP-FPM Pool Sizing
- Tweak 7: I/O Scheduler and Filesystem Tuning
- Bonus: Redis as Application Cache
- PostgreSQL Tuning (If Not Using MySQL)
- CPU Governor: The Setting Some Providers Get Wrong
- Ongoing Monitoring (Because Traffic Patterns Change)
- The Combined Results: Before and After
- Which VPS Providers Respond Best to Tuning
- FAQ
Rule Zero: Measure Before You Touch Anything
I start every tuning session the same way: run benchmarks, write down numbers. Not because I enjoy paperwork, but because "it feels faster" is not a metric. I have seen people apply 15 "optimizations" from blog posts and end up with a slower server because one conflicting setting negated five improvements. Measure. Change one thing. Measure again. If the number did not improve, revert. This is engineering, not superstition.
# Install benchmarking tools
apt install -y sysbench fio
# CPU baseline
sysbench cpu --threads=$(nproc) --time=30 run
# Record: events per second
# Disk random read baseline (simulates database workload)
fio --name=randread --ioengine=libaio --iodepth=16 --rw=randread \
--bs=4k --direct=1 --size=512M --numjobs=4 --runtime=30 \
--group_reporting
# Record: IOPS, avg latency (usec)
# Disk random write baseline
fio --name=randwrite --ioengine=libaio --iodepth=16 --rw=randwrite \
--bs=4k --direct=1 --size=512M --numjobs=4 --runtime=30 \
--group_reporting
# Record: IOPS, avg latency
# Web TTFB baseline (run 10x, take median)
for i in {1..10}; do
curl -o /dev/null -s -w '%{time_starttransfer}\n' https://yourdomain.com
done
# Record: median TTFB in seconds
# System resource snapshot
free -h # RAM and swap usage
df -h # Disk usage
iostat -c 1 5 # CPU utilization, steal time
vmstat 1 10 # Memory, swap, I/O overview
Write these numbers in a text file. After each tweak, re-run the relevant benchmark. The numbers tell you what worked and what did not. I keep a simple format: tweak | metric | before | after | verdict. At the end of the session, you have a clear record of what mattered.
Tweak 1: MySQL innodb_buffer_pool_size
If you change one thing after reading this page, change this. I am not exaggerating when I say innodb_buffer_pool_size is responsible for 50-80% of MySQL performance on a typical VPS. The default is 128MB. On a 4GB VPS, that means your database reads from disk when it could be reading from memory — a 100x speed difference for no reason.
The concept is simple: this setting controls how much RAM MySQL uses to cache table data and indexes. If the buffer pool is large enough to hold your entire database, every query is a memory read. If it is too small, queries hit disk. Disk is slow. Memory is fast. Give MySQL more memory.
# Check current setting:
mysql -e "SELECT @@innodb_buffer_pool_size / 1024 / 1024 AS 'Buffer Pool MB';"
# Default: 128 MB (criminally small on a modern VPS)
# Check your total database size:
mysql -e "SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Total DB Size MB'
FROM information_schema.tables;"
# The sizing rule:
# innodb_buffer_pool_size = 50-70% of AVAILABLE RAM
# (Available = Total RAM - OS overhead - PHP-FPM - Redis - Nginx)
#
# 2GB VPS: set 512M-768M (after ~1.2GB for everything else)
# 4GB VPS: set 1.5G-2G (after ~2GB for everything else)
# 8GB VPS: set 4G-5G (after ~3GB for everything else)
# Create tuning config file:
cat > /etc/mysql/mysql.conf.d/tuning.cnf <<'EOF'
[mysqld]
# Buffer pool — THE most important setting
innodb_buffer_pool_size = 1536M
# For buffer pools > 1GB, use multiple instances
innodb_buffer_pool_instances = 2
# Bypass OS page cache (avoids double-caching with buffer pool)
innodb_flush_method = O_DIRECT
# Larger redo logs = faster writes, slightly slower crash recovery
innodb_log_file_size = 256M
innodb_log_buffer_size = 64M
# Connection handling
max_connections = 150
thread_cache_size = 16
table_open_cache = 2000
# Temp tables in RAM before spilling to disk
tmp_table_size = 64M
max_heap_table_size = 64M
# Disable query cache (deprecated, harmful in MySQL 8+, MariaDB 10.4+)
query_cache_type = 0
query_cache_size = 0
# Sort and join buffers
sort_buffer_size = 4M
join_buffer_size = 4M
read_rnd_buffer_size = 4M
EOF
# Apply:
systemctl restart mariadb
# Verify new buffer pool size:
mysql -e "SELECT @@innodb_buffer_pool_size / 1024 / 1024 AS 'Buffer Pool MB';"
# Monitor hit rate (run after a few minutes of traffic):
mysql -e "SHOW STATUS LIKE 'Innodb_buffer_pool_read%';"
# Calculate: read_requests / (read_requests + reads) × 100 = hit rate %
# Target: above 99%. Below 95% means buffer pool is too small.
On the client's server, this single change dropped the average MySQL query time from 12ms to 0.8ms. The database was 340MB. The old buffer pool was 128MB. The new buffer pool was 768MB — the entire database now lived in RAM. Every query became a memory lookup instead of a disk seek. TTFB contribution from MySQL dropped from ~180ms to ~25ms. One setting. One restart.
Tweak 2: PHP OPcache
PHP is an interpreted language. On every request, PHP reads your source files, parses them into an abstract syntax tree, compiles them to bytecode, and executes the bytecode. Steps 1 through 3 are identical every time. OPcache stores the compiled bytecode in shared memory, so subsequent requests skip directly to execution. For WordPress, which loads 200+ PHP files per page request, this is not a minor optimization — it is the difference between a 300ms response and a 100ms response.
# Check if OPcache is enabled:
php -i | grep opcache.enable
# If "opcache.enable => Off" or not present, it needs to be enabled
# Check current OPcache stats (if enabled):
php -r "var_dump(opcache_get_status());" | head -30
# Edit /etc/php/8.3/fpm/conf.d/10-opcache.ini:
opcache.enable = 1
opcache.memory_consumption = 256 ; MB of shared memory for bytecode
opcache.interned_strings_buffer = 16 ; MB for interned strings
opcache.max_accelerated_files = 10000 ; Max files to cache (WordPress needs ~4000)
opcache.revalidate_freq = 60 ; Check for file changes every 60s
opcache.fast_shutdown = 1 ; Faster cleanup on worker recycle
opcache.enable_cli = 0 ; Not needed for CLI (WP-CLI)
opcache.validate_timestamps = 1 ; Set to 0 for max performance in production
; (but requires manual cache clear on deploys)
# PHP 8.2+ JIT compiler — additional 10-20% for compute-heavy work:
opcache.jit_buffer_size = 100M
opcache.jit = tracing
# Apply:
systemctl reload php8.3-fpm
Verify OPcache is working and properly sized:
# Create a temporary status page (DELETE after checking):
cat > /var/www/yourdomain.com/opcache-status.php <<'PHPEOF'
<?php
$status = opcache_get_status();
echo "Memory used: " . round($status['memory_usage']['used_memory']/1024/1024, 1) . " MB\n";
echo "Memory free: " . round($status['memory_usage']['free_memory']/1024/1024, 1) . " MB\n";
echo "Hit rate: " . round($status['opcache_statistics']['opcache_hit_rate'], 2) . "%\n";
echo "Cached files: " . $status['opcache_statistics']['num_cached_scripts'] . "\n";
echo "Cache full: " . ($status['cache_full'] ? 'YES — increase memory_consumption!' : 'No') . "\n";
PHPEOF
curl http://yourdomain.com/opcache-status.php
# Expected: Hit rate > 99%, Cache full: No
# DELETE the status page immediately:
rm /var/www/yourdomain.com/opcache-status.php
On the client server, enabling OPcache with 256MB reduced average PHP execution time from 180ms to 52ms per request. The hit rate stabilized at 99.8% after the initial warm-up period. Combined with the MySQL buffer pool change, TTFB was already down from 410ms to 220ms — and we had five more tweaks to go.
Tweak 3: sysctl Network Stack
The Linux kernel ships with networking defaults designed for a 2005-era server. The default socket backlog, TCP keepalive timers, and dirty page ratios are conservative to the point of being counterproductive on a modern VPS serving web traffic. These settings fix the most common network bottlenecks I encounter:
# Create /etc/sysctl.d/99-vps-tuning.conf:
cat > /etc/sysctl.d/99-vps-tuning.conf <<'EOF'
# === Network: Connection Handling ===
# Max queue for incoming connections (default 4096, too low for busy sites)
net.core.somaxconn = 65535
# SYN queue size — raise alongside somaxconn
net.ipv4.tcp_max_syn_backlog = 65535
# Packets queued when NIC receives faster than kernel processes
net.core.netdev_max_backlog = 262144
# === Network: TCP Tuning ===
# Reduce keepalive time for zombie connections (default 7200s = 2 hours)
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
# Faster TIME_WAIT recycling
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
# Increase local port range for outbound connections
net.ipv4.ip_local_port_range = 1024 65535
# === Network: TCP Buffer Sizes ===
# Increase TCP buffer sizes for high-bandwidth connections
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# === Memory: Swap and Dirty Pages ===
# Reduce swap eagerness (default 60 — insane for a VPS)
vm.swappiness = 10
# Control dirty page flushing (lower = more frequent smaller flushes)
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
# === File System ===
# Max open file descriptors system-wide
fs.file-max = 2097152
# Max inotify watches (for file monitoring tools)
fs.inotify.max_user_watches = 524288
EOF
# Apply immediately without reboot:
sysctl -p /etc/sysctl.d/99-vps-tuning.conf
# Verify a key setting:
sysctl net.core.somaxconn
# Should show: net.core.somaxconn = 65535
What each group does and why it matters:
- somaxconn and tcp_max_syn_backlog: These control how many pending connections the kernel queues before dropping them. The default of 4096 sounds generous, but under a traffic spike, Nginx can fill that queue in seconds. Raising to 65535 means the kernel queues connections instead of silently dropping them, giving Nginx time to process the backlog. This is the tweak that prevents the "some requests randomly fail under load" problem.
- tcp_keepalive_time: Default 7200 seconds (2 hours) means dead connections linger for two hours consuming resources. Reducing to 300 seconds frees connection slots 24x faster.
- vm.swappiness: Default 60 means the kernel starts pushing pages to swap when 40% of RAM is still free. On a VPS where swap is 10-100x slower than RAM, this is actively harmful. Setting 10 means the kernel only swaps under genuine memory pressure.
- vm.dirty_ratio and dirty_background_ratio: Controls when the kernel flushes dirty (unwritten) pages to disk. Lower values mean smaller, more frequent flushes — reducing the I/O spikes that cause latency hiccups during write-heavy operations.
Tweak 4: vm.swappiness and Swap Management
We already set vm.swappiness=10 in the sysctl config above. But swap management deserves its own section because I see two common mistakes: people who disable swap entirely (dangerous) and people who never check whether swap exists (also dangerous).
# Check if swap exists:
swapon --show
free -h | grep Swap
# If "Swap: 0B 0B 0B" — you have no swap. Create one.
# Create a swap file (2GB for a 2GB RAM VPS, 4GB for 4GB+):
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
# Verify:
free -h | grep Swap
# Should show 2.0Gi total
# Monitor swap usage over time:
vmstat 5 12 | awk '{print NR": si="$7, "so="$8}'
# si (swap in) and so (swap out) should be 0 during normal operation
# Non-zero values = RAM is insufficient, consider upgrading
Do not disable swap entirely. The "just add more RAM" crowd has never been woken at 3 AM by the OOM killer murdering MariaDB during a traffic spike. A 2GB swap file costs nothing in storage and buys you the 30 seconds needed to SSH in and kill a runaway process before the OOM killer makes that decision for you — and it always picks the process you care about most.
Tweak 5: Nginx Worker and Buffer Tuning
The stock Nginx configuration works on everything from a Raspberry Pi to a 128-core server, which means it is optimized for neither. On a VPS serving web traffic, these settings typically improve throughput by 15-25%:
# Edit /etc/nginx/nginx.conf:
worker_processes auto; # One worker per CPU core
worker_rlimit_nofile 65535; # Match system file descriptor limit
events {
worker_connections 1024; # Per worker (4 workers × 1024 = 4096 max)
multi_accept on; # Accept all pending connections at once
use epoll; # Linux kernel event notification (fastest)
}
http {
# Kernel-level file transfer (bypasses userspace buffer copying)
sendfile on;
tcp_nopush on; # Batch TCP headers with sendfile
tcp_nodelay on; # Disable Nagle's algorithm (low latency)
# Keepalive settings
keepalive_timeout 65;
keepalive_requests 1000; # Requests per keepalive connection
# File descriptor caching (avoids repeated stat() calls)
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# Gzip compression
gzip on;
gzip_comp_level 5; # Sweet spot: 5 (1=fast/low, 9=slow/high)
gzip_min_length 256; # Don't compress tiny responses
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml image/svg+xml application/xhtml+xml;
gzip_vary on; # Proper cache handling for compressed content
# Buffer tuning (prevents disk buffering for typical requests)
client_body_buffer_size 128k;
client_max_body_size 64m;
client_header_buffer_size 1k;
large_client_header_buffers 4 4k;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
# Logging optimization (buffer log writes)
access_log /var/log/nginx/access.log combined buffer=512k flush=1m;
}
# Test and reload:
# nginx -t && systemctl reload nginx
The most impactful settings here are sendfile + tcp_nopush (kernel-level file transfer) and open_file_cache (avoids redundant filesystem calls). For sites serving many static files, these two alone reduce static asset latency by 20-30%.
Tweak 6: PHP-FPM Pool Sizing
This is where I see the most performance left on the table. Too few workers and requests queue. Too many and the OOM killer comes calling. The math is straightforward once you know your PHP process size:
# Check average PHP-FPM memory per process:
ps --no-headers -o rss -C php-fpm8.3 | awk '{ sum+=$1; n++ } END { print sum/n/1024 " MB avg, " n " processes" }'
# Typical: 35-50MB for WordPress, 50-80MB for Laravel/WooCommerce
# Formula:
# max_children = Available_RAM_for_PHP / Average_process_size
#
# 2GB VPS example:
# Total: 2048MB
# OS: -300MB, MariaDB: -768MB, Redis: -100MB, Nginx: -50MB
# Available for PHP: ~830MB
# avg process: 50MB
# max_children = 830 / 50 = 16
# Edit /etc/php/8.3/fpm/pool.d/www.conf:
pm = dynamic
pm.max_children = 16
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500 # Restart worker after 500 requests (prevents leaks)
pm.process_idle_timeout = 10s
# For high-traffic sites with predictable load, consider:
# pm = static
# pm.max_children = 16
# (Static avoids the overhead of spawning/killing workers)
# Apply:
systemctl reload php8.3-fpm
Also increase file descriptor limits for PHP-FPM:
# Edit /etc/security/limits.conf:
www-data soft nofile 65535
www-data hard nofile 65535
# Edit /etc/systemd/system/php8.3-fpm.service.d/override.conf:
# (Create the directory if it doesn't exist)
mkdir -p /etc/systemd/system/php8.3-fpm.service.d
cat > /etc/systemd/system/php8.3-fpm.service.d/override.conf <<'EOF'
[Service]
LimitNOFILE=65535
EOF
systemctl daemon-reload
systemctl restart php8.3-fpm
Tweak 7: I/O Scheduler and Filesystem Tuning
Two quick wins that together reduce disk I/O overhead by a meaningful margin.
I/O Scheduler: Most VPS instances run on NVMe storage. The Linux kernel's default I/O scheduler reorders requests to optimize seek time — which is relevant for spinning disks but counterproductive for NVMe, which has its own internal multi-queue optimization. Setting the scheduler to none removes kernel overhead that the drive does not need.
# Check current I/O scheduler:
cat /sys/block/sda/queue/scheduler 2>/dev/null || \
cat /sys/block/vda/queue/scheduler 2>/dev/null || \
cat /sys/block/nvme0n1/queue/scheduler 2>/dev/null
# Set for NVMe:
echo none | tee /sys/block/nvme0n1/queue/scheduler
# Set for SATA SSD:
echo mq-deadline | tee /sys/block/sda/queue/scheduler
# Make persistent with udev rule:
cat > /etc/udev/rules.d/60-scheduler.rules <<'EOF'
ACTION=="add|change", KERNEL=="nvme*", ATTR{queue/scheduler}="none"
ACTION=="add|change", KERNEL=="sd*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline"
ACTION=="add|change", KERNEL=="sd*", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="bfq"
EOF
Filesystem: noatime. Every time you read a file on Linux, the kernel writes the current timestamp back to the filesystem as "last access time." Every read generates a write. On a busy web server serving hundreds of static files per second, this is thousands of pointless disk writes per minute:
# Check current mount options:
mount | grep ' / '
# Add noatime to /etc/fstab (prevents access time writes on reads):
# Change: UUID=xxx / ext4 defaults 0 1
# To: UUID=xxx / ext4 defaults,noatime 0 1
# Apply without reboot:
mount -o remount,noatime /
# Verify:
mount | grep noatime
Bonus: Redis as Application Cache
Redis is not a kernel tweak, but it belongs in every performance tuning conversation. As an object cache for WordPress, Laravel, or any PHP application, Redis eliminates 60-80% of database queries by caching results in memory. The effect on TTFB is dramatic.
# Install Redis (if not already):
apt install -y redis-server
# Configure for VPS use (/etc/redis/redis.conf):
maxmemory 256mb # Limit RAM usage (adjust for your VPS size)
maxmemory-policy allkeys-lru # Evict least-recently-used keys when full
save "" # Disable disk persistence (pure cache)
appendonly no # Disable AOF (saves disk I/O)
bind 127.0.0.1 # Only accept local connections
unixsocket /run/redis/redis.sock # Unix socket = lower latency than TCP
unixsocketperm 770
# Restart and verify:
systemctl restart redis-server
redis-cli ping # PONG
# For WordPress: see the WordPress VPS migration guide for Redis Object Cache setup
# For Laravel: set CACHE_DRIVER=redis and SESSION_DRIVER=redis in .env
The WordPress migration guide covers the full Redis Object Cache setup, and the Docker guide shows how to run Redis in a container for isolation.
PostgreSQL Tuning
If you are running PostgreSQL instead of MySQL, the tuning philosophy differs. PostgreSQL relies heavily on the OS page cache, so you allocate less to shared_buffers (25% of RAM vs MySQL's 50-70%) and let the kernel cache the rest:
# Key settings in /etc/postgresql/16/main/postgresql.conf:
shared_buffers = 1GB # 25% of total RAM (for 4GB VPS)
effective_cache_size = 3GB # 75% of total RAM (tells query planner about OS cache)
work_mem = 8MB # Per sort/hash operation (be careful: connections × sorts)
maintenance_work_mem = 256MB # VACUUM, CREATE INDEX operations
wal_level = replica
checkpoint_completion_target = 0.9
max_connections = 100
# For connection pooling (strongly recommended for PostgreSQL):
# Install pgBouncer:
apt install -y pgbouncer
# Configure /etc/pgbouncer/pgbouncer.ini for transaction pooling
CPU Governor
Some providers ship VPS instances with the CPU governor set to "powersave" or "ondemand." On a server that is supposed to handle production traffic, this throttles clock speed to save the provider's electricity bill at the cost of your performance. I have seen this shave 15-20% off CPU benchmark scores:
# Check current governor:
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null
# If it says "powersave" or "conservative" — change it
# Set to performance (max clock speed always):
echo performance | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor 2>/dev/null
# Make persistent:
apt install -y cpufrequtils 2>/dev/null
echo 'GOVERNOR="performance"' > /etc/default/cpufrequtils 2>/dev/null
# Note: Many VPS hypervisors expose a fixed CPU frequency regardless of governor.
# If the file doesn't exist, your provider controls the frequency — nothing to do here.
Ongoing Monitoring
Tuning is not a one-time event. Traffic patterns change, databases grow, and what worked at 1,000 daily visitors may fall apart at 10,000. Set up monitoring now so you see problems before your users do.
# Quick diagnostic commands to run during load testing or traffic spikes:
vmstat 1 10 # CPU, memory, swap, I/O summary per second
iostat -x 1 5 # Disk utilization, %await, %util per device
sar -n DEV 1 5 # Network interface throughput
ss -s # Socket summary: established, TIME-WAIT counts
ss -tnp | wc -l # Count active TCP connections
# MySQL performance snapshot:
mysql -e "SHOW STATUS LIKE 'Threads_connected';"
mysql -e "SHOW STATUS LIKE 'Innodb_buffer_pool_read%';"
mysql -e "SHOW STATUS LIKE 'Slow_queries';"
# PHP-FPM status (enable status page in pool config first):
# In /etc/php/8.3/fpm/pool.d/www.conf:
# pm.status_path = /fpm-status
# Then: curl -s http://localhost/fpm-status
# For ongoing visibility, install Netdata (lightweight, one command):
curl https://get.netdata.cloud/kickstart.sh > /tmp/netdata-kickstart.sh
bash /tmp/netdata-kickstart.sh --dont-wait
# Access at http://your-vps-ip:19999
# Netdata uses ~50MB RAM and tracks every metric on this page automatically
For more comprehensive monitoring options, the VPS for developers guide covers Prometheus + Grafana setups. For a quick external check, set up UptimeRobot monitoring (free tier, 50 monitors, 5-minute checks).
The Combined Results
Here is the before-and-after from the client's $6/mo Vultr instance, with each tweak applied sequentially:
| Change | TTFB Before | TTFB After | Time to Apply |
|---|---|---|---|
| Baseline (no changes) | 410ms | — | — |
| 1. innodb_buffer_pool_size 128M → 768M | 410ms | 280ms | 3 min |
| 2. PHP OPcache enabled + configured | 280ms | 195ms | 4 min |
| 3. sysctl network tuning | 195ms | 178ms | 3 min |
| 4. vm.swappiness 60 → 10 | 178ms | 172ms | 1 min |
| 5. Nginx worker + buffer tuning | 172ms | 158ms | 4 min |
| 6. PHP-FPM pool sizing | 158ms | 148ms | 3 min |
| 7. noatime + I/O scheduler | 148ms | 138ms | 4 min |
| Total improvement | 410ms | 138ms | 22 min |
66% TTFB reduction. 22 minutes of work. Zero dollars spent. The first two tweaks (MySQL buffer pool and OPcache) accounted for over half the improvement. The network and I/O tweaks contributed the remainder. Every change was measured individually, and none were reverted because all produced measurable improvement.
Your numbers will differ based on your workload, provider, and starting configuration. But the pattern is consistent across every server I have tuned: the defaults leave 40-60% of performance on the table, and the top 3-4 tweaks capture 80% of the available gains.
Which Providers Respond Best to Tuning
Not all VPS instances benefit equally from tuning. The limiting factor is often the underlying hardware quality and hypervisor overhead, which varies significantly by provider. Based on my benchmark data:
- Vultr and DigitalOcean: Consistent NVMe performance, low steal time, responsive to every tweak in this guide. Tuning usually delivers the full 40-60% improvement.
- Hetzner: Outstanding baseline performance on their AMD EPYC hardware. NVMe is blazing fast. Sometimes less room for improvement because the defaults are already good, but buffer pool and OPcache still make a big difference.
- Kamatera: Good CPU performance, SSD (not NVMe on all plans). I/O scheduler tuning matters more here. Network tuning is effective.
- Contabo: Highest RAM-per-dollar but I/O performance is less consistent. Buffer pool tuning is extremely effective because the large RAM allocation gives you room. CPU steal time can be higher — watch the %st metric.
- RackNerd: Budget tier with variable disk performance. Tuning helps significantly but the ceiling is lower. I/O-bound workloads benefit most from scheduler and buffer tuning.
For detailed performance comparisons, the price comparison tool and benchmark database show how providers compare on CPU, disk, and network performance — the metrics that determine how much tuning headroom you have.
Frequently Asked Questions
What is CPU steal time and how do I check it?
CPU steal time measures the percentage of time your virtual CPU waits because the physical host CPU is busy with other VMs. Check with top (look at %st in the CPU line) or vmstat 1 10 (the st column). Under 2% is normal. 2-5% warrants monitoring. Above 5% consistently means noisy neighbors are stealing your compute — upgrade to a dedicated CPU plan or switch providers. No application tuning fixes stolen CPU cycles.
Should I disable swap on a VPS?
No. Set vm.swappiness=10 instead of disabling swap. Swap is your last line of defense against the OOM killer, which will terminate your database or web server processes when RAM is exhausted. A 2GB swap file costs nothing in storage and buys you the 30 seconds needed to intervene during a traffic spike. The goal: never use swap during normal operation, but have it available as emergency overflow.
How do I know if my Nginx worker_processes is correct?
Set worker_processes auto; — Nginx creates one worker per CPU core. On a 4 vCPU VPS, that is 4 workers. Set worker_connections 1024; per worker for 4,096 total concurrent connections. Check the error log for "worker_connections are not enough" messages. If you see them, increase worker_connections before adding workers. Monitor connection counts with ss -s during peak traffic.
What is the single most impactful MySQL setting to tune?
innodb_buffer_pool_size. Set it to 50-70% of available RAM. On a 4GB VPS with ~2GB available for MySQL: set to 1.5G. The default (128MB) forces most queries to read from disk. A properly sized buffer pool caches your entire database in memory, reducing query times by 5-10x. Verify the hit rate: SHOW STATUS LIKE 'Innodb_buffer_pool_read%'; — target above 99%.
How do I verify that tuning actually helped?
Benchmark before and after each individual change. CPU: sysbench cpu --threads=4 run. Disk: fio with random read pattern. Web: curl -o /dev/null -s -w '%{time_starttransfer}' https://yourdomain.com (run 10x, compare medians). Compare p95 response times, not averages — averages hide tail latency. If a change does not measurably improve the benchmark, revert it. Keep a log: tweak, metric, before, after.
Does PHP OPcache make a real difference?
A dramatic one. OPcache stores compiled PHP bytecode in memory, skipping the parse-compile steps on every request. For WordPress (200+ PHP files per page load), this typically reduces PHP execution time by 3-5x. Set opcache.memory_consumption=256, opcache.max_accelerated_files=10000, and opcache.revalidate_freq=60. The JIT compiler in PHP 8.2+ adds another 10-20% for compute-heavy operations. Check hit rate by monitoring opcache_get_status() — target above 99%.
What I/O scheduler should I use for NVMe?
Use none (also called noop). NVMe drives have internal multi-queue optimization that outperforms any kernel I/O scheduler. The default scheduler adds overhead by reordering requests the drive handles better itself. For SATA SSDs: use mq-deadline. For spinning disks (rare on VPS): use bfq. Check current scheduler with cat /sys/block/nvme0n1/queue/scheduler. Make it persistent with a udev rule.
How much improvement can I realistically expect?
On a default-configured VPS running a web application: 40-60% TTFB improvement from the combination of all tweaks. The biggest gains come from innodb_buffer_pool_size (if the database was disk-bound) and OPcache (if not enabled). I have seen cases as dramatic as 410ms to 138ms. Diminishing returns set in after the top 5-7 tweaks. If tuning gets you to within 20% of your target and you still need more, that is when upgrading the plan makes sense.
Is it worth tuning a $6/mo VPS or should I just upgrade?
Tune first. Always. Most VPS instances operate at 30-40% of their potential because every setting uses conservative defaults. Thirty minutes of tuning delivers performance equivalent to a plan costing twice as much. Only upgrade after confirming via monitoring that a specific resource (CPU, RAM, or disk I/O) is the genuine bottleneck. Upgrading before tuning is like buying a faster car with the parking brake on.