SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Limit process memory and CPU with systemd

A capped process can still stall your whole VPS. Set MemoryHigh, MemoryMax, CPUQuota and TasksMax on a systemd unit, and read the OOM kill after.

Limit process memory and CPU with a systemd drop-in

You limit process memory and CPU on a Linux VPS by adding a few lines to the unit that runs the process. MemoryMax= is the hard ceiling on memory. CPUQuota= is the ceiling on processor time. Both are enforced by cgroup v2 (control groups, version 2), the kernel feature systemd already uses to account for every service on the box.

sudo systemctl edit myapp.service

That opens a drop-in file with instructions in comments. Add this above them:

[Service]
MemoryHigh=512M
MemoryMax=768M
MemorySwapMax=0
CPUQuota=80%
TasksMax=128
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl show myapp.service -p MemoryHigh -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax

systemctl show must repeat your numbers back in the kernel's own units: MemoryMax=805306368 and CPUQuotaPerSecUSec=800ms. If it prints MemoryMax=infinity, the drop-in never loaded. Check the file landed at /etc/systemd/system/myapp.service.d/override.conf, and that it starts with the [Service] header, because a settings line with no section above it makes systemd log Assignment outside of section. Ignoring. and start the service with no limits at all.

The rest of this guide is how to choose those numbers, and what still goes wrong once they are set.

Why a runaway process freezes a VPS it never fills

A process that hits a hard memory cap dies in about a second and the service restarts. That is the good case. The bad case is the one where nothing dies: the box answers ping, SSH accepts the connection, and the shell prompt never arrives. The machine is alive and busy, and none of that work is useful.

Here is the mechanism, because it is not obvious. When free memory runs low, the kernel reclaims pages instead of handing out new ones. The cheapest pages to reclaim are file-backed, and the page cache holds the executable code of everything that is running. So the kernel evicts the text pages of sshd, and the next instruction sshd runs is a page fault that has to read those bytes back from storage. Every process ends up waiting on the disk rather than running. The same pages leave and come back in a loop, which is called thrashing.

Two things make this worse on a VPS than on a laptop. Storage is often network attached or shared, so each fault costs more milliseconds than a local NVMe device would. And the kernel does not measure time, it measures failure: as long as reclaim keeps handing back a page, however slowly, the kernel believes it is making progress and does not call the out of memory (OOM) killer. A box can sit in that state for many minutes before anything is killed.

You can watch it happen. The kernel exports pressure stall information (PSI) on Linux 4.20 and newer:

cat /proc/pressure/memory
cat /proc/pressure/io
some avg10=63.72 avg60=41.02 avg300=12.33 total=13729481
full avg10=48.15 avg60=30.44 avg300=8.90 total=9114233

The full line is the one that matters. full avg10=48.15 means that over the last ten seconds, 48% of the time every runnable task on the box was stalled waiting on memory work, so nothing ran. A healthy server reads close to zero on full. Above 10 it feels slow to a human, and 40 or more is the state people describe as frozen.

This is also why a limit on its own is not a promise. A unit held under MemoryHigh= is throttled instead of killed, so it stays alive and stays slow, and nothing restarts it because from systemd's point of view it never failed. A capped unit that is still allowed to swap generates reads and writes that are charged to that unit but served by one shared device, so it can push /proc/pressure/io up for every other service on the box. Limits decide who pays for a shortage, and they cannot create capacity.

Check that your VPS runs cgroup v2

stat -fc %T /sys/fs/cgroup

cgroup2fs is the unified hierarchy, which is what every setting below needs. tmpfs means the box booted the older v1 layout, where MemoryHigh= and MemorySwapMax= do not exist and per-unit OOM behaviour is different. Ubuntu 22.04 and later, and Debian 11 and later, use v2 by default. An old image, or a kernel booted with systemd.unified_cgroup_hierarchy=0, does not.

On cgroup v2 systemd turns on memory accounting for every unit by default, so the numbers are already there:

systemd-cgtop -m

That lists cgroups sorted by memory use, which is the fastest way to answer "what is eating this box" while it can still answer. If the server is new, the account and firewall work in the first ten minutes on a new VPS comes before this.

