Permissions are the first thing you check when something “works on my machine” and fails on the server, and the last thing anybody explains properly. Ten minutes here removes an entire category of confusing errors.
Topic 1: Identity — Users, Groups and Where They Live
Every process runs as a UID (user ID) and belongs to a set of GIDs (group IDs). The names you see are a convenience; the kernel only ever compares numbers.
id # your identity, numerically and by name
id appuser # somebody else's
whoami # just the username
groups # groups you belong to
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo),999(docker)
That docker group is worth staring at: membership is equivalent to root, because anyone who can talk to the Docker socket can start a container that mounts the host filesystem. Adding a user to docker is a privilege grant, not a convenience.
The four files that define identity:
| File | Holds | Readable by |
|---|---|---|
/etc/passwd | Username, UID, GID, home directory, login shell. No passwords, despite the name. | Everyone |
/etc/shadow | Password hashes and ageing policy. | root only |
/etc/group | Group names, GIDs, and supplementary members. | Everyone |
/etc/sudoers | Who may run what as whom. Edit with visudo, never directly. | root only |
getent passwd deploy # the right way to query -- works with LDAP/SSSD too
awk -F: '$3 >= 1000 {print $1, $3, $7}' /etc/passwd # human accounts and their shells
awk -F: '$2 == "" {print $1}' /etc/shadow # accounts with NO password set
A login shell of /usr/sbin/nologin or /bin/false is how service accounts are prevented from being logged into interactively — worth confirming on any account you did not create.
Managing accounts:
sudo useradd -m -s /bin/bash -G docker alice # -m creates the home dir
sudo passwd alice
sudo usermod -aG sudo alice # -a is MANDATORY with -G
sudo gpasswd -d alice docker # remove from one group
sudo userdel -r alice # -r also removes the home directory
Common mistake: usermod -G docker alice without the -a. -G replaces the entire supplementary group list, so this silently removes alice from sudo and every other group. It is one of the fastest ways to lock yourself out of a box.
Topic 2: The Permission Bits
Every file carries three permission triplets: one for the owner, one for the group, one for everyone else (often called “other” or “world”).
$ ls -l deploy.sh
-rwxr-xr-- 1 deploy ops 2048 Aug 5 09:14 deploy.sh
│└┬┘└┬┘└┬┘ └──┬─┘ └┬┘
│ │ │ │ │ └── group
│ │ │ └── other │
│ │ └───── group └─────── owner
│ └──────── owner
└────────── file type
The leading character is a type, not a permission:
| Char | Type |
|---|---|
- | Regular file |
d | Directory |
l | Symbolic link |
c / b | Character / block device |
s | Socket |
p | Named pipe (FIFO) |
What the bits mean on a directory — the part everyone gets wrong:
The same three letters mean something different for directories, and this catches people constantly:
| Bit | On a file | On a directory |
|---|---|---|
r | Read the contents | List the names inside |
w | Modify the contents | Create and delete entries inside |
x | Execute it | Enter it — cd into it, or traverse it in a path |
Two consequences follow that look like bugs until you know this:
- A directory with
rbut noxlets youlsthe names but not read any of the files, because you cannot traverse into it. won a directory lets you delete a file you do not own and cannot write to — deletion changes the directory, not the file. That is what the sticky bit exists to prevent.
Common mistake: Fixing a “permission denied” on /data/app/config.yml by chmod-ing the file, when the missing bit is x on /data or /data/app. Every directory in the path needs execute for you to reach the file. Check the whole chain with namei -l /data/app/config.yml.
Topic 3: chmod, chown and umask
chmod 750 deploy.sh # numeric: exact, unambiguous, what runbooks use
chmod u+x deploy.sh # symbolic: add execute for the owner
chmod go-w deploy.sh # remove write from group and other
chmod -R u+rwX,go-rwx /data # capital X = execute only on DIRECTORIES, not files
chown deploy:ops deploy.sh # owner and group together
chown -R deploy: /srv/app # trailing colon = set group to the user's own group
chgrp ops deploy.sh # group only
The capital X in that fourth line is a genuinely useful trick: recursive +x makes every text file executable, which is noise at best. +X grants execute only where it makes sense — directories, and files that already had it somewhere.
umask — why new files are never 666:
umask is a mask of bits to remove from the default mode of anything you create. The kernel defaults are 666 for files and 777 for directories; the umask subtracts from those.
umask # 0022 on most systems
umask -S # u=rwx,g=rx,o=rx -- the same thing, readable
| Base | umask 022 | Result | |
|---|---|---|---|
| File | 666 | -022 | 644 — rw-r—r— |
| Directory | 777 | -022 | 755 — rwxr-xr-x |
Files never get the execute bit by default no matter what the umask says — that is why a script you just wrote needs chmod +x before it will run.
A umask of 077 is the hardened choice for a shared host: new files are 600, readable by nobody but you. Set it in /etc/profile for interactive sessions or UMask= in a systemd unit for a service.
Try it yourself: Run umask, then touch t1 && mkdir d1 && ls -ld t1 d1. Confirm the modes match the arithmetic above. Then umask 077, repeat with new names, and compare.
Topic 4: The Special Bits — setuid, setgid, sticky
Three extra bits sit above the nine you have seen. They appear as a fourth octal digit and change how execution and ownership behave.
| Bit | Octal | Shows as | Effect |
|---|---|---|---|
| setuid | 4000 | s in the owner’s x slot | The program runs as its owner, not as you. |
| setgid | 2000 | s in the group’s x slot | On a file: runs as the owning group. On a directory: new files inherit the directory’s group. |
| sticky | 1000 | t in the other x slot | In a shared directory, only the file’s owner may delete it. |
setuid, the one to respect:
ls -l /usr/bin/passwd
-rwsr-xr-x 1 root root 68208 /usr/bin/passwd
^
passwd must edit /etc/shadow, which only root can write. The setuid bit lets any user run it as root for the duration. This is necessary — and it is also why every setuid binary is a potential privilege-escalation path. A bug in one is a root shell for whoever finds it.
# Audit: every setuid binary on the box
find / -xdev -perm -4000 -type f -exec ls -l {} \; 2>/dev/null
Run that on a host you own and confirm you recognise every result. An unexpected setuid binary in /tmp or a user’s home directory is an incident, not a curiosity.
The sticky bit and /tmp:
ls -ld /tmp
drwxrwxrwt 10 root root 4096 Aug 5 09:14 /tmp
^
/tmp is world-writable — it has to be. Without the sticky bit, any user could delete any other user’s temp files, because write permission on a directory is what governs deletion. The t restricts deletion to each file’s owner.
chmod 1777 /shared # sticky + rwx for all
chmod 2775 /team # setgid: everything created inherits the 'team' group
That setgid directory trick is the standard fix for “the other team cannot read the files I put in the shared folder” — without it, each file gets the creator’s primary group.
Topic 5: sudo — Controlled Escalation
sudo runs one command as another user (root by default), logging who did what.
sudo systemctl restart nginx
sudo -u postgres psql # as a specific user, not root
sudo -i # a full root login shell
sudo -l # what am I permitted to run?
sudo -E ./script.sh # preserve MY environment variables
sudo -E matters more than it seems. By default sudo resets the environment for safety, so a PATH or proxy variable you set is not there when the command runs. That is why a script works interactively and then fails under sudo for no visible reason.
Granting narrowly:
# /etc/sudoers.d/deploy — always create files here, never edit /etc/sudoers directly
%ops ALL=(ALL) /bin/systemctl restart nginx, /bin/systemctl reload nginx
deploy ALL=(ALL) NOPASSWD: /usr/local/bin/deploy.sh
Always validate before you log out — a syntax error in sudoers can lock everyone out of root:
sudo visudo -c -f /etc/sudoers.d/deploy
Common mistake: Granting %ops ALL=(ALL) /bin/vi or any command with a shell escape. vi can spawn a shell, so that line is functionally ALL=(ALL) ALL. The same applies to find, less, awk, and anything that runs arbitrary commands — narrow-looking grants that are actually full root.
Try it yourself: Run sudo -l on any host you have access to and read exactly what you are permitted to run. Then run sudo env | wc -l and compare with env | wc -l to see how much sudo strips.