A failed OpenVPN start often arrives as a resource complaint plus a well-meant suggestion to raise a limit, and the suggestion only works about half the time. Two unrelated counters sit behind those failures. One is a kernel limit on the number of tasks owned by a real user ID, inherited when the process is executed. The other is a cgroup budget that the service manager applies to a unit and everything it forks. Reading the effective value of each, for the unit that actually runs, costs less than a guess.

Two Counters, Two Questions

LimitNPROC is RLIMIT_NPROC under a systemd name, and its accounting unit is the real user ID. Every process that user owns counts against the same total, including work started by other units, by a login session or by a scheduler job. The limit is therefore not a per-service cap even when it is set inside a service unit, and a service that forks a helper for each client competes with everything else running as that account. A busy cron job under the same user can push a VPN unit over a line it never crossed on its own.

TasksMax has a different owner: the pids controller on the unit's cgroup, which counts tasks rather than processes. Threads are tasks, so a single-threaded-looking daemon with a worker pool can consume a budget that reads, at a glance, far larger than its process count. EffectiveTasksMax reports what the cgroup hierarchy actually allows once parent slices have had their say.

Why the Root Exception Changes the Answer

The kernel does not apply RLIMIT_NPROC to a process that carries CAP_SYS_RESOURCE or CAP_SYS_ADMIN, which is why a unit running as root without dropping privileges is effectively unbounded by that setting. This single exception explains the contradictory advice people trade: raising LimitNPROC repairs a deployment that dropped privileges and does nothing at all for one that stayed privileged. Check User= and whether the daemon changes identity after startup before crediting or blaming that setting. A privileged unit that later switches to an unprivileged account falls back under the limit at the moment of the switch, which makes the timeline of the failure worth reading too.

The failing stage narrows it further. A refusal during execution setup, a fork error reported by the daemon itself and a rejected task creation inside a cgroup produce different journal lines, and only one of them is answered by a larger budget.

Read the Effective Values, Not the Shell's

An interactive shell's ulimit -u describes that shell. It does not describe what the service manager passed to the daemon, and it says nothing about cgroup accounting. Query the unit instead, using the instance name that exists on the host rather than an example from a guide.

systemctl show [email protected] -p User -p LimitNPROC -p TasksCurrent -p TasksMax -p EffectiveTasksMax
cat /proc/$(systemctl show -p MainPID --value [email protected])/limits | grep -i processes

Inside a container the answer needs two more readings, because an inner value of infinity only describes the guest's view. Ask the host owner for the container's own pids ceiling, and confirm whether the inner service manager can delegate that controller at all. A nested unit cannot hold a budget its parent cgroup never granted.

Size the Fix and Prove It Under Load

Pick a number from the workload rather than from the error text. Count the processes the daemon starts per connected client or per helper invocation, multiply by the concurrency the deployment is expected to carry, add a margin for administrative sessions, then compare that figure with the two effective values. A local drop-in override keeps the change reviewable and survives package upgrades, whereas editing a shipped unit file hides the modification until the next update replaces it. Write the arithmetic down next to the override, because the next person to raise or lower it needs the assumptions rather than the final number.

Budget changes are applied at execution, so a reload alone does not resize processes that are already running. Restart the unit through its normal lifecycle, accepting the session interruption, and read the counters again on the new instance.

sudo systemctl daemon-reload
sudo systemctl restart [email protected]
systemctl show [email protected] -p TasksCurrent -p EffectiveTasksMax
sudo journalctl -u [email protected] -b --no-pager | tail -n 20

What proves the repair is a restarted unit whose task count sits below both ceilings while carrying the intended load, plus a journal that no longer reports task or fork exhaustion at the same stage. Keep the before-and-after numbers with the change so the next investigation starts from a measurement instead of from the same advice. If the count still reaches the ceiling, the arithmetic was wrong rather than the limit being too small by accident, and that difference is worth knowing before the next restart.

Nuŋlɔɖi siwo ku ɖe eŋu