MemoryHigh throttles. MemoryMax kills.

The difference between the two memory settings decides what a failure looks like.

  • MemoryHigh= is a soft cap. Above it the kernel reclaims aggressively from that cgroup and slows its allocations down on purpose. Usage can still go past the number, and nothing is killed.
  • MemoryMax= is a hard cap. When an allocation cannot be satisfied under it, the OOM killer runs inside that cgroup and kills one of that unit's own processes.

That second half is the real reason to set MemoryMax= on anything you do not fully trust. With no cap, a shortage is a whole-box problem, and the global OOM killer picks its victim by oom_score, which mostly means the largest process. The largest process is usually your database, not the script that leaked. With a cap, the kill lands inside the unit that caused it.

Set both, with MemoryHigh= around 20 to 30 percent below MemoryMax=. The gap is a warning zone: a slow leak crosses High and shows up as a service that got slow, while a sudden spike goes straight through Max and dies.

Percentage values are read against installed physical memory, so MemoryMax=25% on a 4 GB plan is 1 GB and stays a quarter of the box after you resize the plan. MemorySwapMax=0 keeps that unit out of swap completely, which turns a long crawl into a fast, obvious kill.

A cap needs a restart policy next to it, or the kill just leaves you with a stopped service.

[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Restart=on-failure
RestartSec=5s

StartLimit* belongs in [Unit] and Restart= in [Service]. Put either in the wrong section and systemd ignores it. Five restarts in five minutes is a leak rather than a blip, so after that systemd gives up and leaves the unit failed, which is the state you want to find later instead of a crash loop that hides the problem.

Cap CPU with CPUQuota, or share it with CPUWeight

CPUQuota= takes a percentage of the time available on one CPU. CPUQuota=50% is half of one core. CPUQuota=200% is the equivalent of two cores, which the unit may spread across as many threads as it wants. On a 2 vCPU plan, CPUQuota=200% is the whole machine.

CPUWeight= is the better default for most services. It is a relative share from 1 to 10000, and the kernel default is 100. It only bites when something is competing: a backup job at CPUWeight=20 yields to a web server at 100 under load, and still uses the entire box while the box is idle. A hard quota throws that idle capacity away.

Be honest about what a CPU limit buys you. A CPU-bound process rarely freezes Linux, because the scheduler keeps handing time to everyone. Memory is what takes a box down. Reach for CPUQuota= when you want a predictable ceiling, for example on a build or an agent that would otherwise run flat out for an hour. Sizing that kind of workload is its own question, covered in how much RAM and CPU a coding agent VPS needs.

If the CPU reads as busy while none of your processes are doing much, the cause may sit on the other side of the hypervisor. That is CPU steal time from a noisy neighbour, and no quota you set will change it.

TasksMax stops a fork loop

TasksMax= is the number of processes and threads a unit may hold. Threads count, so a Java or Go service needs more headroom than the process list suggests. It is the cheapest protection against a script that forks in a loop, because the fork fails inside the unit instead of the box running out of process IDs.

TasksMax=128

When a unit hits the limit, the kernel logs a line naming the cgroup:

cgroup: fork rejected by pids controller in /system.slice/myapp.service

The program itself usually reports fork: retry: Resource temporarily unavailable. Check what the manager applies by default with systemctl show -p DefaultTasksMax.

Limit a one-off job with systemd-run

You do not need a unit file to use any of this. systemd-run builds a transient one around a single command.

sudo systemd-run --scope -p MemoryMax=1G -p MemorySwapMax=0 -p CPUQuota=50% -p TasksMax=64 ./import-data.sh

--scope runs the command in your terminal, after printing Running scope as unit: run-r7c1a....scope. Output stays on your screen and the limits disappear when the command exits. Any property from systemd.resource-control works after -p.

For a long job, drop --scope and give it a name. It then runs in the background as a transient service and logs to the journal:

sudo systemd-run --unit=nightly-import -p MemoryMax=1G -p CPUWeight=20 ./import-data.sh
journalctl -u nightly-import -f

The same options work with --user when you are not root, though your user manager only has the controllers that were delegated to it, so a property can be rejected there. Run it with sudo if that happens. When a job earns a permanent home, the settings move unchanged into a real unit: see running a script as a systemd service and timer.

The swap question, answered honestly

Swap changes the shape of the failure rather than preventing it.

With no swap, a leak reaches the ceiling and something dies within seconds. The outage is loud, short and easy to read in the journal afterwards. With swap, the kernel writes cold anonymous pages out to disk and buys time. If the process was going to level off, swap saves you. If it is a runaway, swap turns a five second outage into a twenty minute stall, and the stall is worse, because a dead process still leaves you a working shell and a thrashing box does not.

swapon --show
free -h

A workable middle on a small VPS: keep a modest swap file for pages that are allocated once and never touched again, and set MemorySwapMax=0 on the units you are willing to lose. The important services keep their swap. The unpredictable ones hit the wall quickly and restart.

Lowering vm.swappiness is a weak lever, and it is worth knowing why. It only shifts the balance between evicting page cache and swapping anonymous pages, and both cost a disk read later. It changes which pages thrash, not whether the box thrashes.

An early OOM daemon kills before the stall

The kernel waits for reclaim to fail completely, and on a small VPS that wait is the exact window where you lose the machine. Two userspace daemons close it by watching memory themselves and killing sooner.

earlyoom watches available memory and free swap, and kills the highest scoring process when either drops below a threshold.

sudo apt install earlyoom
systemctl status earlyoom

The Debian and Ubuntu package starts the service on install. Its options live in /etc/default/earlyoom:

EARLYOOM_ARGS="-m 5,2 -s 5,2 --avoid '^(sshd|systemd)$' --prefer '^(node|python3)$'"

-m PERCENT sets the available memory minimum and -s PERCENT the free swap minimum, both 10 percent by default. The second number in each pair is the SIGKILL point: earlyoom sends SIGTERM once you fall below the first value, then SIGKILL below the second, which defaults to half the first. Apply a change with sudo systemctl restart earlyoom, and read journalctl -u earlyoom to see which process it killed and how much memory that process was holding.

systemd-oomd is the other option. Its manual page describes it as "a system service that uses cgroups-v2 and pressure stall information (PSI) to monitor and take corrective action before an OOM occurs in the kernel space". It acts on whole cgroups rather than single processes, so it kills a unit, not a stray child. Units opt in with ManagedOOMMemoryPressure=kill or ManagedOOMSwap=kill, and the thresholds live in /etc/systemd/oomd.conf.

systemctl status systemd-oomd
oomctl

oomctl prints what it is currently monitoring, which is often nothing on a server image, because the setting is opt-in per unit. Pick one daemon and stop there. Running both means two things racing to choose a victim, and the reason for any kill gets harder to reconstruct.

Which unit was responsible?

Start with the kernel, because it records every kill it makes.

journalctl -k --grep "Killed process" --since "2 hours ago"

A kill from the global OOM killer looks like this:

Out of memory: Killed process 4127 (node) total-vm:2731084kB, anon-rss:1874232kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:4212kB oom_score_adj:0

anon-rss is the memory that process held in RAM when it died, about 1.8 GB here. Read the name in brackets with suspicion. That is the victim the kernel chose, and the kernel chooses the largest process, which is not always the one that caused the shortage.

A kill from a cgroup limit is prefixed differently, and the report printed above it names the cgroup that hit its own ceiling:

Memory cgroup out of memory: Killed process 8811 (python3) total-vm:1044320kB, anon-rss:769112kB, file-rss:0kB, shmem-rss:0kB, UID:998 pgtables:1720kB oom_score_adj:0

That prefix is most of the diagnosis. Memory cgroup out of memory means one unit hit the MemoryMax= you gave it and the rest of the box was fine. A plain Out of memory means the machine as a whole ran out, so your caps were either missing or too generous to add up.

Then ask systemd what it saw:

systemctl status myapp.service
journalctl -u myapp.service -n 50
myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Main process exited, code=killed, status=9/KILL
myapp.service: Failed with result 'oom-kill'.

systemctl status says the same thing in one line, as Active: failed (Result: oom-kill).

The cgroup counters are the third source, and the only one that records throttling, which never produces a log line at all:

cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
cat /sys/fs/cgroup/system.slice/myapp.service/memory.peak
low 0
high 4213
max 118
oom 12
oom_kill 12

high counts how many times the unit was pushed over MemoryHigh= and throttled. max counts how often it reached the hard cap, and oom_kill counts processes actually killed. A large high with oom_kill 0 is the silent case from earlier: the service is running, slowed to a crawl, and has reported no failure to anyone. memory.peak (Linux 5.19 and newer) holds the highest usage the cgroup reached, which is the number to size MemoryMax= against. Both files reset when the unit restarts, because systemd creates the cgroup again.

One prerequisite sits under all of this. If /var/log/journal does not exist, the journal lives in RAM, and every line is gone after the reboot you needed in order to recover the box.

sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
journalctl --list-boots

journalctl --list-boots showing more than the current boot means history now survives, so journalctl -k -b -1 can show you the kernel messages from the boot that died.

A starting point for a small VPS

On a 2 GB plan, leave 300 to 400 MB for the kernel and page cache, and do not let the caps add up to the full 2 GB, because every unit can peak at the same moment. Give the service that matters the largest share, then cap everything speculative around it.

[Service]
MemoryHigh=256M
MemoryMax=384M
MemorySwapMax=0
CPUWeight=20
TasksMax=64
Restart=on-failure
RestartSec=5s

Keeping a way in is worth one more setting. OOMScoreAdjust=-500 in a drop-in for ssh.service makes the global OOM killer far less likely to pick your SSH daemon as its victim, which is the difference between fixing the box and rebooting it from the control panel. It only changes the kernel's choice of victim. It does not shorten the stall.

Containers run in cgroups of their own, created by the container runtime rather than by your unit files, so a limit on docker.service does not become a limit on one container. The per container equivalents of MemoryMax= and CPUQuota= are covered in setting memory and CPU limits in Docker Compose.

FAQ

Why did my VPS freeze instead of killing the runaway process?

Because the kernel judges progress by whether reclaim returns pages, not by how long that takes. While memory is short it evicts page cache, including the executable pages of running programs, then reads them back on the next instruction. Everything waits on storage and no allocation has technically failed, so the OOM killer is never called. Check /proc/pressure/memory while it is happening: a full avg10 above 40 means almost no task got to run in the last ten seconds. A userspace daemon such as earlyoom kills before the box reaches that state.

What is the difference between MemoryHigh and MemoryMax?

MemoryHigh= is a soft cap that throttles. The kernel reclaims hard from the unit and slows its allocations, but usage can exceed the number and nothing is killed. MemoryMax= is a hard cap: an allocation that cannot be met under it invokes the OOM killer inside that unit's own cgroup, so the process that caused the problem is the one that dies instead of the largest process on the box. Set MemoryHigh= below MemoryMax= and treat the gap between them as a warning zone.

How do I find which service the OOM killer hit?

Run journalctl -k --grep "Killed process" --since "2 hours ago". A line starting Memory cgroup out of memory means one unit hit its own MemoryMax=, while a plain Out of memory means the whole machine ran out. Then run journalctl -u <unit> -n 50 and look for Failed with result 'oom-kill'. If /var/log/journal does not exist on your server, the journal was held in RAM and the evidence died with the reboot, so create that directory before the next incident.

Should I add swap to a small VPS?

A small swap file helps with cold pages that are allocated once and never touched again. It does not help with a runaway process: it delays the kill and replaces a short outage with a long stall you cannot log in to fix. Keep swap modest, and set MemorySwapMax=0 on the units you are willing to lose, so those reach their ceiling and restart quickly while the important services keep their swap.

Can I limit a command without writing a unit file?

Yes. sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh runs the command in your terminal inside a transient scope with those limits, and the limits vanish when it exits. Every property from systemd.resource-control is available after -p, so MemorySwapMax=, TasksMax= and CPUWeight= work there too. Drop --scope and add --unit=name to run the job in the background with its output in the journal.