Run services as an unprivileged user
Running a service as root turns one bug into full server access. Give each service its own unprivileged account, or let systemd do it with DynamicUser.
Why not just run everything as root
Root can do anything on the machine: read every file, change any setting, delete the whole system. When you run a service as root, you hand all of that power to that service. If the service has a bug an attacker can exploit, they do not just get the service, they get root, and root is the whole server. Running as an unprivileged user contains the damage. A bug in a service that runs as a limited account gives an attacker only what that account can touch, which should be almost nothing.
This is the principle of least privilege: give each part of the system exactly the access it needs to do its job, and nothing more. It is the single most effective habit for limiting the blast radius of a compromise, and on a modern server it costs you almost nothing to apply.
A dedicated account per service
The classic approach is to create a separate system user for each service, one that owns only that service's files and cannot log in. A system account for a web app might look like this:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin appsvc
Each flag matters. --system makes it a service account, not a human login. --no-create-home skips a home directory it does not need. --shell /usr/sbin/nologin means that even if an attacker somehow gets hold of the account, they cannot open a shell with it. The account exists only to own a process and its files.
Then give that user just the files it needs, and no more:
sudo chown -R appsvc:appsvc /opt/myapp
Now the service reads and writes its own directory and has no business anywhere else on the disk. If it is ever exploited, the files the attacker can change are limited to /opt/myapp; the account can still read anything world-readable, but it cannot modify the rest of the system.
Let systemd run it as that user
Once the account exists, tell systemd to run the service as it. In the unit file, one line does it:
[Service]
ExecStart=/opt/myapp/bin/server
User=appsvc
Group=appsvc
User=appsvc means the process starts with that account's limited privileges instead of root's. This is the normal, well-trodden way to run an application under systemd, and it is worth doing for every service you write a unit for.
Or skip the account entirely with DynamicUser
systemd can go one step further and create a throwaway user for you, one that exists only while the service runs. Set DynamicUser=yes and you do not manage an account at all:
[Service]
ExecStart=/opt/myapp/bin/server
DynamicUser=yes
StateDirectory=myapp
At start, systemd allocates an unused user ID; at stop, it releases it. The service also gets a private /tmp, a read-only view of most of the filesystem, and a writable state directory under /var/lib/myapp that StateDirectory= sets up and hands to it. For a self-contained service that only needs its own state directory, DynamicUser=yes is the least-effort way to get strong isolation, because there is no long-lived account for an attacker to target at all.
Writing units by hand is fiddly, and getting the hardening directives right is most of the value. The generator in the systemd service and timer guide can fill in these options for you so the unit is correct the first time.
How this fits with the rest
Least privilege is one layer, and it works with the others rather than replacing them. A default-deny firewall controls what can reach the service; running it as an unprivileged user controls what the service can do if it is breached; and hardened SSH keeps attackers off the box in the first place. No single one of these is enough, and together they mean a bug in one service does not become a compromise of the whole server.
Before you move on, run through a hardening checklist for the whole box and generate a personalised copy to work from:
FAQ
Why should I not run a service as root?
Because root can do anything on the machine, a service running as root that gets exploited hands the attacker the entire server, not just the service. Running the service as a limited, unprivileged account contains the damage to whatever that account can access. Reserve root for administration, and run every long-running service as a restricted user.
How do I create a user that cannot log in?
Run sudo useradd --system --no-create-home --shell /usr/sbin/nologin NAME. The nologin shell means the account cannot open an interactive session even if its credentials are stolen, --system marks it as a service account, and --no-create-home skips a home directory it does not need. Give it ownership of only its own files with chown.
What is systemd DynamicUser?
DynamicUser=yes tells systemd to create a temporary user for the service that exists only while it runs, so you never manage a long-lived account. It also gives the service a private /tmp, a mostly read-only filesystem view, and a managed state directory. It is the least-effort way to run a self-contained service under a throwaway, low-privilege identity.
Does running as a non-root user replace a firewall?
No. They protect different things. Running as an unprivileged user limits what a service can do if it is breached, while a firewall limits what can reach the service at all. Use both, along with hardened SSH, so each layer covers what the others cannot.
What files should the service user own?
Only the files the service actually needs, and nothing more. Give the account ownership of its own working directory and its data, and leave everything else owned by root. A good pattern is sudo chown -R svc-app:svc-app /opt/svc-app for the application directory, while configuration under /etc stays owned by root and is only readable by the service. The goal is that if the process is ever compromised, the files it can change are limited to its own data, not the rest of the system.