Default systemd unit files generated by package managers often run with sweeping privileges. If an attacker compromises your application process, they'll inherit those system privileges to pivot across the entire operating system. You don't want your app reading arbitrary files or calling dangerous kernel APIs. You need to restrict file system access, network capabilities, and system calls explicitly inside the unit file. For a broader look at isolating persistent background tasks, you can review Systemd Services for Long-Running Processes.
Standard package installations drop software onto disk and attach naive service files that inherit global permissions. It's an easy trap. We'll change that behavior entirely.
Enforcing Dynamic Users and Isolation
The fastest way to reduce the blast radius of a compromised daemon is to hand off user management to systemd itself. The DynamicUser=yes directive creates an unprivileged user and group on the fly when your service starts, and tears them down when it stops. These accounts don't have a home directory and can't log in via traditional shells. They exist for the lifetime of the process and nothing more.
Here's a complete, hardened unit file for a background worker or web daemon:
[Unit]
Description=Secure Node Application Worker
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/node /var/www/app/index.js
DynamicUser=yes
ProtectSystem=strict
ProtectHome=yes
NoNewPrivileges=yes
PrivateTmp=yes
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
SystemCallArchitectures=native
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
If you prefer an approach tailored specifically to ephemeral accounts without manual UID tracking, check out Securing Daemons with Systemd Dynamic Users for additional implementation patterns. Dynamic users will fail if your application tries to write to arbitrary paths outside its designated state directories.
You'll need to configure explicit storage parameters when using dynamic users. Systemd creates specific state directories automatically if you define them in the unit file. That avoids manual chown scripts in your deployment pipelines.
Here's how you add managed state paths to the service definition:
[Service]
StateDirectory=app
LogsDirectory=app
CacheDirectory=app
When you use these directives, systemd mounts isolated directories under /var/lib, /var/log, and /var/cache respectively. Your application can write logs and state safely without needing write access to the rest of the host operating system.
Locking Down File System Access
By default, systemd services can read and write to the entire host file system unless you explicitly block them. The ProtectSystem=strict directive mounts the entire root directory, including /usr and /etc, as read-only for the process. If your application needs to read specific configuration files, you'll have to permit them individually, but keeping the baseline locked is mandatory.
You should also isolate the temporary directories. Setting PrivateTmp=yes gives your service its own private /tmp and /var/tmp directories, preventing other local users from snooping on temporary files or executing symlink attacks against your application. Attackers often target shared temporary directories to hijack local file operations.
Here's how you configure specific writable storage paths while keeping the rest of the file system locked down:
[Service]
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/app /var/log/app
InaccessiblePaths=/boot /home /root
The ProtectHome=yes directive hides directories like /home and /root completely, making them appear empty to the running process. If a file-reading vulnerability exists in your application, the attacker won't be able to harvest SSH keys or configuration files from adjacent user directories. They'll hit an empty directory tree instead.
Mount namespaces provide this isolation without requiring a heavy container runtime. It's built directly into the kernel and managed via systemd.
Restricting System Calls and Capabilities
Linux capabilities divide traditional root privileges into distinct units. Most web apps and background daemons don't need any capabilities at all. Setting CapabilityBoundingSet= to an empty value strips all capabilities from the process, preventing it from binding to privileged ports below 1024, changing ownership of files, or bypassing permission checks.
You must also disable new privilege acquisition entirely by setting NoNewPrivileges=yes. This blocks setuid and setgid binaries from elevating privileges even if the binary has the SUID bit set on disk. Combine this with RestrictNamespaces=yes to prevent the process from creating new kernel namespaces, which blocks container escape techniques and advanced local privilege escalation vectors.
System call filtering via seccomp is another layer you can add. By default, blocking dangerous system call architectures stops attackers from executing 32-bit payloads on 64-bit kernels. You'll specify this with SystemCallArchitectures=native.
Finally, always test your unit file configurations before pushing them to production servers. Run the verify command to catch syntax errors or unsupported directives:
systemd-analyze verify /etc/systemd/system/app.service
If the command returns output, inspect the line numbers immediately. A single typo in a sandboxing directive can cause systemd to refuse to start the service entirely, resulting in unexpected downtime during your deployment window. Fix the error, reload the daemon manager via systemctl daemon-reload, and restart your application service.













