Home Server & Self-Hosting

systemd Resource Controls for a Home Server - Give One Service a Ceiling Without Calling It Isolation

systemd Resource Controls for a Home Server - Give One Service a Ceiling Without Calling It Isolation

A home server often asks one small machine to do several jobs at once. A web application, database, thumbnail generator, and background worker may all be well behaved on an ordinary day. The difficult question appears when one of them is not: how much of the machine should a single service be allowed to consume before the operating system pushes back?

For services managed by systemd, resource controls provide a practical answer. They use Linux control groups, or cgroups, to account for and govern a service as a group of processes. That can preserve some headroom for SSH, the database, or other essential work. It cannot prove that the constrained service is secure, healthy, or fast. A limit is a boundary around resource use, not a security sandbox or a cure for a memory leak.

The useful mental model: a household budget, not a private machine

Linux cgroups organize processes into a hierarchy. Limits and accounting can apply to a group and its descendants, and a parent can impose a stricter boundary than a child requests. systemd maps units and slices onto that hierarchy, then exposes resource settings in unit files. The systemd resource-control documentation describes this connection directly.

It helps to separate two kinds of control:

  • Weights divide contested capacity. A service with a lower CPUWeight= receives a smaller share than its siblings when they all want CPU time. A weight is neither a reservation nor a hard ceiling. If nobody else needs the CPU, the service can still use available capacity.
  • Ceilings stop use at a boundary. CPUQuota=, MemoryMax=, and TasksMax= impose different kinds of maximum. A ceiling may protect the rest of the host, but the constrained workload may become slow or fail when it reaches that boundary.

This distinction prevents a common configuration mistake. CPUWeight=50 does not mean “50 percent CPU.” Conversely, CPUQuota=50% does not mean “half of every CPU in the machine.” systemd defines 100% quota relative to the capacity of one CPU, and values above 100% can permit time on more than one CPU.

Measure before choosing a number

There is no responsible universal memory limit for “a PHP worker” or “an image service.” Workloads have different steady states and legitimate bursts. The host also needs room for the kernel, filesystem cache, login sessions, and services outside the unit being tuned. Start by observing normal and intentionally demanding operations, then ask what degradation is acceptable.

These commands provide different views of an existing service:

systemctl status example-worker.service
systemctl show example-worker.service \
  -p MemoryCurrent -p MemoryPeak -p MemoryHigh -p MemoryMax \
  -p CPUUsageNSec -p CPUQuotaPerSecUSec \
  -p TasksCurrent -p TasksMax
systemd-cgtop
journalctl -u example-worker.service --since today

systemctl show is intended for inspecting normalized properties and runtime state. systemd-cgtop provides a live control-group view, although its memory and I/O columns can be incomplete when the corresponding accounting is not enabled. Application-level metrics still matter: a low memory graph does not reveal whether requests are timing out or useful work is completing.

A brief sample is not enough for a seasonal job, backup, import, or thumbnail batch. Capture representative peaks over time. Leave margin for variation. More importantly, decide in advance whether throttling, rejected work, or a stopped service is preferable to pressure on the entire host.

A conservative drop-in example

Suppose observation suggests that an illustrative worker usually stays below 512 MiB, sometimes needs more, and should never be allowed to consume memory without a hard boundary. A drop-in could look like this:

[Service]
MemoryHigh=512M
MemoryMax=768M
MemorySwapMax=256M
CPUWeight=50
CPUQuota=100%
TasksMax=200

These values are examples, not recommendations. Copying them without measuring the real workload would replace one guess with another.

Create an override with:

sudo systemctl edit example-worker.service

By default, systemctl edit creates a drop-in rather than replacing the vendor unit, and reloads the manager configuration after a successful edit. This keeps local policy separate from package-managed files. Inspect the combined result before restarting anything:

systemctl cat example-worker.service
systemctl show example-worker.service \
  -p MemoryHigh -p MemoryMax -p MemorySwapMax \
  -p CPUWeight -p CPUQuotaPerSecUSec -p TasksMax

For a unit file maintained as code, run systemd-analyze verify against the file before installation. It can catch unknown directives, invalid sections, and some missing dependencies or commands. It cannot tell whether 768 MiB is enough for a production import.

