Every service you run, whether a Node.js app, a Python worker or a game server, should run as its own unprivileged account. If the app has a bug or is attacked, the damage stays inside what that account can touch. This guide shows how to create a system user without a password or shell, give it a working folder and run a service as that user with systemd.
Before you start
- A Linux VPS with Ubuntu 22.04 or 24.04, Debian 12, or AlmaLinux or Rocky Linux 8 or 9.
- Root or sudo access over SSH.
- The app you want to run, and a name for its account, such as
myapp.
System users vs normal users
A system user is an ordinary account with a few differences:
- Its UID comes from the system range, usually below 1000, so it is hidden from login screens and user lists that show people.
- It normally has no password, so nobody can log in with it.
- It normally has the shell
/usr/sbin/nologin, so evensuinto it is refused. - It has no home folder in
/home, or a home folder where the app keeps its data.
Packages already use this pattern: www-data, mysql and postgres are all system users.
Create the user on AlmaLinux and Rocky Linux
sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp--systempicks a UID from the system range and skips password ageing.--no-create-home(short-M) avoids an unwanted folder.--shell /usr/sbin/nologinblocks interactive logins.
A private group named myapp is created as well, because user private groups are the default on these systems.
If the app needs a data folder, you can set it as the home folder in the same command:
sudo useradd --system --home-dir /var/lib/myapp --create-home --shell /usr/sbin/nologin myappCreate the user on Ubuntu and Debian
useradd works exactly the same way as above. Debian also offers adduser --system, which picks safe defaults for you:
sudo adduser --system --group --no-create-home myapp--systemuses a system UID, no password and thenologinshell.--groupcreates a matchingmyappgroup. Without it, the user joins the sharednogroupgroup.--home /var/lib/myappsets a data folder instead of--no-create-homeif you need one.
Give the app a place for its files
Keep code owned by root or a deploy user, and give the service account write access only where it needs it, such as data, cache or logs:
sudo mkdir -p /opt/myapp /var/lib/myapp
sudo chown -R root:root /opt/myapp
sudo chown myapp:myapp /var/lib/myapp
sudo chmod 750 /var/lib/myappThis way, even if the app is compromised, it cannot rewrite its own code.
Run the service as that user
Set the user in the systemd unit. A minimal example in /etc/systemd/system/myapp.service:
[Unit]
Description=My app
After=network.target
[Service]
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/myapp
Restart=on-failure
[Install]
WantedBy=multi-user.targetLoad and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now myappThe full walkthrough is in Create a systemd service.
Tip: systemd can also create a throwaway user for you each time the service starts with DynamicUser=yes. It suits simple stateless services. For apps that keep files between restarts, a fixed system user is easier to reason about.
Run a one-off command as the user
Because the account has no shell, su myapp is refused. Use sudo -u to run a single command as that user:
sudo -u myapp /opt/myapp/bin/myapp --versionIf you really need an interactive shell for debugging, pass the shell explicitly:
sudo -u myapp -s /bin/bashExit as soon as you are done.
Ports below 1024
Unprivileged users cannot bind to ports such as 80 or 443. The usual fix is to run the app on a high port, such as 3000 or 8000, and put Nginx in front. If the app must listen directly, grant just that ability in the unit file:
AmbientCapabilities=CAP_NET_BIND_SERVICECheck it works
Confirm the account details:
getent passwd myappmyapp:x:998:998::/var/lib/myapp:/usr/sbin/nologinThe UID is below 1000 and the shell is nologin. Then check that the service really runs as that user:
ps -o user,pid,cmd -C myappThe USER column should show myapp, not root.
Troubleshooting
- "Permission denied" in the app log: the account cannot write somewhere. Find the path in the error, then grant access with
chown myapp:myappon that folder only. su myappsays "This account is currently not available": that is expected withnologin. Usesudo -u myappinstead.- App fails to bind port 80 or 443: non-root users cannot use ports below 1024. Use a reverse proxy, or add
CAP_NET_BIND_SERVICEas above. - "useradd: user 'myapp' already exists": a package or earlier attempt created it. Check with
getent passwd myappand reuse it if the settings fit.
Next steps
- Restrict the service further: Sandbox systemd services.
- Example for a real app: Run Node.js as a systemd service.
