Why Your Cheap VPS Keeps Dying: Memory, Swap and the OOM Killer

Why Your Cheap VPS Keeps Dying: Memory, Swap and the OOM Killer

A WordPress site on the cheapest tier available dies every few days, always in the small hours, always the same way. The page returns a database connection error, MySQL is stopped, and starting it by hand buys another two or three days. The host gets blamed, a support ticket gets opened, and the reply is usually one line: check the kernel log for the OOM killer. It is there, plain as anything, recording that the kernel killed the database in the middle of the night to keep the rest of the system alive. That detail is worth sitting with. Nothing had crashed. Something made a deliberate decision, wrote it down, and carried on. Once you can read that decision and see why it keeps repeating, the box turns boring, which is the highest compliment a server can be paid. Swap, arithmetic and smaller worker counts get you there.

the cheapest tier is a memory problem, not a CPU problem

Entry-level VPS plans are sized for the plan sheet, not for the stack most people install on them. A 1 GB box sounds fine until you count what’s actually resident: the kernel and systemd, a web server, a PHP or Python application with several worker processes, and a database engine that would quite like to cache your working set in RAM. Each of those is modest. Together they overrun.

MySQL is usually the biggest single consumer, and its defaults assume a larger machine. PHP-FPM and Gunicorn compound it in a way that surprises people, because each worker is a separate process holding its own interpreter and loaded modules. Ten workers is not one process using slightly more memory. It’s ten processes.

The Indian angle is that the regional data centres worth using for latency often start their ladder at the tier that’s too small for a database-backed site. If you’re still choosing, my comparison of hosting providers covers what each tier gives you. Spend on RAM before anything else; CPU is rarely the constraint on a small site.

reading free -h

Terminal running free -h on a 1 GB VPS: 957Mi total, 612Mi used, 71Mi free, 274Mi buff/cache and 201Mi available, with swap at 318Mi used of 2.0Gi.
The free figure is not your headroom. Read available: it already counts the cache Linux will give back.
Stacked bar of 957 MiB: 612 used, 274 buffer and cache, 71 free. Available memory of 201 MiB is free plus reclaimable cache.
Most of the ‘missing’ memory is disk cache, which the kernel returns on demand.
free -h

#               total  used   free   shared  buff/cache  available
# Mem:          957Mi  612Mi   71Mi    18Mi       274Mi      201Mi
# Swap:         2.0Gi  318Mi  1.7Gi

Almost everyone reads the wrong column. The free figure looks alarming, 71 MiB out of nearly a gigabyte, and people conclude they’re about to fall over. They’re not, necessarily, and the reason is the next column.

buffers and cache are not waste

Linux uses memory that nothing is asking for to cache disk contents. That’s not the system hoarding, it’s the system doing its job, and it makes your disk reads dramatically cheaper. The moment an application genuinely needs that memory, the kernel drops cache pages and hands it over. Free RAM sitting idle is RAM you paid for and aren’t using.

So available is the column that matters. It’s the kernel’s own estimate of how much a new process could claim without pushing the system into swapping. In the output above, roughly 200 MiB of headroom on a box that’s already using swap. Not a crisis, not comfortable either.

Then find out who’s actually holding memory, in descending order:

# resident set size per process, biggest first, in kilobytes
ps -eo pid,user,rss,comm --sort=-rss | head -12

# per-service totals, which is more useful than per-process
systemd-cgtop -m --iterations=1

# how much a unit is using right now
systemctl status mysql | grep -i memory

the OOM killer, and proving it acted

Flow: an allocation fails, there is no swap left, the kernel scores every process, kills the biggest one which is usually MySQL, and records it in the kernel log.
The OOM killer sacrifices one process to keep the machine reachable, then leaves a record you can read.
Terminal with three kernel log searches for OOM kills: dmesg -T with grep, journalctl -k for a time window, and journalctl for the previous boot.
Check the kernel log before blaming the host. The OOM killer always leaves a line behind.

When the kernel cannot satisfy an allocation and has no swap left to fall back on, it does not return an error to the process and it does not freeze. It picks a victim and kills it. The choice is scored roughly by how much memory each process is using, weighted by a tunable adjustment, so the biggest consumer usually loses. On a small web server the biggest consumer is your database, which is why the symptom is so often “MySQL keeps stopping”.