systemd-analyze verify ./example-worker.service

Apply a restart only during a period when interruption is acceptable:

sudo systemctl restart example-worker.service
systemctl status example-worker.service
journalctl -u example-worker.service --since "5 minutes ago"

What each setting actually trades away

MemoryHigh=: pressure before failure

MemoryHigh= is a throttling threshold. The documented behavior allows usage to go above it when unavoidable, while reclaim becomes aggressive and the processes are heavily slowed. systemd recommends it as the main mechanism for controlling a unit's memory use. This makes it useful as an early pressure zone, but unsuitable when an absolute boundary is required.

MemoryMax=: the last line, not the first

MemoryMax= is the absolute memory limit. If the kernel cannot keep the unit below it, out-of-memory handling is invoked inside the unit. That may preserve the wider host while causing work in the unit to fail. The documentation therefore recommends MemoryHigh= for primary control and MemoryMax= as a last line of defense.

The gap between the two values is intentional: it gives the workload a pressure region before the hard boundary. How large that gap should be remains a workload decision, not a formula supplied by systemd.

MemorySwapMax=: separate swap headroom

MemorySwapMax= limits the unit's swap use separately. A smaller value can prevent one service from filling swap and making the host painfully unresponsive. It also removes an escape route from memory pressure, so a service can encounter failure sooner. “No swap” is not automatically safer; it changes the tradeoff.

CPUWeight= and CPUQuota=: preference versus cap

Use a weight when the aim is relative fairness under contention. Use a quota when the service must not exceed a CPU-time ceiling even while the machine is otherwise idle. A hard quota can introduce latency and reduce throughput, especially for bursty work. Combining both can be valid, but each should answer a distinct policy question.

TasksMax=: protection from unbounded fan-out

TasksMax= limits tasks in the unit through the cgroup PIDs controller. Here, threads count individually. A multithreaded runtime can therefore approach the limit with far fewer visible processes than the number suggests. Set it from observed concurrency plus deliberate headroom, not from a process count alone.

Watch effective behavior, not only the file

A syntactically correct drop-in is only the beginning. After activation, inspect the effective properties, exercise representative operations, and watch logs and service-level outcomes. The cgroups manual explains why hierarchy matters: limits higher in the tree constrain descendants. A unit may therefore receive less than its local configuration appears to allow.

Look for several failure signals:

  • requests or jobs taking longer after CPU throttling;
  • memory pressure causing repeated reclaim, OOM events, or restarts;
  • thread or process creation failing near TasksMax=;
  • a restart policy turning a resource failure into a restart loop;
  • another service or parent slice already imposing a tighter limit.

Change one policy at a time when possible. If the service degrades, widen or remove the relevant boundary and investigate the underlying demand. The goal is not to make the graph look tidy. It is to choose which component should absorb pressure when the machine has finite capacity.

What these controls do not provide

Resource limits are sometimes described as “containing” a service, but that word can hide several different ideas. CPU and memory controls do not restrict which files a process can read, which network destinations it can contact, or which system calls it can make. Those are separate security and sandboxing questions. A compromised process can remain dangerous while staying below 512 MiB.

Limits also do not replace capacity planning or application fixes. If ordinary traffic legitimately needs more memory than the host can spare, a tighter ceiling only chooses a more local failure. That may still be the correct operational decision on a shared home server, but it should be described honestly.

A cautious starting checklist

  1. Confirm the systemd version and whether the machine uses cgroup v2; consult documentation matching the installed distribution.
  2. Observe representative steady-state and burst behavior before choosing values.
  3. Decide whether each setting expresses relative priority, soft pressure, or a hard ceiling.
  4. Use a drop-in instead of editing a vendor unit directly.
  5. Validate syntax, inspect the merged unit, and schedule any restart deliberately.
  6. Test useful work, not just whether the process remains “active.”
  7. Watch logs and effective properties, then revise the limits when evidence changes.

On a small server, a resource boundary can be a useful form of humility: no single service is assumed to deserve the whole machine. The difficult part is not writing MemoryMax=. It is choosing a failure boundary from real observations, then checking that the protection offered to the host is worth the cost imposed on the service.

References