Linux users, groups and permissions
Who can read, write and run what: rwx, chmod, chown, sudo and the bugs they cause.
In 30 seconds
Every file on Linux has an owner, a group and three sets of permissions: read, write and execute. Every process runs as some user. Most 'permission denied' errors on servers come from a mismatch between the user an app runs as and the owner of the files it needs. Understand that model, and you can fix those errors safely instead of reaching for chmod 777.
Why I use it
- I use a separate system user per site or app, so one compromised site cannot read or change another one.
- Permission errors are among the most common production issues I fix; one recent case was a cache directory the app could not write to.
- Least privilege is part of my security baseline for every server: people log in as themselves, use sudo when needed, and nobody works as root.
How it works
01Users, groups and root
A user is an identity with a numeric ID (UID). Humans are users, but so are services: nginx usually runs as www-data on Ubuntu (nginx on RHEL), PostgreSQL as postgres. A group is a named set of users, which lets you give several users the same access. root (UID 0) can do anything and bypasses normal permission checks, which is why you should not work as root day to day. User accounts are listed in /etc/passwd, password hashes in /etc/shadow (readable only by root), and groups in /etc/group. The id command shows who you are and which groups you belong to.
whoami # current user
id # uid, gid and all groups
id www-data # who is the web server user?
getent passwd your_user # account details
groups your_user # group membership
sudo adduser your_user # create a user (Debian/Ubuntu, interactive)
sudo useradd -m -s /bin/bash your_user # portable, non-interactive
sudo usermod -aG www-data your_user # ADD to a group (-a matters!)
02Reading rwx and octal permissions
Run ls -l and you will see something like -rw-r--r-- 1 deploy www-data. The first character is the type (- file, d directory, l link). Then come three triplets: the owner's rights, the group's rights and everyone else's. r is read, w is write, x is execute. On a directory the meaning shifts: r lets you list names, w lets you create and delete files inside, and x lets you enter it and reach files by path. Each triplet is also a number: r = 4, w = 2, x = 1, added together. So 644 is rw-r--r-- (owner writes, others read), 755 is rwxr-xr-x, 600 is private to the owner and 640 lets the group read.
- 644: normal files. 755: directories and scripts. 600: secrets like SSH keys and .env files.
- 640 / 750: owner full, group read-only, others nothing; good for app config shared with the web server group.
- 777 means anyone on the system can write. It hides the real problem and opens a security hole.
- Special bits: setgid on a directory (2775) makes new files inherit the directory's group; sticky bit (1777, as on /tmp) stops users deleting others' files.
03chmod, chown and fixing ownership
chmod changes permissions, chown changes owner and group. Both accept -R for recursive changes, which is powerful and dangerous: a careless chmod -R on the wrong directory can break SSH or a whole system. Use symbolic modes for small, precise changes (u+x adds execute for the owner, g-w removes write from the group) and octal modes when you want an exact result. A common pattern is to give files and directories different modes using find, because directories need x and normal files usually should not have it.
chmod 600 ~/.ssh/id_ed25519 # private key: owner only
chmod u+x deploy.sh # make a script executable
chmod g-w config.php # remove group write
sudo chown deploy:www-data -R /var/www/app
# Directories 755, files 644, in one tree
find /var/www/app -type d -exec chmod 755 {} +
find /var/www/app -type f -exec chmod 644 {} +
# Inspect a path permission by permission
namei -l /var/www/app/storage/logs/app.log
04sudo and /etc/sudoers
sudo runs a single command as root (or another user) after checking that you are allowed to. Every use is logged, which is much better for accountability than sharing a root password. On Ubuntu, members of the sudo group get full sudo rights; on RHEL the group is wheel. Rules live in /etc/sudoers and in files under /etc/sudoers.d. Never edit them with a normal editor: use visudo, which checks the syntax before saving. A broken sudoers file can lock everyone out of sudo. You can also grant narrow rights, for example letting a deploy user restart one service without a password and nothing else. Recent Ubuntu releases ship sudo-rs, a memory-safe rewrite with the same commands and config.
sudo usermod -aG sudo your_user # full sudo (wheel on RHEL)
sudo -l # what am I allowed to run?
sudo -u postgres psql # run as another user
sudo visudo -f /etc/sudoers.d/deploy
# Content of /etc/sudoers.d/deploy:
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.service
05umask, service users and ACLs
umask decides the default permissions of new files by removing bits. The common default 022 gives new files 644 and new directories 755; a umask of 002 gives 664 and 775, which suits teams sharing a group. For apps, create a dedicated system user with no login shell, and make it the owner of only what the app must write. ACLs (access control lists) add extra rules on top of the owner/group/other model, for example giving one more user write access to a single directory. They are handy, but hidden from a quick ls -l (look for a + at the end of the mode), so use them sparingly and document them.
umask # show current mask, e.g. 0022
sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp
sudo chown -R myapp:myapp /var/lib/myapp
# ACL: let www-data write to one directory, now and for new files
sudo setfacl -R -m u:www-data:rwX /var/www/app/storage
sudo setfacl -R -d -m u:www-data:rwX /var/www/app/storage
getfacl /var/www/app/storage
06The classic bug: the web server cannot write
A typical case: a PHP or Node app works locally but in production logs 'Permission denied' when it writes cache, logs or uploads. The cause is almost always that the code was deployed by one user (deploy or root) while the app runs as another (www-data or a PHP-FPM pool user). The fix is not 777. Instead, find out which user the process runs as, keep the code owned by the deploy user and read-only for the app, and give the app write access only to the directories that really need it, such as storage and bootstrap/cache in Laravel. Using the setgid bit keeps new files in the right group. Also remember that deploying or running artisan commands as root can create root-owned cache files the app then cannot overwrite.
ps -o user,pid,cmd -C php-fpm8.3 # which user runs PHP? (adjust version)
ps -o user,pid,cmd -C node
cd /var/www/app
sudo chown -R deploy:www-data .
sudo chmod -R 750 .
sudo chmod -R 2775 storage bootstrap/cache # group write + setgid
sudo find storage bootstrap/cache -type f -exec chmod 664 {} +
sudo -u www-data php artisan cache:clear # run as the app user, not root
Use it when
- Setting up any new server: create personal users with sudo and a separate system user per app.
- Debugging 'Permission denied' or 'EACCES' errors in logs.
- Giving a CI or deploy user narrow rights, such as restarting one service.
- Protecting secrets: .env files, private keys and database credentials should be 600 or 640.
Skip it when
- Do not use chmod 777 or chown -R root as a quick fix; it hides the real cause and weakens security.
- Do not rely on ACLs for everything; if the owner/group model can express the rule, keep it simple and visible.
- Do not share one login (especially root) between several people; you lose accountability.
Alternatives
- ACLs (setfacl)
Extra per-user or per-group rules when owner/group/other is not enough.
- SELinux / AppArmor
Mandatory access control on top of normal permissions. SELinux is default on RHEL, AppArmor on Ubuntu.
- Containers
Docker isolates apps further, but you still need to set the user inside the container and on mounted volumes.
- systemd sandboxing
Options like DynamicUser, ProtectSystem and ReadWritePaths restrict a service without manual users.
Best practices
- Log in as a named user and use sudo; disable direct root login over SSH.
- Run each app as its own system user with no login shell, owning only what it must write.
- Use 600 for private keys and env files, 644 / 755 for normal files and directories.
- Always use usermod -aG (with -a) to add groups; without -a you replace all of the user's groups.
- Edit sudo rules only with visudo, preferably as separate files in /etc/sudoers.d.
- Run app commands (cache clears, migrations) as the app user, for example sudo -u www-data.
- Note that group changes only take effect at the next login (or with newgrp).
Common mistakes
- chmod -R 777 on a web root, which lets any process on the box rewrite your code.
- Running artisan, npm or a deploy script as root and leaving root-owned files the app cannot touch.
- usermod -G instead of -aG, silently removing a user from the sudo group.
- SSH refusing a key because ~/.ssh or authorized_keys is group- or world-writable.
- Editing /etc/sudoers directly and saving a syntax error that breaks sudo.
Deep dive
07How the kernel actually checks access
On every open, the kernel compares the process's effective UID and its groups (primary plus supplementary) with the file's owner and group. The check picks exactly one class, in order: if you are the owner, only the owner bits count; otherwise if one of your groups matches, only the group bits count; otherwise the other bits. Classes do not add up, so a file with mode 0077 denies its own owner even though everyone else can read it. Reaching a file also needs x (search) permission on every directory along the path, which namei -l shows at a glance. Deleting or renaming a file depends on write permission on the directory, not the file. Root skips most of these checks not by magic but through capabilities such as CAP_DAC_OVERRIDE, and a running process keeps the group list it had at login, which is why new group membership needs a new session.
cat /proc/self/status | grep -E '^(Uid|Gid|Groups)' # real/effective IDs
chmod 077 test.txt && cat test.txt # owner denied: only owner bits apply
namei -l /srv/app/shared/.env # x needed on every directory
sudo -u www-data test -r /srv/app/shared/.env && echo readable
newgrp www-data # pick up a new group without relogin
08setuid, setgid and capabilities
A setuid program runs with the privileges of its owner rather than the caller; passwd and sudo are setuid root so ordinary users can change protected files in controlled ways. Every setuid-root binary is attack surface, so audit them and never set the bit on scripts or your own tools. Linux capabilities split root's power into about forty pieces, so a program can receive just one: cap_net_bind_service lets a non-root process bind ports below 1024, cap_net_raw lets ping send raw packets. File capabilities are set with setcap, and systemd can grant them to a service with AmbientCapabilities while dropping everything else with CapabilityBoundingSet. Filesystems can also be mounted with nosuid, nodev and noexec, which is a cheap hardening step for upload and temporary directories that should never contain runnable programs.
find / -xdev -perm -4000 -type f 2>/dev/null # setuid binaries
getcap -r /usr/bin 2>/dev/null # files with capabilities
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/myserver
# In a systemd unit, instead of setcap:
# AmbientCapabilities=CAP_NET_BIND_SERVICE
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
findmnt -o TARGET,OPTIONS /tmp # check nosuid,noexec
09When the modes are right but access is still denied
If ls -l looks perfect and the app still gets EACCES, something above the classic model is saying no. Mandatory access control is the usual suspect: AppArmor on Ubuntu restricts confined programs to the paths in their profile, and SELinux on RHEL labels every file and process and denies anything the policy does not allow, regardless of owner and mode. On SELinux a file moved (rather than copied) into a web root keeps its old label, a classic cause of 403 errors; restorecon fixes it. Other hidden layers: the immutable attribute set with chattr +i, which blocks even root until removed; read-only or noexec mounts; ACL masks that cap group entries; and, in containers, user namespaces that map container root to an unprivileged host UID. Check these before you touch modes.
sudo aa-status # AppArmor profiles in enforce mode
sudo journalctl -k | grep -i apparmor # AppArmor denials
ls -Z /var/www/html # SELinux labels (RHEL family)
sudo ausearch -m avc -ts recent # SELinux denials
sudo restorecon -Rv /var/www/html # reset labels to policy defaults
lsattr /etc/resolv.conf # 'i' = immutable
getfacl /srv/app/storage | grep mask # ACL mask limiting entries
10UIDs across containers and volumes
The kernel stores numbers, not names. A file owned by UID 1000 belongs to whoever has UID 1000 in the current user database, which differs between your host and a container image. That is why bind-mounted directories often show strange owners or become unwritable: the container process runs as UID 33 (www-data in Debian images) or 1000 (node in the official Node images), while the host files belong to your deploy user. Solutions, from simplest: run the container with the host's UID and GID (user: "1000:1000" in Compose), chown the volume to the container's UID, or use named volumes that Docker initializes. Rootless Docker and Podman add user namespaces, so container root maps to a high unprivileged UID on the host, which improves security but shifts the numbers you see on disk. systemd's DynamicUser allocates a transient UID per service in the same spirit.
docker compose exec app id # UID inside the container
stat -c '%u:%g %n' ./storage # numeric owner on the host
# docker-compose.yml
# services:
# app:
# user: "1000:1000" # match the host deploy user
cat /etc/subuid # ranges for rootless containers
Real-world scenarios
A contractor needs access to one site only
Problem
An external developer must deploy and read logs for one site on a shared server that hosts several client sites. They must not see the other sites' code or secrets.
How to solve it
Create a personal account for them with their own SSH key and no password. Each site already runs as its own user, so add the contractor to that site's group only, and make sure other sites' directories are 750 or stricter so 'other' gets nothing. If they need to restart the site's service, add one rule in /etc/sudoers.d/ with visudo allowing exactly that systemctl command. Give them read access to the site's logs through group ownership or an ACL rather than adm group membership, which exposes all logs. Set an account expiry with chage -E, review sudo logs in auth.log, and delete the account when the work ends.
SSH ignores a correct key
Problem
A user's public key is in authorized_keys, but SSH still asks for a password or fails with 'Permission denied (publickey)'. The key works on another server.
How to solve it
Look at the server side, not the client. sshd with StrictModes (the default) refuses keys if the home directory, ~/.ssh or authorized_keys is writable by group or others, or owned by someone else. Run namei -l /home/user/.ssh/authorized_keys and fix with chmod go-w ~, chmod 700 ~/.ssh, chmod 600 ~/.ssh/authorized_keys and chown -R user:user ~/.ssh. Check sudo journalctl -u ssh (sshd on RHEL) for 'bad ownership or modes'. On SELinux systems, a restored home directory may need restorecon -Rv ~/.ssh. Also confirm the line is not wrapped and that the key type is accepted by the server's OpenSSH version.
Files created by a container belong to root
Problem
After running composer install or npm ci inside a container with the project bind-mounted, the deploy user on the host can no longer update or delete vendor and node_modules.
How to solve it
The container process ran as root, so the files it created are owned by UID 0 on the host. Fix the existing files once with sudo chown -R deploy:deploy vendor node_modules. Then stop it from recurring: run build commands with --user "$(id -u):$(id -g)" or set user: in the Compose file, or build the image with a non-root user whose UID matches the host. For long-running app containers, prefer named volumes or copy code into the image during the build instead of bind-mounting the source tree in production. Avoid fixing it with chmod 777 on the mount, which just moves the problem into a security issue.
Cheat sheet
- Numeric owner, group and mode of a path
stat -c '%U:%G %a %n' /srv/app/.env- Check access as another user
sudo -u www-data test -w /srv/app/storage && echo ok- Find world-writable files
find /srv -xdev -type f -perm -0002- Find files with no valid owner
find / -xdev \( -nouser -o -nogroup \)- List setuid binaries
find / -xdev -perm -4000 -type f- Lock an account and expire it
sudo usermod -L -e 1 contractor- Set an account expiry date
sudo chage -E 2026-12-31 contractor- Show sudo usage history (Ubuntu)
sudo grep 'sudo:' /var/log/auth.log | tail- Allow binding to port 80 without root
sudo setcap 'cap_net_bind_service=+ep' /path/to/binary- Make a file immutable / mutable
sudo chattr +i file / sudo chattr -i file
In my real projects
- A separate system user per site or app is part of the security baseline I apply to every server.
- I fixed a production cache-permission error after a read-only diagnosis of which user owned the files and which user the app ran as.
- On the servers I manage, people log in with SSH keys as named users rather than with passwords.
Questions & answers
Q1What does chmod 755 mean, and when would you use 644 or 600?
Each digit is the sum of read (4), write (2) and execute (1) for the owner, the group and everyone else. 755 is rwxr-xr-x: the owner can do everything, others can read and execute. That fits directories, because x on a directory lets you enter it, and executable scripts. 644 is rw-r--r--, the normal mode for files: the owner edits, others read. 600 is rw-------, only the owner can read or write, which is what private SSH keys, .env files and other secrets need. SSH actually refuses keys that are readable by others, so the mode is enforced, not just recommended.
Q2A Laravel app throws 'Permission denied' writing to storage. How do you fix it?
First I confirm which user writes: the PHP-FPM pool user, often www-data, or a dedicated site user. Then I check ownership with ls -l and namei -l along the path. Usually the code was deployed or an artisan command was run as root or the deploy user, so the files belong to someone else. I make the deploy user the owner and the web server group the group, give storage and bootstrap/cache group write with the setgid bit (2775) so new files keep the group, and run future artisan commands as the app user. I never use 777; it would just let any process modify the app.
Q3Why use sudo instead of logging in as root?
sudo gives each person their own account and logs every privileged command with the user who ran it, so you have an audit trail. It limits how long you hold root rights to a single command, which reduces accidental damage, and it lets you grant narrow rights, such as one user being allowed to restart one service. Disabling root SSH login also removes the one account name every attacker tries first. With root, everyone shares one password, you cannot tell who did what, and a single typo in a long root session can hurt the whole system.
Q4What is umask?
umask is a mask of permission bits that are removed from newly created files and directories. Programs usually request 666 for files and 777 for directories, and the umask subtracts from that. With the common default of 022, new files are 644 and directories 755: only the owner can write. With 002 they become 664 and 775, so the group can write too, which suits a team or a deploy user and a web server that share a group. With 077 everything new is private. You can set it per shell, per user in profile files, or per service with UMask= in a systemd unit.
Q5Why should each application run as its own user?
Because permissions are the main wall between processes on the same server. If every site runs as www-data, a vulnerability in one site lets an attacker read the config and database passwords of every other site and change their code. With a separate user per app, each one can read only its own files and write only its own storage directories, so a breach stays contained. It also makes resource usage and logs easier to attribute, and you can apply systemd limits per service. The service user should have no login shell and no sudo rights.
Q6What are ACLs and when would you use them?
Access control lists extend the basic owner, group and others model with extra entries, for example granting one additional user write access to a single directory without changing its group. You manage them with setfacl and view them with getfacl, and default ACLs make new files inherit the rules. They are useful when two different services need different access to the same files, or for shared upload directories. The downside is visibility: ls -l only shows a plus sign, so people miss them during debugging. I use plain owner and group first and reach for ACLs only when that model cannot express the rule.
Senior-level questions
S1How would you design file permissions for a PHP app so that a compromise does the least damage?
Separate who deploys from who runs. Code is owned by a deploy user and is read-only to the PHP-FPM pool user, so an attacker who gets code execution through the app cannot rewrite the application or plant a backdoor in it. Only the directories the app must write, such as storage, cache and uploads, are writable by the runtime user, and the upload directory is served without PHP execution in nginx and ideally mounted noexec. Secrets live in a 640 env file readable by the runtime group only. Each site has its own pool user, so sites cannot read each other. Add systemd or AppArmor confinement if possible. The trade-off is friction: deploys and artisan commands must run as the right user, which needs scripting discipline.
S2Is giving a deploy user passwordless sudo for systemctl safe?
It depends entirely on how narrow the rule is. A rule like deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.service, with the full argument list, only allows that one action. A rule allowing /usr/bin/systemctl with any arguments is effectively root: the user can edit units, start arbitrary services or open a pager that spawns a shell. Wildcards are dangerous for the same reason, as are commands that run editors or accept file paths, such as tee, cp or tar. Better options are a small root-owned wrapper script with no arguments, or polkit rules that allow managing one specific unit. Whatever the choice, keep the deploy key restricted, log sudo use, and review rules as if the deploy key will one day leak.
S3An app gets 'Permission denied' but ls -l shows the right owner and mode. What else do you check?
First, confirm the identity of the process, not the user you think it is: ps -o user,group,supgrp -p PID, because group membership is fixed at process start and a group added later is not active until a restart. Second, walk the path with namei -l, since every parent directory needs execute permission. Then look above discretionary permissions: AppArmor or SELinux denials in the kernel log or audit log, ACL entries and masks with getfacl, the immutable attribute with lsattr, and mount options with findmnt, such as read-only or noexec. For systemd services, check sandboxing options like ProtectSystem, ProtectHome or ReadOnlyPaths in systemctl cat. In containers, compare numeric UIDs inside and outside, since names can mislead.
Glossary
- UID / GID
- Numeric user and group IDs. The kernel stores and compares these numbers; names are only a lookup table.
- Effective UID
- The identity the kernel uses for permission checks. It differs from the real UID while running a setuid program.
- Supplementary groups
- Extra groups a process belongs to besides its primary group, fixed when the session starts.
- setuid bit
- A mode bit that makes a program run as its file's owner, often root, instead of the user who started it.
- Capability
- One slice of root's privileges, such as binding low ports, that can be granted to a process on its own.
- MAC
- Mandatory access control: system-wide policy (SELinux, AppArmor) that can deny access even when file permissions allow it.
- ACL mask
- An ACL entry that caps the maximum rights of named users and groups. chmod on the group bits changes it.
- User namespace
- A kernel feature that maps UIDs inside a container to different, usually unprivileged, UIDs on the host.
- StrictModes
- An sshd setting, on by default, that rejects keys when the home or .ssh directory has unsafe ownership or modes.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.