
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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).

