I’ve been running this on a production HestiaCP server for a while and wanted to share it — it solves a gap in HestiaCP’s Resource Limits.
The problem. HestiaCP enforces per-account limits through systemd user-<uid>.slice cgroups, but only processes spawned via an interactive session (login/SSH/cron/CLI) land there automatically. php-fpm workers and Dovecot session processes (IMAP/POP3/managesieve) never do — they stay in the service slices and escape the account’s limits entirely.
What it does. A small native Rust daemon that runs alongside HestiaCP and:
recreates missinguser-<uid>.slice slices after reboot (writing the user’s HestiaCP CPU_QUOTA/MEMORY_LIMIT limits into the cgroup)
detects php-fpm workers and Dovecot session processes, resolves their owner’s package, and moves them into the account’s slice
applies optional cgroup v2 attributes per package (memory.high, cpu.weight) that HestiaCP doesn’t expose
Exim addon: a small Exim ACL rule + notifier that, after SMTP AUTH, moves the authenticated SMTP connection process into the sender’s slice too (verified authenticated_id → uid → slice)
Why Rust. No ps, grep, awk or jq runs in the loop — it talks to /proc and cgroupfs directly, so it’s cheap on busy servers.
Safety design. HestiaCP stays the owner of cgroups — the daemon never touches an existing slice, never replaces Resource Limits, and every Exim submission is verified against /proc/<pid>/exe before anything is moved. 61 unit tests, MIT licensed, nothing to install on clients.
Install:
git clone https://github.com/hedon77/process-slice-manager.git
cd process-slice-manager
sudo bash install.sh --with-exim # --with-exim adds the SMTP AUTH notifier
Yes. HestiaCP supports Debian 12 (bookworm), and the daemon only depends on paths that Debian 12 provides:
/usr/local/hestia/data/users and /usr/local/hestia/data/packages — identical on Debian
cgroup v2 — Debian 12’s systemd uses the unified hierarchy by default (verify: [ -f /sys/fs/cgroup/cgroup.controllers ])
The install.sh bootstraps Rust itself if cargo is missing
One thing to be aware of: the optional Exim addon patches /etc/exim4/exim4.conf.template. On Debian 12 with HestiaCP’s Exim 4.96 the template is the same monolithic one, and the patch is idempotent with an hourly self-healing timer, so it recovers on its own if an upgrade resets it. I tested on Ubuntu 24.04 (Hestia 1.9.8, Exim 4.97); I’d be glad to hear a Debian 12 confirmation from anyone who tries it.
Q2: “Does php-fpm normally limit processes by load?”
No — that’s exactly the gap this daemon fills. php-fpm’s own pm settings (pm.max_children, pm.start_servers, etc.) only cap the number of workers and how many PHP requests run concurrently. They do not limit CPU or memory per account.
HestiaCP’s Resource Limits (CPUQuota/MemoryMax) are enforced through systemd user-.slice cgroups. But those slices only catch processes that land there automatically — interactive sessions (login/SSH/cron/CLI). php-fpm workers are spawned by the php-fpm master inside /system.slice/php8.x-fpm.service and never inherit the account slice, so they bypass the panel’s limits entirely. The daemon detects each worker’s real uid, resolves the owner’s package, and moves the worker into user-.slice, where the account’s CPU/memory limits then actually apply.
Q3: “The first version of the module was quite crooked…”
Fair — this is a complete rewrite in Rust, not a patched bash script. The old bash daemon is gone; the Rust version:
has no ps/grep/awk/jq in the monitor loop — it reads /proc and cgroupfs directly
never touches an existing slice (HestiaCP stays the owner of cgroups) and never replaces Resource Limits
verifies every Exim submission against /proc//exe before moving anything
Every <name>.pkg file there is a package (the .sh files next to them are Hestia’s own per-package hooks and are ignored). The daemon also reads each user’s user.conf to map user → package. So the panel stays the single source of truth: add/rename a package in HestiaCP and the daemon picks it up on its next reload (which happens automatically when a user is added or a package changes, via the installed <Package>.sh hooks → SIGHUP).
The [Package] sections in the config file are entirely optional and only exist if you want to apply extra cgroup v2 attributes that HestiaCP doesn’t expose (e.g. memory.high, cpu.weight, io.weight). Rules:
an attribute is applied only if it’s listed in the config
attributes not listed are never touched — HestiaCP’s own limits stay intact
a package with no[Package] section simply gets no extensions — the core behaviour (moving php-fpm/Dovecot/Exim processes into the account’s user-<uid>.slice) still works for it, because that uses the limits HestiaCP already set in user.conf
The example file ships with a [default] section plus Mini/Pro/Business — those are just examples. You can delete them all and the daemon runs fine; you only add sections for the packages where you want extra attributes.
Thanks for the feedback — I’ve added uninstall.sh to the repo.
sudo bash uninstall.sh
It removes everything install.sh put in place, and it does handle the Exim patch the way you asked:
stops and disables the psm-exim-patch.timer self-healing timer first, so it can’t re-inject the rule while we’re uninstalling,
strips the injected warn rule from /etc/exim4/exim4.conf.template,
runs update-exim4.conf and reloads Exim, so the rule disappears from the generated /var/lib/exim4/config.autogenerated too,
deletes the notifier and the patch script,
then removes the daemon binary, config, systemd unit, logrotate config, the HestiaCP per-package hooks and the psm-monitor.sh viewer.
HestiaCP’s own user-<uid>.slice cgroups and the daemon log are left untouched.
On php-fpm load limiting vs. codebyoul/php — both projects cap php-fpm CPU/RAM, but they take opposite approaches, and the overhead is a big part of the difference:
codebyoul/php is the bash daemon. It builds its own cgroup tree (/sys/fs/cgroup/<Package>/tasks/), writes CPU/memory/swap limits into it from user.conf, and moves every non-root process of a user there. It runs a second, parallel limit hierarchy the panel doesn’t know about. And on every 2 s cycle it shells out to v-list-users json, pipes it through jq for every user and package, and parses a full ps listing — that’s the real overhead: a bunch of forked processes and JSON/text parsing just to decide which cgroup a pid goes into.
process-slice-manager is the Rust rewrite. It reuses the user-<uid>.slice cgroups HestiaCP already manages and only moves php-fpm workers, Dovecot session processes and (opt-in) Exim SMTP AUTH connections into them. It never writes a limit itself — it makes those processes count against the CPUQuota/MemoryMax already configured in the panel. The monitor loop reads /proc and cgroupfs directly — no v-list-users, no jq, no ps, no awk — so there is no per-cycle parsing overhead at all. Single static binary, one systemd unit.
So: yes, it still limits php-fpm load — the workers land in the account slice where HestiaCP’s limits apply (the gap the bash version also targeted) — but the enforcement lives in the panel’s own hierarchy, and the per-cycle cost that made the bash version heavy on busy servers is gone.
I don’t know what’s wrong but it won’t start - Daemon running: no (cannot connect to /var/run/process-slice-manager.sock: No such file or directory (os error 2))
The linker cc not found error is a missing C toolchain on the fresh server — rustup installs rustc, but rustc needs a C linker (cc/gcc) to build. The installer only pulled in Rust before, so the first build on a bare box failed exactly like yours.
install.sh now self-heals both this and the other gaps. On a fresh minimal server it now:
installs missing helper utilities first: curl, wget, git and ca-certificates (needed for the rustup download over TLS),
then installs the C toolchain (build-essential on Debian/Ubuntu, gcc make on RHEL) when no cc/gcc is present — this is the exact fix for your error,
then installs Rust via rustup if cargo isn’t there yet,
Everything else (Rust, compiler, curl/wget/git, daemon, config, systemd unit, optional Exim patch) gets set up automatically. If you still hit the error, run it again — it’s idempotent and picks up where it left off.
Re: won’t start - cannot connect to /var/run/process-slice-manager.sock
That error means the daemon process isn’t running at all (no socket = no daemon). The status command couldn’t tell you why — so first, get the real reason:
systemctl status process-slice-manager
journalctl -u process-slice-manager -n 50
If it says something like “Unit hestia.service not found” or the service keeps failing with an ordering/dependency error — that was the bug, and it’s now fixed.
The old systemd unit had:
Requires=hestia.service
Requires= is a hard dependency: when hestia.service doesn’t exist on the server (it’s not present on every HestiaCP install) or is inactive, systemd refuses to start the daemon at all — so status reported “Daemon running: no (cannot connect to the socket)” with no visible reason.
Now the daemon always starts; hestia.service is only used for ordering when it happens to exist. The install.sh also gained post-start verification — if the service fails to start, the installer prints systemctl status + the last 30 journal lines and tells you the common causes instead of silently leaving it dead. And status now prints a hint pointing at systemctl/journalctl so you can always see the underlying error.
To get the new unit on your server, just re-run:
sudo bash install.sh
(or sudo systemctl daemon-reload && sudo systemctl restart process-slice-manager after updating the service file). If it still won’t start, paste the output of the two commands above and I’ll take it from there.
1785935590 [INFO] Loading user data from /usr/local/hestia/data/users
1785935590 [ERROR] Initial load of user data failed: hestia data error: no hosting users found under /usr/local/hestia/data/users
That error is actually expected behavior on a fresh HestiaCP install — and it was a bug in the daemon, now fixed.
HestiaCP starts with only the admin account, and the daemon deliberately skips admin (it’s the panel account, not a hosting user). So right after a clean install there are zero hosting users, and the old code treated “no users” as a fatal error — the daemon refused to start, which is why you saw the cannot connect to the socket message earlier too.
The fix (70d32e3) changes that: an empty user list is now not an error. The daemon starts immediately, logs a warning instead of crashing, and picks up new accounts automatically on the next reload — which the HestiaCP user-creation hook already triggers the moment you run v-add-user (or create a user in the panel).
So the flow is now:
sudo bash install.sh → daemon starts right away (even with zero users), status shows “Daemon running: yes”,
create your first user in HestiaCP → the hook fires → the daemon reloads and starts monitoring,
verify with process-slice-manager status or sudo bash /usr/local/bin/psm-monitor.sh.
To pick up the fix, just re-run the installer (it’s idempotent and safe on top of an existing install):
cd /path/to/process-slice-manager
sudo bash install.sh
If you already created users and it still reports the error, that’s a different case — please paste ls /usr/local/hestia/data/users and the first 5 lines of journalctl -u process-slice-manager -n 30, and I’ll look into it.
root@test:~/process-slice-manager# sudo bash install.sh
[ok] utils found: curl, wget, git
[ok] cargo found: cargo 1.65.0
[ok] C linker found: /usr/bin/cc
[..] building release binary...
error: failed to parse lock file at: /root/process-slice-manager/Cargo.lock
Caused by:
lock file version 4 was found, but this version of Cargo does not understand this lock file, perhaps Cargo needs to be updated?
root@test:~/process-slice-manager# rm /root/process-slice-manager/Cargo.lock
root@test:~/process-slice-manager# sudo bash install.sh
[ok] utils found: curl, wget, git
[ok] cargo found: cargo 1.65.0
[ok] C linker found: /usr/bin/cc
[..] building release binary...
Updating crates.io index
error: package serde_json v1.0.151 cannot be built because it requires rustc 1.71 or newer, while the currently active rustc version is 1.63.0
Either upgrade to rustc 1.71 or newer, or use
cargo update -p [email protected] --precise ver
where ver is the latest version of serde_json supporting rustc 1.63.0
Is it possible to make an update script? If any corrections have been made?
hat’s a stale distro toolchain. Debian/Ubuntu’s cargo (yours is 1.65) is far behind what the repo needs: the shipped Cargo.lock uses the v4 format (needs cargo ≥ 1.78) and the current dependencies want rustc ≥ 1.71. You don’t need to rm Cargo.lock — the installer now handles this automatically.
install.sh (30c538f) now checks the cargo version before building, and when it’s older than 1.78 it upgrades the toolchain via rustup (which always installs latest stable), instead of using the stale apt build. It also prefers rustup for fresh installs and only falls back to apt cargo as a last resort. The --no-rust-install flag still works — it now fails with a clear “cargo too old” message instead of a cryptic Cargo error.
So just re-run it:
cd /root/process-slice-manager
sudo bash install.sh
Expected output now:
[..] cargo 1.65.0 is too old (>= 1.78 required); upgrading...
[..] upgrading Rust toolchain via rustup...
[ok] installed Rust: cargo 1.8x.x
[ok] C linker found: /usr/bin/cc
[..] building release binary...
The deleted Cargo.lock is fine — cargo regenerates it. One tip: if you later get a Git warning about “Cargo.lock has been deleted”, you can restore it with git checkout Cargo.lock before the next run; the repo pins the exact dependency versions so builds are reproducible.
After the build you should see the daemon start and systemctl status process-slice-manager show active.
Good catch — that was a real gap. The root cause: HestiaCP runs the package hook (<Package>.sh) only on user creation and package change, but not on v-delete-user. So when you deleted the account, the daemon never received the reload signal and never found out the user was gone — the slice stayed behind.
The fix (d6a421b) adds two things:
Periodic data refresh. New config option refresh_interval (default 300 s, 0 disables). The daemon now re-reads the HestiaCP user list on that interval, so a deleted account is noticed even though no hook fires on v-delete-user:
refresh_interval = 300
You can also trigger it manually anytime: systemctl reload process-slice-manager.
Safe slice reclamation. When a uid disappears from the HestiaCP data, the daemon removes that user’s slice — but only if it’s empty. On cgroup v2, rmdir fails with EBUSY while a slice still holds any process, so:
an empty, orphaned slice of a deleted user → removed,
a slice that still has live processes → left alone (removing it would disrupt them).
Live workloads are never touched; this is purely reclaiming leftovers.
Log output looks like this after a deletion is noticed:
[INFO] Reload requested (SIGHUP)
[INFO] Loading user data from /usr/local/hestia/data/users
[INFO] Loaded 1 users / 14 packages
[INFO] uid=1004 removed empty slice /sys/fs/cgroup/user.slice/user-1004.slice
To pick it up, re-run sudo bash install.sh (it’s idempotent) or update the binary + config manually. If you want the cleanup to run faster than 5 minutes, set refresh_interval = 60.
One note: if the deleted user’s processes are still running (e.g. you deleted the account while a session was open), the slice won’t be removed until they exit — which is the intended safety behavior.
Previously no — and that was a bug. HestiaCP’s v-delete-package removes only the <name>.pkg file, not the <name>.sh next to it. Since install.sh installs one hook copy per package, deleting a package left our .sh stranded forever. That’s now fixed (ef1ca3d).
The important part is safety: HestiaCP’s packages directory can also contain your own package scripts that the panel runs at user creation. So the daemon must never delete those. The fix works like this:
The installed hook now carries a marker comment (PSM-HOOK-MARKER), so our copies are distinguishable from admin-written scripts.
On every reload the daemon removes <name>.sh files whose <name>.pkg no longer exists — but only files containing the marker. An admin-written Custom.sh without a matching package is left alone.
Because the periodic refresh (refresh_interval) already triggers reloads, deleting a package is picked up automatically within ~5 minutes (default), without any manual step:
refresh_interval = 300
Re-running sudo bash install.sh also reclaims orphaned hooks immediately, and uninstall.sh now matches the same marker.
Log output after a package is deleted:
[INFO] Reload requested (SIGHUP)
[INFO] Loading user data from /usr/local/hestia/data/users
[INFO] removed orphaned package hook /usr/local/hestia/data/packages/Mini.sh (package 'Mini' deleted)
So: the .sh of a deleted package is now cleaned up, while any .sh you wrote yourself is never touched. Just re-run sudo bash install.sh to get the new hook + binary.