Skip to content

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:

ReasonDetail
Blast radiusA compromised process can only touch what its user can touch
Accidental damageAn rm -rf in a deploy script as deploy deletes the app; as root it can delete the OS
npm postinstall scriptsDependencies run arbitrary code at install time. As root, a malicious package owns the machine (Level 23)
Root-owned filesRunning a build as root leaves root-owned files that later break non-root deploys with EACCES
Auditabilitysudo 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 ​

bash
# SERVER — as root
adduser deploy

adduser (Debian/Ubuntu, interactive) is friendlier than the low-level useradd. It:

  1. Creates the user deploy
  2. Creates /home/deploy with mode 755, owned by deploy:deploy
  3. Creates a group named deploy and makes it the primary group
  4. Copies skeleton files from /etc/skel (.bashrc, .profile)
  5. Sets the login shell to /bin/bash
  6. 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 ​

bash
# SERVER — as root
usermod -aG sudo deploy
FlagMeaning
-aAppend to the groups listed
-GSupplementary 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.)

bash
# SERVER — verify
groups deploy
# deploy : deploy sudo

Give the deploy user SSH access ​

The new user has an empty home directory and no authorized_keys, so it cannot log in yet.

bash
# 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_keys

Or from your laptop:

bash
# LOCAL
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@203.0.113.10

TEST 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:

bash
# 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 rootsudo as deploy
Every command is privilegedYesNo — only the ones you prefix
Audit trailNothing (all root actions look identical)/var/log/auth.log records user, time, command
Accidental damageUnlimitedLimited unless you type sudo
Can be revoked per-userNoYes — remove from the sudo group
Requires re-authenticationNoYes, with a 15-minute cached window

sudo forces a deliberate pause before every privileged action. That pause is the point.

bash
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 immediately

sudo -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 ​

bash
sudo node -v
# sudo: node: command not found

sudo 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:

bash
sudo chown -R deploy:deploy /var/www/myapp

If 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.

bash
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 docker

Groups that matter on a deployment server:

GroupGrantsNote
sudoAbility to run commands as rootThe main privilege boundary
dockerAbility to talk to the Docker socketEquivalent to root — see below
www-dataNginx's user/groupGive Nginx read access to static files
admRead most files in /var/logUseful for a read-only "observer" user
postgresPostgreSQL's system userDo 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).

bash
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 it

GROUP 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 ​

bash
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.

bash
sudo chown -R deploy:deploy /home/deploy/apps/myapp

Recommended modes inside the app:

PathOwnerModeReason
/home/deploydeploy:deploy755Nginx must traverse it to reach static files
apps/myapp/deploy:deploy755Normal
apps/myapp/.envdeploy:deploy600Contains the database password. Nobody else, ever.
apps/myapp/.output/deploy:deploy755Build artifacts
apps/myapp/logs/deploy:deploy755PM2 writes here
~/.ssh/deploy:deploy700SSH 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:

bash
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.

bash
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 everything

Key lines in the default Ubuntu file:

sudoers
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) ALL

Reading %sudo ALL=(ALL:ALL) ALL:

PartMeaning
%sudoApplies to the group sudo (% prefix means group)
ALL=On all hosts
(ALL:ALL)May run as any user, any group
ALLMay run any command

Drop-in files ​

Modern practice is to leave /etc/sudoers alone and add files in /etc/sudoers.d/:

bash
sudo visudo -f /etc/sudoers.d/deploy-services
sudoers
# 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 -t
bash
sudo chmod 440 /etc/sudoers.d/deploy-services   # required: 0440, root-owned

This 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:

ActorShould be able toShould NOT be able to
deployOwn and run the app, read its own logs, reload its own services via scoped sudoRead /etc/shadow, modify other services, install packages unprompted
Node app processRead its code, write its logs, connect to localhost PostgreSQL/RedisWrite to its own source directory, read other users' files, bind port 443
www-data (Nginx)Read static files, write its own logsRead .env, execute app code, write to the app directory
postgresOwn /var/lib/postgresqlLog in over SSH (it has /usr/sbin/nologin)
CI deploy keyRun one deploy scriptGet 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.

bash
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/myapp

This 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.

For a single-server Nuxt + NestJS deployment:

UserTypeShellPurpose
rootSystem/bin/bashEmergency use only, via sudo or console
deployHuman/bin/bashAll day-to-day work and app ownership
www-dataSystem/usr/sbin/nologinNginx workers
postgresSystem/bin/bash (created by the package)PostgreSQL
redisSystem/usr/sbin/nologinRedis

Rules:

  1. One human user per person. If a colleague joins, create alice — do not share deploy's key. Then offboarding is deluser alice rather than rotating everything.
  2. deploy is for the application, and is the account CI authenticates as.
  3. Service users never log in. Their shell is /usr/sbin/nologin.
  4. Root SSH login is disabled (Level 4).

Useful user-management commands ​

bash
# 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:

  1. sudo passwd -l theiruser — lock the password
  2. Remove their public key from every authorized_keys on every server
  3. sudo usermod -s /usr/sbin/nologin theiruser — revoke the shell
  4. Check for lingering sessions: who, then sudo pkill -u theiruser
  5. Rotate any shared secret they had access to — .env values, API keys, database passwords
  6. 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 deploy user exists with a strong password stored in a password manager
  • [ ] deploy is in the sudo group (added with -aG, not -G)
  • [ ] deploy has my SSH public key in ~/.ssh/authorized_keys with 700/600 permissions
  • [ ] I tested ssh deploy@server and sudo whoami in a second terminal before closing the root session
  • [ ] The application directory is owned entirely by deploy:deploy
  • [ ] .env is 600 and owned by deploy
  • [ ] /home/deploy is 755 so Nginx can traverse it
  • [ ] I never run pnpm, node, or pm2 with sudo
  • [ ] If passwordless sudo is needed for CI, it is scoped to specific absolute command paths in /etc/sudoers.d/, mode 440
  • [ ] I only ever edit sudoers with visudo
  • [ ] I understand that the docker group is equivalent to root
  • [ ] Each human has their own account; nobody shares keys
  • [ ] I have an offboarding procedure that includes rotating secrets

Next: Level 4 — Initial Server Security →