free -h shows swap almost full, and a reboot fixes it for a few days before it creeps back. The usual question is whether to buy more RAM. Before you do, it helps to know there are three common causes that look identical from the outside: an application slowly leaking memory, a plan that is simply too small for what runs on it, or a cryptominer someone installed through a vulnerable site or a weak password. Each one leaves different fingerprints, and you can find them with tools already on the server.
This is also one of the more common misreads at a support desk. A customer sees high swap and assumes the server is broken or oversold, when the answer is sitting in a few lines of /proc.
Swap in use is not the same as swap pressure
vmstat shows it directly in the si (swapped in) and so (swapped out) columns, in kilobytes per second.
BASH
# Totals: RAM, swap, and the 'available' column (memory usable without swapping)
free -h
# Which swap devices or files exist and how full each one is
swapon --show
# Sample every 5 seconds, 12 times. Watch the si and so columns.
vmstat 5 12
# How eager the kernel is to swap (higher means more willing)
cat /proc/sys/vm/swappinessvmstat; it is an average since boot. If si and so sit near zero across the run, the swap is mostly cold pages and the slowdown is probably coming from somewhere else, such as CPU or disk I/O. If they stay non-zero while the site is under normal load, the server genuinely does not have enough RAM for its current working set, and the next step is finding out who that working set belongs to. Save this output; you will compare against it after any fix.
Find out which processes actually live in swap
VmSwap in /proc/PID/status. Most top setups do not show it by default, so it is easier to read it directly and sort.
BASH
# Top 15 processes by swapped-out memory (kB). Kernel threads have no VmSwap line and are skipped.
for f in /proc/[0-9]*/status; do
awk '/^Name:/{n=2} /^VmSwap:/{print2, n, FILENAME}' $f 2>/dev/null
done | sort -rn | head -15
# To confirm a suspected leak, record one process over time (replace 1234 with the real PID)
PID=1234
while true; do
date '+%F %T'
grep -E '^(VmRSS|VmSwap):' /proc/$PID/status
sleep 300
done/proc path, which contains the PID. On a typical web VPS you expect to see names like mysqld or mariadbd, php-fpm, lsphp, httpd, nginx, or a Node or Java process near the top. That alone is useful: it tells you which service to look at instead of guessing.
If the numbers here add up to noticeably less than what free reports as swap used, that is not necessarily a bug in your math. Pages from shared memory and tmpfs mounts such as /dev/shm can be swapped out without being charged to any single process. A large unexplained gap is worth a look at what is stored in /dev/shm, which also happens to be a favorite place for malware to drop files.
Telling a leak from a plan that is simply too small
VmRSS is the part of the process in RAM, VmSwap the part on disk, and together they approximate how much memory the process is holding. A leak looks like that sum climbing steadily and never coming back down, even through quiet periods, then dropping to a normal level when you restart only that service. Long-running workers in PHP-FPM, application servers, and some caching daemons are typical places to see it. If restarting one service gives you days of relief and nothing else changes, that is strong evidence.
An undersized plan looks different. No single process grows without limit. Instead, memory rises with traffic and falls back when traffic drops, and the total working set simply does not fit. The usual culprits are configuration values that were never sized for the plan: too many PHP-FPM children, a database buffer pool set larger than the RAM can really spare, or several sites each with their own cache and worker pool. Multiply the per-worker memory you saw in the ranked list by the maximum worker count in your config, add the database, and compare that against total RAM. If the math does not fit, tuning or resizing is the honest answer.
Swappiness is the knob people reach for first, and it rarely solves either case. Lowering it with sysctl vm.swappiness=10 (temporary until reboot) makes the kernel prefer dropping file cache before swapping out application memory. That can smooth out a server that is only slightly short on RAM. It does nothing for a leak, and on a server that is truly undersized it can push you toward the OOM killer instead of a slow swap. Change it only after the measurements, and re-run vmstat to see whether si and so actually fell.
What a miner looks like from the command line
systemd helpers, or php-fpm), running from writable temp directories, deleting the binary after launch, and reinstalling themselves from cron. These checks are all read-only:
BASH
# Heaviest CPU users with owner, parent, runtime, and full command line
ps -eo pid,ppid,user,%cpu,%mem,etime,args --sort=-%cpu | head -15
# Where does a suspicious PID really run from? (replace 1234)
ls -l /proc/1234/exe
tr '\0' ' ' < /proc/1234/cmdline; echo
# Any process whose binary was deleted or lives in a temp directory
ls -l /proc/[0-9]*/exe 2>/dev/null | grep -E 'deleted|/tmp/|/var/tmp/|/dev/shm/'
# Established outbound TCP connections with owning process (run as root)
ss -tnp state established
# Common persistence spots
for u in (cut -d: -f1 /etc/passwd); do crontab -l -uu 2>/dev/null | sed 's/^/'$u': /'; done
ls -la /etc/cron.d /var/spool/cron 2>/dev/null
systemctl list-timers --all/proc/PID/exe usually cannot. If something calling itself a kernel thread has an exe link pointing to /tmp/.x/something, it is not a kernel thread; real kernel threads have no exe target at all. Show (deleted) results some care, though: a legitimate service that was not restarted after a package upgrade also shows its old binary as deleted. Check its path and your package history before drawing conclusions.
In the ss output, look for long-lived connections from processes owned by your web user (www-data, apache, nobody, or a cPanel account) to IPs and ports your applications have no reason to contact. Web server workers normally answer inbound connections; they rarely hold outbound sessions open for hours.
If ps shows nothing odd but CPU stays pegged and memory does not add up, the process listing itself may be lying. Rootkits that hook process tools through the dynamic linker are covered in Checking /etc/ld.so.preload to Confirm Hidden Malware Processes, and that check belongs here before you trust any of the output above.
Deciding: tune, resize, or treat it as an incident
| What you measured | Likely cause | Next step |
|---|---|---|
| One process's RSS + swap climbs steadily and resets on restart | Memory leak | Fix or update the application; schedule worker recycling as a stopgap |
| Memory follows traffic, no single grower, worker math exceeds RAM | Undersized plan or oversized config | Lower worker and buffer limits to fit, or resize the plan |
| Unknown high-CPU process, temp or deleted binary, odd outbound connections | Compromise | Incident response, not tuning |
ps, ss, and /proc/PID/exe output, the file paths, and the cron entries. Then find the entry point, rotate every credential the attacker could have seen, and seriously consider rebuilding from a known-clean backup rather than cleaning in place. If the miner ran as root, assume the whole server is untrustworthy.
Whichever path you take, finish where you started. Run the same free, vmstat, and per-process swap commands again under similar load and compare them to the baseline you saved. If si and so are back near zero and no process is creeping upward, the fix worked. If not, the numbers will tell you which of the three causes you have not ruled out yet.
The practical rule: do not upgrade RAM or touch swappiness until you have watched si/so, ranked processes by VmSwap, and spent two minutes checking for a process that should not exist. Resizing a compromised server only gives the miner more room.