systemd Resource Limits Per Service

– min read
systemd project logo
systemd logo (CC BY-SA 4.0, systemd.io)


My homelab server used to lock up at random hours. Every time the culprit was the same: a runaway Plex transcoder or a database backup that consumed all 3.3 GB of RAM and dragged the machine into swap hell. systemd can cap CPU, memory, and process count per service since v247, and it took me way too long to actually set it up.


systemd resource control references: systemd.resource-control man page | systemd project site | systemd on GitHub


What You Need

  1. A Linux server running systemd v247+ (Ubuntu 22.04+, Debian 12+, Fedora 38+)
  2. Cgroups v2 mounted at /sys/fs/cgroup (default on modern distros)
  3. Root or sudo access to modify system service units


1. Check Your Current Limits

Before setting anything, look at the default limits your system manager applies. On a stock Ubuntu 26.04 server, most services have no CPU or memory cap — they can grab everything.


# Check limits on the system manager itself
systemctl show --property=CPUQuota,MemoryMax,TasksMax $(systemctl show -p Id --value)

# Check a running service (e.g. sshd)
systemctl show --property=CPUQuota,MemoryMax,TasksMax sshd

# Interactive cgroup monitor
systemd-cgtop


If you see blank fields for CPUQuota, MemoryMax, or TasksMax, those limits are not applied — the service can use as much as the kernel allows.


2. One-Off Limits With systemd-run

The quickest way to test limits is systemd-run. It starts a transient service or scope with the properties you pass. I ran three tests on a 2-CPU, 3.3 GB RAM VM to see the effect.


CPU Quota

Without a limit, a busy-loop Python process uses 99% CPU and does 28 million iterations in 5 seconds. With CPUQuota=50%, it gets capped to half a core.


cat > /tmp/spin.py << 'EOF'
import time
t0 = time.time()
n = 0
while time.time() - t0 < 5:
    n += 1
print(f"iterations={n} in 5s")
EOF

# Without limit — 99% CPU
python3 /tmp/spin.py
# iterations=28038087 in 5s

# With 50% CPU quota
systemd-run -u cpu-demo --same-dir -p CPUQuota=50% python3 /tmp/spin.py
# iterations=14642445 in 5s  (50% CPU)


The iterations dropped by roughly half. The systemd journal confirms CPU usage went from 99% down to 50%.


Memory Limit

MemoryMax=200M caps the cgroup at 200 MB of physical RAM. When the process exceeds that, the cgroup’s OOM killer terminates it — not the host-wide OOM killer, just that unit.


cat > /tmp/alloc.py << 'EOF'
import sys
chunk = 1024*1024
held = []
try:
    while True:
        held.append(bytearray(chunk))
        sys.stdout.write(f"\rallocated {len(held)} MB")
        sys.stdout.flush()
except:
    print(f"\nstopped at {len(held)} MB")
EOF

# With MemoryMax=200M
systemd-run -u mem-demo --same-dir -p MemoryMax=200M -p MemoryAccounting=yes python3 /tmp/alloc.py

# The unit gets oom-killed
systemctl status mem-demo.service | tail -3
journalctl -u mem-demo.service | head -5


The journal shows mem-demo.service: The kernel OOM killer killed some processes in this unit. The process died at 200 MB peak instead of drowning the whole machine.


TasksMax — Fork Bomb Protection

A single buggy process can spawn thousands of threads and exhaust the system’s process table. TasksMax=32 limits the total number of tasks (threads + processes) in the cgroup.


cat > /tmp/forkbomb.py << 'EOF'
import subprocess, time
procs = []
i = 0
try:
    while True:
        procs.append(subprocess.Popen(['sleep','60'],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
        i += 1
except OSError as e:
    print(f"spawned {i} children, then: {e}")
    for p in procs:
        try: p.terminate()
        except: pass
EOF

# With TasksMax=32
systemd-run -u task-demo --same-dir -p TasksMax=32 python3 /tmp/forkbomb.py

# Output: spawned 31 children, then: [Errno 11] Resource temporarily unavailable

# Without any limit it spawns 3646 children before the host ulimit kicks in


With the limit, the process stops at 31 children (32 minus 1 for the parent process). The system stays responsive because it never exhausted available PIDs.


Tux the Linux mascot penguin
Tux (Attribution, Wikimedia Commons)


3. Persistent Limits With Drop-Ins

One-off limits are good for testing. For real services — Plex, Jellyfin, Syncthing, a database — you want limits that survive reboots. The cleanest method is systemctl set-property which creates a drop-in override.


# Set limits on an existing service
systemctl set-property jellyfin.service \
  CPUQuota=75% \
  MemoryMax=1G \
  TasksMax=256

# This writes /etc/systemd/system/jellyfin.service.d/override.conf
cat /etc/systemd/system/jellyfin.service.d/override.conf
# [Service]
# CPUQuota=75%
# MemoryMax=1G
# TasksMax=256

# Reload and verify
systemctl daemon-reload
systemctl show --property=CPUQuota,MemoryMax,TasksMax jellyfin.service


You can also apply limits per-user by editing ~/.config/systemd/user.conf or using systemctl --user set-property for user services.


4. Monitor Limits in Action

Use systemd-cgtop to watch live resource usage per cgroup. It is like top but groups processes by their systemd unit.


# Live cgroup resource monitor
systemd-cgtop

# Query a specific unit
systemctl show --property=MemoryCurrent,MemoryMax,MemoryPeak mem-demo.service


For persistent data collection, enable accounting globally:


echo "DefaultCPUAccounting=yes
DefaultMemoryAccounting=yes
DefaultTasksAccounting=yes" >> /etc/systemd/system.conf

systemctl daemon-reload
# Note: existing services need MemoryAccounting=yes added to their own unit


Resource limits did not fix every crash in my homelab, but they fixed the worst class: the ones where a single service takes the whole machine with it. If you have a homelab server with services you do not fully trust or fully debugged, applying a few systemd resource limits takes ten minutes and saves a lot of SSH sessions at midnight.


Related: Audit cron and systemd timer hygiene — Enable zram compressed swap on Ubuntu — Monitor Linux servers with Glances

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page