TROUBLESHOOTING
July 27, 2026

How to Fix High CPU Usage on a Web Server

5 min read
Author
CloudStick Team
Server Infrastructure
Share this article
How to Fix High CPU Usage on a Web Server
CloudStick
High CPU Usage

Find The Culprit Process

Before you touch any config, identify what is actually eating the CPU. Run top or htop sorted by the %CPU column, or if you just need a fast, non-interactive snapshot over a flaky SSH session, run ps aux --sort=-%cpu | head -15. Whichever process sits at the top, sustained above 90-100%, is your starting point.

On a shared server with multiple sites, note the process owner (the username column), not just the binary name. Because CloudStick runs every site under its own system user with a dedicated PHP-FPM pool, the owning username tells you exactly which website is responsible before you dig any further — no guessing which of twenty WordPress installs is at fault.

$ ps aux --sort=-%cpu | head -15
USER PID %CPU %MEM COMMAND
siteuser1 8842 97.3 4.1 php-fpm: pool siteuser1
mysql 1122 41.0 12.4 /usr/sbin/mysqld
siteuser2 9013 6.2 1.8 php-fpm: pool siteuser2

Check MySQL Queries

A single unoptimized or missing-index query is the single most common cause of sustained CPU load on a WordPress or PHP web server. If mysqld shows up high in the process list, check what it is actually executing with SHOW FULL PROCESSLIST or the equivalent mysqladmin shortcut, which doesn't require a MySQL shell session.

Look at the Time column (seconds the query has been running) and the State column. A query stuck in "Sending data" for 10+ seconds on a table that should return instantly is almost always doing a full table scan because a WHERE clause or JOIN column has no index. A wp_postmeta lookup by meta_key without an index, for example, will happily scan millions of rows on every page load.

$ mysqladmin -u root -p processlist
+-----+------+-----------+----------+---------+------+--------------+-------------------------+
| Id | User | Host | db | Command | Time | State | Info |
| 812 | wpdb | localhost | wp_prod | Query | 14 | Sending data | SELECT * FROM wp_postm..|
+-----+------+-----------+----------+---------+------+--------------+-------------------------+

Any single query that shows the same "Time" value climbing every time you re-run the processlist command is not slow — it's stuck. That's your CPU spike, and it's almost always fixable with one index.

Hunt A Plugin Or Bot

If MySQL comes back clean, the next suspect is traffic pattern, not the query itself. Two things cause this constantly: a WordPress plugin firing an expensive job on every single request (unbounded WP-Cron tasks, badly written analytics hooks, image-processing on page load), and a bot or scraper hammering one expensive, unauthenticated endpoint like /?s= search, /wp-json/wp/v2/, or xmlrpc.php.

Count requests by source IP straight from the Nginx access log with awk. If one IP (or a narrow subnet) is responsible for a disproportionate share of hits, especially against a search or REST endpoint, that's your load generator, not your visitors.

$ awk '{print $1}' /home/siteuser1/logs/mysite/nginx-cs/access.log \
| sort | uniq -c | sort -rn | head -20
14832 185.220.101.44
2110 66.249.66.1
398 51.15.79.20

Pair that with which URL that IP is actually requesting: grep '185.220.101.44' access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head. If it's slamming /?s=random-word thousands of times a minute, every one of those hits triggers a full, uncached database search — that's your CPU spike explained.

Diagnose PHP-FPM

An under-provisioned PHP-FPM pool causes CPU churn even without a bad query or a bot, because the pool constantly kills and respawns worker processes to keep up with concurrent requests. Each fork-and-initialize cycle costs CPU on its own, on top of whatever the request itself needs.

Check the pool's pm.max_children setting against available RAM and concurrent traffic. On a CloudStick server, the pool config lives at /etc/php83cs/fpm-pools.d/<sitename>.conf (versioned by whichever PHP build the site uses, e.g. php83cs), with the socket at /run/<sitename>.sock. If max_children is set too low relative to traffic, requests queue and workers thrash; too high on constrained RAM and you'll swap instead, which looks identical from the CPU graph but has a different fix (more RAM or a lower ceiling, not a higher one).

Use Strace

When a process is pegging CPU and neither the process list, MySQL, nor the access logs explain why, attach strace directly to the PID as a last resort to see exactly what syscalls it's stuck making. This is invasive and briefly adds overhead, so use it only after the cheaper checks come up empty.

$ strace -p 8842 -f -tt -o /tmp/trace.log
14:22:01.118422 read(11, "...", 8192) = 8192
14:22:01.118501 read(11, "...", 8192) = 8192
14:22:01.118577 read(11, "...", 8192) = 8192

A tight loop of the same syscall repeating thousands of times a second — reading the same file, retrying the same socket connect, spinning on a lock — tells you the process is stuck in a loop rather than doing legitimate work, which points at application code or a hung dependency (a slow external API call, a deadlocked cache connection) rather than raw traffic volume.

Apply The Fix

The fix depends on which culprit you found, and most of the time it's one of three things: add the missing database index, rate-limit the abused endpoint at Nginx, or block the abusive IP outright.

For a slow query, add an index on the column used in the WHERE or JOIN clause the processlist flagged. For an abused endpoint, add a limit_req zone to the site's vhost — on a CloudStick server that config lives at /etc/nginx-cs/vhosts.d/<site>.conf rather than the system /etc/nginx/ path. For a specific abusive IP, block it at the firewall with CSF so it never reaches PHP-FPM at all.

-- Add the missing index MySQL was scanning without:
ALTER TABLE wp_postmeta ADD INDEX meta_key_idx (meta_key(191));
# In /etc/nginx-cs/vhosts.d/mysite.conf, inside the server block:
limit_req_zone $binary_remote_addr zone=search:10m rate=5r/m;
location / { limit_req zone=search burst=3 nodelay; }
# Block the abusive IP at the firewall level:
csf -d 185.220.101.44 "CPU abuse on search endpoint"

After a config change, reload Nginx (not a hard restart, so existing connections aren't dropped) and watch the CPU graph for the next few minutes to confirm the spike actually flattens rather than shifting to a different endpoint.

Correlate With Server Stats

The fastest way to shortcut all of the above is knowing exactly when the spike started, then jumping straight to the access log entries at that timestamp instead of scrolling through hours of noise. CloudStick's Server Stats panel graphs real-time CPU, RAM, and disk usage from the Zabbix agent already running on every server, so you can pinpoint the exact minute usage jumped from 15% to 95% without SSHing in first.

Once you have that timestamp, filter the site's access log to the same minute and you'll usually see the culprit immediately — a burst of hits from one IP, a sudden run of requests to /wp-json/, or a single slow page being requested repeatedly. Fix the specific query, endpoint, or IP as covered above, then keep an eye on Server Stats for the next hour to confirm the graph stays flat instead of spiking again on a delay (a common sign of a cron job you haven't found yet).

Leave a comment
Full Name
Email Address
Message
Contents