19.4 User and Group: Running Services as Non-Root
Right, so you’ve written a service unit. It runs. You’re a hero. But let me guess: it’s running as root, isn’t it? We’ve all been there. It’s the path of least resistance, the default, the “I’ll fix it later” that becomes “oh god we’re in production.” Running everything as the almighty root user is like using a bazooka to open a beer—it works, but the collateral damage potential is catastrophic. The core philosophy of systemd, and of modern Linux administration, is to grant only the privileges you need, and nothing more. This is where User and Group come in.
The Why: Containment and Common Sense
Think of a user as a security container. If your nifty little web app service gets compromised because of some bug you didn’t know about, the attacker inherits the privileges of the user running it. If that user is root, they now own your entire system. Game over. If that user is myappuser, whose only permission is to read and write to a specific directory /opt/myapp, the damage is contained to that directory. This is the principle of least privilege, and it’s not just good practice; it’s basic system hygiene.
Crafting the Dedicated User
Your first step isn’t in the unit file; it’s on the system itself. You need a user and group that exist solely for this service. Don’t be tempted to use nobody or www-data for everything. If ten different services run as nobody, a breach in one affects all ten. Isolation is key.
We create a system user (--system) with no login shell (--shell /usr/sbin/nologin), no home directory (--no-create-home), and we assign it a group with the same name. The --user-group flag is a handy shortcut for that.
sudo groupadd --system myappgroup
sudo useradd --system --shell /usr/sbin/nologin --no-create-home -g myappgroup myappuser
Now, this user exists but can’t log in. It’s a digital ghost, perfect for running processes.
The Unit File Syntax: It’s Simple (Mostly)
Telling systemd to use this user is straightforward. In your service unit (e.g., /etc/systemd/system/myapp.service), you add the User and Group directives under the [Service] section.
[Unit]
Description=My Brilliant App
After=network.target
[Service]
Type=simple
User=myappuser
Group=myappgroup
ExecStart=/usr/bin/python3 /opt/myapp/app.py
Restart=on-failure
# Optional but highly recommended: Tighten the security profile
NoNewPrivileges=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
That’s the basics. Reload systemd (sudo systemctl daemon-reload) and start your service. It will now run as myappuser:myappgroup. Congratulations, you’re no longer a barbarian.
The Devil’s in the Details: Permissions and Paths
Here’s where everyone trips up. Your service, now a mere mortal user, might try to do something and get promptly slapped with a Permission denied error. You have to think like this user.
- Logs: If your service writes its own log file to
/var/log/myapp.log, doesmyappuserhave write permission to that file? Probably not. You’ll need tochown myappuser:myappgroup /var/log/myapp.logand ensure the permissions are correct. - PID Files: If it writes a PID file to
/var/run, same story. The modern, systemd-way is to useRuntimeDirectory, which we’ll get to in a second. - Network Ports: Binding to a privileged port (anything below 1024, like 80 or 443)? That’s a
root-only privilege. Your service will fail. The correct solution is rarely to run the service as root; it’s to use a reverse proxy (like nginx or Caddy) as a front-end, which can run as root to bind to port 80/443 and then hand off traffic to your high-numbered-port service.
systemd’s Helpful Security Built-Ins
Instead of manually chowning a bunch of directories, systemd can do it for you, securely and transiently. This is a best practice you should adopt.
RuntimeDirectory: Creates a directory under/run(a tmpfs, so it’s in RAM) for your service, owned by your service user. Perfect for PID files, sockets, or temporary runtime state.StateDirectoryandCacheDirectory: Creates directories under/var/liband/var/cacherespectively, owned by your service user. Perfect for persistent state and non-critical cached data.
Let’s upgrade our unit file:
[Service]
Type=simple
User=myappuser
Group=myappgroup
RuntimeDirectory=myapp
RuntimeDirectoryMode=0750
StateDirectory=myapp
StateDirectoryMode=0750
ExecStart=/usr/bin/python3 /opt/myapp/app.py
Restart=on-failure
Now, when the service starts, systemd automatically ensures:
/run/myappexists and is owned bymyappuser:myappgroupwith mode 750./var/lib/myappexists and is owned bymyappuser:myappgroupwith mode 750.
Your app can then be configured to write its PID file to /run/myapp/app.pid and its data to /var/lib/myapp/data.json. No manual permission hacking required. Elegant.
The “DynamicUser” Power Move
For the ultimate in containment, systemd offers a killer feature: DynamicUser=yes. This creates a user and group dynamically at service start and destroys them at service stop. This user doesn’t even exist in /etc/passwd; it’s a truly ephemeral, namespaced user. It’s the closest you can get to a container without actually using one. It’s fantastic for stateless services.
[Service]
Type=simple
DynamicUser=yes
# You can still assign a name, otherwise it's generated
User=myapp-dynamic
Group=myapp-dynamic
RuntimeDirectory=myapp-dynamic
StateDirectory=myapp-dynamic
ExecStart=/usr/bin/python3 /opt/myapp/app.py
The catch? The user and its files vanish on stop. This is why you must pair it with StateDirectory and RuntimeDirectory—systemd will handle the ownership correctly for the persistent /var/lib data even for this dynamic user. It’s brilliant engineering, honestly.
The transition away from root is a sign of a mature service configuration. It forces you to think explicitly about your service’s needs, its resources, and its boundaries. It’s a bit more work upfront, but it pays for itself the first time it stops a bad day from becoming a catastrophic one.