Level 3 — Users, sudo and Permissions
Right now you are logged in as root, which is the most dangerous way to operate a server. This chapter creates the user structure you will use for the rest of the guide.
The root user
root is UID 0 — the superuser. The kernel skips permission checks entirely for UID 0. Root can:
- Read, modify, or delete any file, including other users' private keys
- Kill any process
- Bind privileged ports (below 1024)
- Load kernel modules
- Modify the firewall, users, and system services
- Destroy the entire system with one mistyped command
There is no "are you sure?" and no undo.
Why applications should not run as root
This is not a formality. It is the difference between a bug and a breach.
THE ESCALATION CHAIN
Suppose your NestJS app has a path-traversal bug in a file upload endpoint.
Running as deploy: the attacker reads files deploy can read — your app code and .env. Bad: they get your database password. But they cannot read /etc/shadow, cannot install a rootkit, cannot modify systemd units, and cannot hide their tracks in logs owned by root. You can detect and recover.
Running as root: the attacker reads /etc/shadow, adds an SSH key to /root/.ssh/authorized_keys, installs a persistent backdoor as a systemd service, disables logging, and pivots to any other machine your server can reach. The only safe response is to destroy the server and rebuild.
Same bug. Two completely different incidents.
Concrete reasons, beyond the headline:
| Reason | Detail |
|---|---|
| Blast radius | A compromised process can only touch what its user can touch |
| Accidental damage | An rm -rf in a deploy script as deploy deletes the app; as root it can delete the OS |
| npm postinstall scripts | Dependencies run arbitrary code at install time. As root, a malicious package owns the machine (Level 23) |
| Root-owned files | Running a build as root leaves root-owned files that later break non-root deploys with EACCES |
| Auditability | sudo logs who did what. Shared root logins log nothing useful. |
"BUT MY APP NEEDS PORT 80"
No, it does not. Nginx (started by root, then dropping to www-data for workers) binds 80 and 443. Your Node app listens on 3000 as deploy. That is the entire reason the reverse proxy exists.
If you genuinely need a non-root process on a low port, use setcap 'cap_net_bind_service=+ep' /path/to/node or a systemd socket unit — never sudo node.
Creating a deployment user
# SERVER — as root
adduser deployadduser (Debian/Ubuntu, interactive) is friendlier than the low-level useradd. It:
- Creates the user
deploy - Creates
/home/deploywith mode755, owned bydeploy:deploy - Creates a group named
deployand makes it the primary group - Copies skeleton files from
/etc/skel(.bashrc,.profile) - Sets the login shell to
/bin/bash - Prompts for a password
You will be asked for a password. Set a strong one and store it in your password manager. Even with SSH password auth disabled, you may need this password to run sudo from the web console during a lockout recovery.
WHY NOT NOPASSWD SUDO?
Passwordless sudo is convenient and it is what most CI setups end up with. But it means any process running as deploy — including a malicious npm postinstall script — can become root with zero friction. See the scoped-sudo compromise later in this chapter.
Add to the sudo group
# SERVER — as root
usermod -aG sudo deploy| Flag | Meaning |
|---|---|
-a | Append to the groups listed |
-G | Supplementary groups |
-a IS NOT OPTIONAL
usermod -G sudo deploy (without -a) replaces all supplementary groups with just sudo, silently removing the user from docker, www-data, or anything else. This breaks things in ways that are hard to trace. Always -aG.
On Ubuntu, membership in the sudo group is what grants sudo access — it is wired up in /etc/sudoers by default. (On RHEL/CentOS the equivalent group is wheel.)
# SERVER — verify
groups deploy
# deploy : deploy sudoGive the deploy user SSH access
The new user has an empty home directory and no authorized_keys, so it cannot log in yet.
# SERVER — as root, copy root's authorized keys to deploy
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keysOr from your laptop:
# LOCAL
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@203.0.113.10TEST BEFORE YOU CLOSE ANYTHING
Open a second terminal and verify the new user works before you disable root login or log out of the root session:
# LOCAL — new terminal
ssh deploy@203.0.113.10
whoami # should print: deploy
sudo whoami # should print: root (after entering deploy's password)Keep the root session open until both commands succeed. This habit — "never close your last known-good session" — will save you at least once.
Root vs sudo
| Login as root | sudo as deploy | |
|---|---|---|
| Every command is privileged | Yes | No — only the ones you prefix |
| Audit trail | Nothing (all root actions look identical) | /var/log/auth.log records user, time, command |
| Accidental damage | Unlimited | Limited unless you type sudo |
| Can be revoked per-user | No | Yes — remove from the sudo group |
| Requires re-authentication | No | Yes, with a 15-minute cached window |
sudo forces a deliberate pause before every privileged action. That pause is the point.
sudo apt update # run one command as root
sudo -u postgres psql # run as a DIFFERENT user (not root)
sudo -i # interactive root shell with root's environment
sudo -s # root shell, keeping your environment
sudo !! # re-run the previous command with sudo
sudo -l # list what YOU are allowed to run
sudo -k # forget the cached credential immediatelysudo -i IS A LOADED GUN
sudo -i gives you a full root shell where every command is privileged and the deliberate pause is gone. It is occasionally necessary (a long sequence of root operations), but the moment you finish, type exit. Do not leave a root shell sitting open in a tmux pane.
sudo and environment variables — a real deployment trap
sudo node -v
# sudo: node: command not foundsudo resets PATH for security (secure_path in /etc/sudoers), so NVM-installed binaries in /home/deploy/.nvm/... are invisible. Likewise sudo pnpm install will not find pnpm.
The correct response is do not run application tooling with sudo at all. If you need a root-owned directory writable by your app, chown it once:
sudo chown -R deploy:deploy /var/www/myappIf you genuinely must preserve your environment for one command: sudo -E command (preserve environment) or sudo env "PATH=$PATH" command. Use sparingly — -E weakens the isolation sudo provides.
Groups
A user has one primary group (usually matching their username) and any number of supplementary groups. Groups let several users share access to files without making them world-readable.
groups # my groups
groups deploy # a specific user's groups
id deploy # UID, GID and all groups, numerically
getent group sudo # who is in the sudo group
getent group dockerGroups that matter on a deployment server:
| Group | Grants | Note |
|---|---|---|
sudo | Ability to run commands as root | The main privilege boundary |
docker | Ability to talk to the Docker socket | Equivalent to root — see below |
www-data | Nginx's user/group | Give Nginx read access to static files |
adm | Read most files in /var/log | Useful for a read-only "observer" user |
postgres | PostgreSQL's system user | Do not add humans to this |
THE docker GROUP IS ROOT
Any member of the docker group can run docker run -v /:/host -it ubuntu chroot /host and become root instantly. There is no privilege boundary between the Docker group and root — this is documented upstream, not a bug.
Grant it only to users you would already trust with full sudo. If you need genuinely unprivileged container use, look at rootless Docker or Podman (Level 11).
sudo groupadd webdevs # create a group
sudo usermod -aG webdevs alice # add a user to it
sudo gpasswd -d alice webdevs # remove a user from itGROUP CHANGES NEED A NEW LOGIN
Group membership is read at login. After usermod -aG docker deploy, the current shell still does not have it. Log out and back in, or run newgrp docker for that shell. "I added myself to docker but still get permission denied" is almost always this.
File ownership and application ownership
ls -l /home/deploy/apps/myapp
# drwxr-xr-x 8 deploy deploy 4096 Aug 11 10:00 .The rule for a deployment: the deploy user owns the entire application directory.
sudo chown -R deploy:deploy /home/deploy/apps/myappRecommended modes inside the app:
| Path | Owner | Mode | Reason |
|---|---|---|---|
/home/deploy | deploy:deploy | 755 | Nginx must traverse it to reach static files |
apps/myapp/ | deploy:deploy | 755 | Normal |
apps/myapp/.env | deploy:deploy | 600 | Contains the database password. Nobody else, ever. |
apps/myapp/.output/ | deploy:deploy | 755 | Build artifacts |
apps/myapp/logs/ | deploy:deploy | 755 | PM2 writes here |
~/.ssh/ | deploy:deploy | 700 | SSH refuses looser modes |
IF NGINX SERVES STATIC FILES FROM YOUR HOME DIRECTORY
Nginx workers run as www-data. To read /home/deploy/apps/myapp/.output/public/, www-data needs x on every directory in the path: /home, /home/deploy, /home/deploy/apps, and so on. 755 on each satisfies this. If you tighten /home/deploy to 750, Nginx returns 403 Forbidden and the error log says "Permission denied".
Verify exactly what Nginx can see:
sudo -u www-data stat /home/deploy/apps/myapp/.output/public/index.html/etc/sudoers and visudo
/etc/sudoers defines who may run what as whom.
NEVER EDIT /etc/sudoers WITH A PLAIN EDITOR
A syntax error in this file breaks sudo for everyone. Since you need sudo to fix it, and root login is (correctly) disabled, you are locked out of all administration and must recover through the provider's web console as root.
Always use visudo. It edits a temporary copy and refuses to install it if the syntax is invalid.
sudo visudo # edit the main file safely
sudo visudo -f /etc/sudoers.d/deploy-pm2 # edit a drop-in file safely
sudo visudo -c # check syntax of everythingKey lines in the default Ubuntu file:
Defaults env_reset
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
root ALL=(ALL:ALL) ALL
%sudo ALL=(ALL:ALL) ALLReading %sudo ALL=(ALL:ALL) ALL:
| Part | Meaning |
|---|---|
%sudo | Applies to the group sudo (% prefix means group) |
ALL= | On all hosts |
(ALL:ALL) | May run as any user, any group |
ALL | May run any command |
Drop-in files
Modern practice is to leave /etc/sudoers alone and add files in /etc/sudoers.d/:
sudo visudo -f /etc/sudoers.d/deploy-services# Allow the deploy user to manage only the services it owns, without a password.
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp-api
deploy ALL=(root) NOPASSWD: /usr/sbin/nginx -tsudo chmod 440 /etc/sudoers.d/deploy-services # required: 0440, root-ownedThis is the scoped-sudo pattern: your CI pipeline can reload Nginx without a password, but cannot run arbitrary commands as root. It is the right compromise between "CI needs to work unattended" and "passwordless root for everything".
WHY NOT deploy ALL=(ALL) NOPASSWD: ALL
This is what most tutorials tell you to do for CI, and it means: any code running as deploy — including a compromised npm dependency's postinstall script — becomes root without any prompt. You have converted a supply-chain compromise into a full server compromise.
Scope it to specific commands. Use full absolute paths (verify with which systemctl). Never use wildcards in command paths, and never allow a command that can spawn a shell (vim, less, find, awk with -exec are all escapes to root).
Least privilege principle
Every user, process, and service should have exactly the permissions it needs to do its job, and no more.
Applied to this server:
| Actor | Should be able to | Should NOT be able to |
|---|---|---|
deploy | Own and run the app, read its own logs, reload its own services via scoped sudo | Read /etc/shadow, modify other services, install packages unprompted |
| Node app process | Read its code, write its logs, connect to localhost PostgreSQL/Redis | Write to its own source directory, read other users' files, bind port 443 |
www-data (Nginx) | Read static files, write its own logs | Read .env, execute app code, write to the app directory |
postgres | Own /var/lib/postgresql | Log in over SSH (it has /usr/sbin/nologin) |
| CI deploy key | Run one deploy script | Get an interactive shell, read arbitrary files |
HARDENING THE APP AGAINST ITSELF
An advanced but cheap win: make the application's source read-only to the process running it. Create a separate appsvc user that runs the Node process, while deploy owns the files. Then a code-execution bug cannot rewrite your source to persist a backdoor.
sudo adduser --system --no-create-home --shell /usr/sbin/nologin appsvc
sudo chown -R deploy:appsvc /home/deploy/apps/myapp
sudo chmod -R 750 /home/deploy/apps/myapp # appsvc reads, cannot write
sudo chown deploy:appsvc /home/deploy/apps/myapp/.env
sudo chmod 640 /home/deploy/apps/myapp/.env # appsvc reads it, others cannot
sudo mkdir -p /var/log/myapp && sudo chown appsvc:appsvc /var/log/myappThis adds real complexity to your deploy scripts. Adopt it when you handle sensitive data; a single-user deploy setup is a reasonable starting point for a small project. Be honest about which you are running.
Recommended production user structure
For a single-server Nuxt + NestJS deployment:
| User | Type | Shell | Purpose |
|---|---|---|---|
root | System | /bin/bash | Emergency use only, via sudo or console |
deploy | Human | /bin/bash | All day-to-day work and app ownership |
www-data | System | /usr/sbin/nologin | Nginx workers |
postgres | System | /bin/bash (created by the package) | PostgreSQL |
redis | System | /usr/sbin/nologin | Redis |
Rules:
- One human user per person. If a colleague joins, create
alice— do not sharedeploy's key. Then offboarding isdeluser alicerather than rotating everything. deployis for the application, and is the account CI authenticates as.- Service users never log in. Their shell is
/usr/sbin/nologin. - Root SSH login is disabled (Level 4).
Useful user-management commands
# SERVER
sudo adduser alice # create a user (interactive)
sudo usermod -aG sudo alice # grant sudo
sudo deluser alice # remove user, keep home directory
sudo deluser --remove-home alice # remove user and home directory
sudo passwd alice # set/change a password
sudo passwd -l alice # LOCK the password (key auth still works)
sudo usermod -s /usr/sbin/nologin bob # revoke shell access without deleting the account
last # recent login history
lastlog # last login per user
who # who is logged in right now
w # who is logged in and what they are running
sudo cat /etc/passwd # all accounts (world-readable — no passwords here)
sudo cat /etc/shadow # password hashes (root-only)OFFBOARDING CHECKLIST
When someone leaves:
sudo passwd -l theiruser— lock the password- Remove their public key from every
authorized_keyson every server sudo usermod -s /usr/sbin/nologin theiruser— revoke the shell- Check for lingering sessions:
who, thensudo pkill -u theiruser - Rotate any shared secret they had access to —
.envvalues, API keys, database passwords - Revoke their access in GitHub/GitLab and remove them from deploy-key access
Step 5 is the one people skip, and it is the one that matters.
Production Checklist — Level 3
- [ ] A non-root
deployuser exists with a strong password stored in a password manager - [ ]
deployis in thesudogroup (added with-aG, not-G) - [ ]
deployhas my SSH public key in~/.ssh/authorized_keyswith700/600permissions - [ ] I tested
ssh deploy@serverandsudo whoamiin a second terminal before closing the root session - [ ] The application directory is owned entirely by
deploy:deploy - [ ]
.envis600and owned bydeploy - [ ]
/home/deployis755so Nginx can traverse it - [ ] I never run
pnpm,node, orpm2withsudo - [ ] If passwordless sudo is needed for CI, it is scoped to specific absolute command paths in
/etc/sudoers.d/, mode440 - [ ] I only ever edit sudoers with
visudo - [ ] I understand that the
dockergroup is equivalent to root - [ ] Each human has their own account; nobody shares keys
- [ ] I have an offboarding procedure that includes rotating secrets