This is a rational design. The alternative is the machine locking up entirely, unreachable by SSH. Sacrificing one process keeps the box alive and leaves a written record, and here’s how to read it:

# human-readable timestamps, which dmesg does not give you by default
sudo dmesg -T | grep -i -E 'out of memory|killed process|oom'

# same thing from the journal, scoped to a window you care about
sudo journalctl -k --since "2026-08-05 01:00" --until "2026-08-05 04:00" | grep -i oom

# across reboots, if the box has persistent journals
sudo journalctl -k --list-boots
sudo journalctl -k -b -1 --grep 'Out of memory'

A hit names the process, its PID, and how much anonymous memory it held when it died. That line is the difference between guessing and knowing, which is why the kernel log deserves a look before you accept that a host’s network was at fault. Pinning a reported time to the right log window is covered in reading server logs.

adding a swapfile, step by step

Six steps to add a swapfile: check with swapon --show, allocate with fallocate, chmod 600, mkswap, swapon, and add a line to /etc/fstab.
chmod 600 is the step people skip, and the one that matters: the file can contain anything that was in memory.
Comparison of swappiness values: 0 does not disable swap and brings back abrupt kills, 10 keeps swap as a reserve and is recommended, 60 is the desktop default.
Swappiness sets how readily the kernel swaps. On a server, 10 keeps swap as insurance rather than a destination.

Most VPS images ship with no swap at all, which means the kernel has no shock absorber. A swapfile costs you a couple of gigabytes of disk and converts a hard kill into a slow patch. On a small box that trade is worth making every time.

# check first, in case swap already exists
swapon --show
free -h

sudo fallocate -l 2G /swapfile          # or: sudo dd if=/dev/zero of=/swapfile bs=1M count=2048
sudo chmod 600 /swapfile                # root-only, this file holds process memory
sudo mkswap /swapfile
sudo swapon /swapfile

# make it survive a reboot
sudo cp /etc/fstab /etc/fstab.bak
grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

swapon --show

The chmod 600 is not housekeeping. That file holds whatever was in memory, credentials included, so it must not be world-readable. If fallocate fails on your filesystem, the dd alternative in the comment works on ext4 and XFS, just slower. Double-check the of= path before pressing Enter, because dd overwrites whatever it’s pointed at; Btrfs needs its own swapfile procedure.

swappiness, and what it really controls

Swappiness, a value from 0 to 200 on current kernels, sets the relative cost the kernel assigns to swapping anonymous pages versus dropping file cache. The default of 60 suits a desktop. On a server where you want swap as an emergency reserve rather than a routine destination, lower it.

cat /proc/sys/vm/swappiness

# try it now
sudo sysctl vm.swappiness=10

# keep it across reboots
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swap.conf

Setting it to 0 is a popular suggestion and a bad one. It doesn’t disable swap, it makes the kernel avoid it until the last possible moment, which brings back the abrupt kills you added swap to prevent. Ten leaves swap available as a reserve without the kernel reaching for it during ordinary work.

swap on network storage is a different animal

Here’s the part worth understanding before you get enthusiastic. On many cloud providers your disk is network-attached block storage, not a drive physically in the machine. Swapping to it means every page fault becomes a network round trip. Local NVMe swap is slow compared to RAM; network-backed swap is slow compared to that.

A box thrashing in swap can be so unresponsive that SSH takes minutes to hand you a prompt, which is arguably worse than a clean restart. Treat swap as insurance against a spike, not as a way to run a workload that doesn’t fit. Constant swap use means the swapfile has stopped being a safety net and become the problem, and that trend is exactly what an on-box agent catches and an uptime check never will, as in knowing your server is down first.

sizing workers for a box this small

Table of starting values: PHP-FPM ondemand with 4 children on 1 GB or 6 to 8 on 2 GB; Gunicorn 2 or 3 workers; MySQL buffer pool 96 to 128 MB or 256 MB; max_connections 25 to 40 or 60; Redis 64 or 128 MB; 2 GB swap.
Starting points, not tuned truths. Leave a few hundred megabytes for the kernel, the cache and your SSH session.

The default configurations for PHP-FPM, MySQL and Gunicorn assume you have room. Cutting them down is usually the fix that makes a small box stable, and the arithmetic is simple: measure one worker, multiply by the count, and make sure the total plus the database leaves headroom.

