
Most “slow WordPress” tickets aren’t really about WordPress. They usually trace back to a VPS with low CPU headroom, a PHP-FPM pool sized by guesswork, or Nginx sending too many requests to PHP. This VPS performance optimization tutorial walks through a practical tuning workflow on Ubuntu with Nginx + PHP-FPM.
Each step includes checkpoints you can re-run. That lets you confirm what actually improved performance.
This guide assumes one VPS hosts one site, or a small handful of WordPress installs. The same workflow applies to a dedicated server. You just adjust the numbers.
If you want a predictable baseline without paying for unused capacity, start with a HostMyCode VPS. If you prefer not to manage patching and core services, managed VPS hosting is often a better fit for business-critical sites.
What you’ll optimize (and how you’ll measure it)
Before you touch configs, define what “faster” means for your site. On VPS hosting, these metrics map well to user experience and server health:
- TTFB (Time to First Byte): aim for ~200–400ms on cached pages on a correctly sized VPS.
- 95th percentile response time: the slow tail users notice during spikes.
- CPU steal / iowait: shows whether you’re contended (steal) or storage-bound (iowait).
- Cache hit rate: how often requests skip PHP and the database entirely.
Grab a baseline from your laptop (or a monitoring host). You’ll compare everything to this later:
curl -s -o /dev/null -w "ttfb=%{time_starttransfer} total=%{time_total}\n" https://example.com/
For light load testing (don’t point this at a busy production store at noon):
sudo apt update && sudo apt install -y wrk
wrk -t4 -c40 -d30s https://example.com/
If you need a safe place to test changes before touching production, use the staging workflow in this staging server tutorial.
Step 1 — Confirm the bottleneck with 10-minute diagnostics
Don’t tune on instinct. First, confirm where the VPS is actually struggling.
1) Check memory pressure and swap thrashing
free -h
vmstat 1 10
Focus on si/so in vmstat. If swap-in/out spikes under load, PHP and MySQL can feel “randomly slow.” They’re often just waiting on disk.
2) Check CPU and iowait
sudo apt install -y htop sysstat
htop
iostat -xz 1 10
If iowait stays high, you’re storage-bound. Common causes include logs, backups, or database writes.
If CPU is pegged while iowait is low, you’re compute-bound. That’s usually PHP, and sometimes aggressive bot traffic.
3) Spot the worst Nginx requests
On Ubuntu with Nginx, access logs are typically in /var/log/nginx/access.log. For a quick first pass, look for requests associated with upstream time:
sudo awk '$NF ~ /upstream_response_time/ {print}' /var/log/nginx/access.log | head
This isn’t perfect yet (we’ll add proper timing logs later). Still, if you’re seeing lots of /wp-admin/admin-ajax.php or /?wc-ajax=, assume PHP is doing too much work per request.
If your VPS security baseline isn’t done, handle that before performance tuning. A compromised server burns resources in ways that look like “mystery slowness.”
Use this Ubuntu hardening tutorial and come back.
Step 2 — Nginx: serve static assets fast and stop wasting PHP
Nginx should serve static files directly. It should send as little traffic to PHP as possible.
Every request that avoids PHP reduces CPU load. It also makes traffic spikes easier to absorb.
Enable sane gzip and static caching headers
Edit your site config (commonly /etc/nginx/sites-available/example.com) and add a static assets location block:
location ~* \.(?:css|js|jpg|jpeg|png|gif|svg|ico|webp|avif|ttf|woff|woff2)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
access_log off;
try_files $uri =404;
}
This reduces repeat bandwidth. It also keeps PHP out of the loop for assets.
Even with a CDN, keep these headers. They improve origin behavior and make caching more reliable.
Fix “try_files” for WordPress routing (avoid extra rewrites)
Your main server block should use a clean front controller:
location / {
try_files $uri $uri/ /index.php?$args;
}
Avoid complicated rewrite chains unless you have a clear reason. They add overhead. They also make slow-URL debugging harder.
Raise file descriptor limits for busy sites
Higher traffic sites often hit worker connection ceilings or file descriptor limits first. In /etc/nginx/nginx.conf:
worker_processes auto;
worker_rlimit_nofile 200000;
events {
worker_connections 8192;
multi_accept on;
}
Then make sure system limits match. For systemd Nginx, create an override:
sudo systemctl edit nginx
Add:
[Service]
LimitNOFILE=200000
Reload:
sudo nginx -t && sudo systemctl reload nginx
If you want a curated Nginx baseline (including security defaults), cross-check against this Nginx setup and tuning guide.
Step 3 — PHP-FPM: right-size pools so you don’t melt RAM
PHP-FPM tuning usually drives the biggest VPS performance gains. Too few workers creates queues and slow TTFB under load.
Too many workers causes memory exhaustion, swap churn, and instability during spikes.
Find your PHP-FPM pool config
On Ubuntu, pools typically live at:
/etc/php/8.3/fpm/pool.d/www.conf(PHP 8.3 is common in 2026)/etc/php/8.4/fpm/pool.d/www.conf(if you’re on PHP 8.4)
Confirm what’s running:
php -v
sudo systemctl status php*-fpm --no-pager | head
Measure average PHP worker memory
Under normal traffic, sample worker RSS:
ps -ylC php-fpm8.3 --sort:rss | awk 'NR==1{print} NR<=15{print}'
Use the RSS column as your input. For WordPress with common plugins, 60–140MB per worker is typical.
Your site’s measurement is the only number that matters.
Set pm settings using real numbers
Edit the pool file. For most WordPress sites, start with pm = dynamic:
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500
Pick pm.max_children based on available memory and your measured RSS:
- Estimate memory you can safely give PHP: total RAM minus the OS, Nginx, and the database. On a 4GB VPS running local MariaDB, allocating ~1.5–2.2GB to PHP is often realistic.
- Divide by the per-worker RSS you measured. Example: 2GB / 100MB ≈ 20 workers.
pm.max_requests forces worker recycling. That helps limit memory bloat from long-running plugins.
300–1000 is a practical range.
Turn on PHP-FPM status for fast troubleshooting
In the pool config:
pm.status_path = /fpm-status
ping.path = /fpm-ping
Then expose it to localhost only in your Nginx site:
location = /fpm-status {
allow 127.0.0.1;
deny all;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location = /fpm-ping {
allow 127.0.0.1;
deny all;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
Reload services:
sudo php-fpm8.3 -t && sudo systemctl reload php8.3-fpm
sudo nginx -t && sudo systemctl reload nginx
Check status locally:
curl -s http://127.0.0.1/fpm-status | head
If you see “max children reached” during spikes, only raise pm.max_children if you have RAM headroom.
If you don’t have headroom, fix caching first. Also reduce heavy plugin work where you can.
Step 4 — Add microcaching to cut PHP load (without breaking carts)
For WordPress, a small Nginx microcache can dramatically reduce PHP load for anonymous traffic.
The rule is simple: cache public pages, and bypass anything tied to accounts, carts, or admin actions.
Create a cache zone
In /etc/nginx/nginx.conf (inside http {}):
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=WORDPRESS:100m inactive=60m max_size=2g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
Create the directory and set permissions:
sudo mkdir -p /var/cache/nginx/fastcgi
sudo chown -R www-data:www-data /var/cache/nginx
Configure cache bypass rules
In your site config, set a variable to skip cache for sensitive cases:
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
# WordPress admin and logged-in users
if ($request_uri ~* "/wp-admin/|/wp-login.php|/xmlrpc.php") { set $skip_cache 1; }
if ($http_cookie ~* "wordpress_logged_in|comment_author|woocommerce_items_in_cart|woocommerce_cart_hash") { set $skip_cache 1; }
Enable fastcgi_cache in the PHP location
Inside your PHP handler (often location ~ \.php$):
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 301 302 60s;
fastcgi_cache_use_stale updating error timeout invalid_header http_500 http_503;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-Cache $upstream_cache_status;
Reload Nginx and test:
sudo nginx -t && sudo systemctl reload nginx
curl -I https://example.com/ | grep -i x-cache
On repeated requests to the same anonymous page, you want X-Cache: HIT.
If you run WooCommerce, test add-to-cart, checkout, and My Account flows immediately after enabling cache.
If you want a deeper microcache + purge workflow, follow this Nginx caching tutorial.
Step 5 — TLS and HTTP/2/3: remove easy overhead
TLS settings rarely explain multi-second TTFB. Still, they can add latency on mobile networks. They can also increase CPU overhead during handshakes.
Use modern protocols and session resumption
In your HTTPS server block:
listen 443 ssl http2;
# HTTP/3 requires QUIC-enabled builds; enable only if your stack supports it.
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
ssl_session_tickets off;
Keep cipher suites current by relying on distro defaults where possible. Also keep OpenSSL patched.
If you need an end-to-end certificate workflow, use this SSL deployment tutorial.
Step 6 — WordPress-level fixes that pay off on a VPS
Server tuning won’t rescue a site with runaway cron jobs, a cache plugin that never hits, or oversized images.
You don’t need a rebuild. You need a few high-discipline fixes that remove predictable waste.
Move WP-Cron to a real cron job
On busy sites, wp-cron.php can fire too often. Under traffic, those runs can stack up.
Disable WP-Cron in wp-config.php:
define('DISABLE_WP_CRON', true);
Then add a system cron (as root) to run every 5 minutes:
sudo crontab -e
*/5 * * * * curl -sS https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
This makes cron predictable. That usually makes load more predictable too.
Fix admin-ajax hot paths
If you see frequent admin-ajax.php calls, find the plugin or theme feature behind them. A quick starting point:
- Use browser DevTools → Network to find repeating AJAX requests.
- Disable suspected plugins on staging and re-check load and response times.
On a VPS, one misbehaving plugin can consume an entire core. When that happens, everything else starts queueing.
Step 7 — Logging that tells you what’s slow (without flooding disk)
Default logs work for “what happened.” They’re weaker for “what was slow.”
Add timing fields you can sort, keep retention tight, and don’t let logging become your next bottleneck.
Add an Nginx log format with upstream timings
In /etc/nginx/nginx.conf inside http {}:
log_format timed '$remote_addr - $host [$time_local] "$request" '
'status=$status bytes=$body_bytes_sent '
'rt=$request_time urt=$upstream_response_time '
'uaddr=$upstream_addr cache=$upstream_cache_status ref="$http_referer" ua="$http_user_agent"';
Then in your site config:
access_log /var/log/nginx/access-timed.log timed;
Reload Nginx and inspect slow requests:
sudo nginx -t && sudo systemctl reload nginx
sudo awk '$0 ~ /rt=/ { for (i=1;i<=NF;i++) if ($i ~ /^rt=/) print $i, $0 }' /var/log/nginx/access-timed.log | sort -nr | head
Keep log growth sane
Confirm logrotate is active:
sudo systemctl status logrotate.timer --no-pager
If your access logs grow unusually fast, assume bots.
Rate limiting often saves CPU without touching PHP; see this rate limiting tutorial.
Step 8 — A practical tuning checklist (use this during change windows)
- Baseline: capture
curlTTFB, a 30swrkrun, andhtopscreenshots (or notes). - Nginx: static cache headers, clean
try_files, adequate worker limits. - PHP-FPM: set
pm.max_childrenbased on measured RSS; enable/fpm-statusfor visibility. - Caching: enable microcache with careful bypass rules; verify WooCommerce flows.
- Cron: disable WP-Cron and schedule a system cron.
- Logs: add upstream timing log format; confirm log rotation.
- Re-test: repeat the same baseline commands and compare.
Troubleshooting patterns (what the symptoms usually mean)
TTFB is slow, CPU is high, iowait is low
- PHP-FPM max children too low (queueing) or plugins doing heavy work.
- Microcache missing or bypassing too often.
- Object cache not configured (Redis/Memcached) — optional, but useful for large sites.
Site is fast until traffic spikes, then everything collapses
- Memory exhaustion causing swap storms.
- Nginx worker connections too low, or ulimit too low.
- Bot traffic hammering uncached endpoints (search, XML-RPC, admin-ajax).
Random slowness with high iowait
- Backups running during peak hours (move them).
- Log volume too high (tighten logging, add rate limiting, block bad bots).
- Disk is saturated (upgrade storage or move heavy writes off the box).
Where HostMyCode fits in this workflow
For a tuned WordPress VPS, consistency beats cleverness. You want predictable CPU scheduling, fast storage, and a clear upgrade path when traffic grows.
That’s the point of a HostMyCode VPS. If you’d rather hand off patching, service checks, and baseline hardening, choose managed VPS hosting. It keeps your focus on the site—not the maintenance queue.
If you’re seeing slow TTFB or CPU spikes, start with a clean baseline and work this plan step by step on a VPS sized for your traffic. HostMyCode offers VPS hosting for hands-on admins and managed VPS hosting when you want the stack maintained alongside you.
FAQ
How do I choose the right VPS size before I start tuning?
For a typical WordPress site with caching, 2 vCPU / 2–4GB RAM is a common starting point. WooCommerce, heavy page builders, and large plugin stacks usually need 4 vCPU and 4–8GB RAM to avoid PHP queueing.
Should I use Nginx microcaching if I already have a WordPress cache plugin?
You can, but avoid double-caching confusion. Many admins either use Nginx microcache for anonymous traffic or rely on a WordPress page cache. If you use both, validate cache headers and bypass rules carefully.
What’s the quickest way to tell if PHP-FPM is underprovisioned?
Enable /fpm-status (localhost only) and watch for “max children reached” and rising listen queue. If you see both under load, PHP workers are the immediate constraint.
Will switching PHP versions improve performance?
Sometimes. The bigger gains usually come from caching and right-sizing PHP-FPM. If you change PHP versions (8.3 to 8.4), do it on staging first and re-run your baseline tests.
My VPS is tuned but email and site performance still degrade together. Why?
Mail queues and web traffic compete for CPU, disk, and network. If you run mail on the same VPS, check queue health and deferred messages. Start with this mail queue troubleshooting tutorial.
Summary
Performance work goes faster if you treat it like a loop: measure, change one thing, measure again.
On WordPress VPS hosting, the highest-impact wins are usually static asset caching, PHP-FPM pool sizing based on real memory use, and microcaching with careful bypass rules.
Once those are solid, you spend less time firefighting and more time improving the site.
If you want a stable platform for this setup (plus an upgrade path as traffic grows), start with a HostMyCode VPS or move to managed VPS hosting when you want the operating tasks handled for you.