ComponentDefault postureOn a 1 GB boxOn a 2 GB box
PHP-FPM pmdynamic, max_children 5 in the stock pool, often raised by tuning guidesondemand, max_children 4dynamic, max_children 6 to 8 after measuring per-child memory
Gunicorn workersoften set to CPU count times two2, with a thread or two each3
MySQL buffer pool128 MB and rising with tuning guides96 to 128 MB, no more256 MB
MySQL max_connections15125 to 4060
Redis, if presentunboundedmaxmemory 64mb, allkeys-lru128mb
Swapnone on most images2 GB2 GB

Those are starting points, not tuned truths. Find your real per-worker footprint with ps under actual traffic, then set the count so the arithmetic leaves a few hundred megabytes of headroom for the kernel, the cache and your own SSH session.

systemd memory limits as a blast-radius tool

Bar chart for a capped service: normal use under both limits, MemoryHigh at 200M where it is throttled, MemoryMax at 280M where it is killed.
MemoryHigh throttles, MemoryMax kills. Either way the blast radius is one service you picked.

You can cap what a service is allowed to use, which changes who dies when something misbehaves. Instead of the kernel picking your database because it’s the largest process, the leaking service hits its own ceiling first.

# sudo systemctl edit my-side-project.service
[Service]
MemoryHigh=200M
MemoryMax=280M
Restart=on-failure
RestartSec=10s

MemoryHigh is a soft limit: cross it and the service is aggressively reclaimed and throttled, but not killed. MemoryMax is the hard wall. Run sudo systemctl daemon-reload after editing, then restart the unit.

I’d put a ceiling on the services I’d rather lose: a hobby script, a feed fetcher, an indexing job written in a hurry. I wouldn’t cap the database, because one killed mid-write is worse than a slow one. The point is choosing the victim deliberately instead of letting the kernel choose by size.

when the answer is more RAM

Checklist of four signs to resize: constant swap use, requests queueing at ordinary traffic, a buffer pool smaller than the working set, and nervousness before deploys.
When all four show up, the cheapest fix is more RAM.

There’s a point where tuning becomes a hobby that costs more than the upgrade. The signs: swap in constant use rather than occasional, worker counts so low that requests queue during ordinary traffic, a buffer pool too small for your working set, or you flinching before every deployment. Resize the instance. Most providers do it with a reboot and let you scale back down, and the monthly difference is small next to another 2 AM investigation. On a first server, my VPS beginner guide covers what resizing involves.

common mistakes

  • Panicking at the free column and running commands to drop caches. You’re throwing away useful cache and making the machine slower for no benefit.
  • Setting swappiness to 0 because a forum post said so, which restores the abrupt kills you were trying to avoid.
  • Copying a MySQL tuning guide written for a 16 GB server. Those buffer pool numbers will get your database killed on a 1 GB box.
  • Leaving the swapfile world-readable. It holds whatever was in memory, so chmod 600 matters.
  • Restarting the service and moving on without reading the kernel log, guaranteeing the same failure next week at the same hour.
  • Forgetting that a nightly backup, a package upgrade or an image build spikes memory. The 2 AM kill and the 2 AM cron job are rarely a coincidence.

FAQ

how much swap should i add?

On a small server, one to two times RAM, capped at about 2 GB. The old rule of twice RAM came from an era of needing to hibernate desktops and doesn’t scale sensibly upward. Two gigabytes is enough to absorb a spike or a badly behaved cron job while staying small enough that you notice when it’s being used heavily.

can i stop the OOM killer from touching my database?

You can bias it with OOMScoreAdjust in the service unit, making the kernel prefer other victims. I’d use it sparingly. Protecting the largest process means the kernel kills smaller ones that were innocent, and you end up with a confusing partial outage instead of a clear one. Reducing total demand is the real fix, and capping the services you don’t mind losing is the second-best.

Add swap, cut your worker counts, then read the kernel log after the next incident instead of just restarting the service. Two of those three take five minutes, and together they turn a box that dies every few days into one you stop thinking about.

Update history: corrected the swappiness range, the PHP-FPM default and the systemd source for OOMScoreAdjust; the fstab step now keeps a backup and skips a duplicate entry.

Sources