
Linux File Permissions: chmod, chown, umask and ACLs Explained
Sooner or later every VPS owner meets one of two messages: a browser page that says 403 Forbidden, or a terminal that answers Permission denied. The fix you will find suggested all over the internet is chmod -R 777, and yes, it makes the error disappear, in the same way that removing your front door fixes a stuck lock. In this guide we explain how Linux file permissions actually work, so you can read them at a glance, change them deliberately with chmod and chown, control the defaults with umask, and reach for ACLs when the classic owner, group and others model is not enough.
Everything here works on a fresh Debian 13 or Ubuntu 24.04 VPS, and we point out every place where the two distributions behave differently. We use nginx as the example web server, but the reasoning is identical for Apache, Caddy, PHP-FPM, Node.js apps and anything else that runs under its own user account.
What you need
- A Linux server running Debian 13 or Ubuntu 24.04. The smallest VPS is more than enough, permissions cost nothing in RAM or CPU.
- SSH access as a regular user with sudo rights. In the examples that user is called
deploy, replace it with your own username. - Optional, for the web server part: nginx installed with
sudo apt install nginxand a site under/var/www. - About 30 minutes and a test directory you are allowed to break.
Let’s create that test directory (we will call it the lab) so you can try every command safely:
mkdir -p ~/perm-lab && cd ~/perm-lab
touch notes.txt script.sh
mkdir shared
ls -l
Nothing is printed by the first three commands; ls -l lists the three new entries. We will decode that listing in a minute.
The idea behind Linux file permissions
Linux is a multi-user system, even on a VPS where you are the only human. Every running program, called a process, runs as some user. Some users are people (deploy, root), many are service accounts that exist only so a program can run with limited rights: nginx and PHP-FPM run as www-data, PostgreSQL as postgres, and so on. A group is simply a named list of users, which lets you give the same access to several accounts at once.
See who you are and which groups you belong to:
id
id www-data
Typical output:
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo)
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Your list may contain more groups, that is normal. www-data has the same UID 33 on Debian and Ubuntu.
Every file and directory has exactly one owner user, one owner group, and a set of mode bits. When a process tries to open a file, the kernel asks three questions, in order, and stops at the first “yes”:
- Is the process running as the file’s owner? Then the user bits apply.
- Is the process a member of the file’s group? Then the group bits apply.
- Otherwise, the others bits apply.
“First match wins” has a funny consequence: if a file is mode 047, its owner gets no access at all, even though everyone else can read it. Rare in practice, but it shows that the classes do not add up. The only big exception is root, which skips read and write checks entirely.
Reading permissions with ls -l
Run ls -l in the lab again. You will see something like:
total 4
-rw-r--r-- 1 deploy deploy 0 Nov 10 08:00 notes.txt
-rw-r--r-- 1 deploy deploy 0 Nov 10 08:00 script.sh
drwxr-xr-x 2 deploy deploy 4096 Nov 10 08:00 shared
If you see -rw-rw-r-- and drwxrwxr-x instead, your umask is 0002 rather than 0022, which is common for regular users on Ubuntu. We explain umask further down. The first column is the interesting part:
After the type character (- file, d directory, l symbolic link) come nine characters in three groups: what the user (owner) may do, what the group may do, and what others (everyone else) may do. A dash means “this permission is not granted”. Then come the link count, the owner, the group, the size, the date and the name.
If you prefer numbers, stat prints both forms at once:
stat -c '%A %a %U:%G %n' /etc/passwd /etc/shadow
-rw-r--r-- 644 root:root /etc/passwd
-rw-r----- 640 root:shadow /etc/shadow
That is a nice real example: everybody may read the list of accounts, but only root and the shadow group may read the password hashes.
What r, w and x mean on files and on directories
This is where most confusion comes from, because the same letters mean different things on a directory:
| Bit | Value | On a file | On a directory |
|---|---|---|---|
| r | 4 | read the contents | list the names inside (ls) |
| w | 2 | change the contents | create, delete and rename entries (needs x too) |
| x | 1 | run it as a program | enter it and reach anything inside (cd, open by path) |
Three consequences worth memorising:
- Deleting a file depends on the directory, not on the file. If you have
wandxon the directory, you can delete a read-only file inside it. - A directory without
xis a locked door. Withralone you can see the names but cannot open anything. - To reach
/a/b/c/file, you needxon/,a,bandc. This single rule explains most web server 403 errors, as we will see.
chmod: changing Linux file permissions
chmod (change mode) changes the nine permission bits. You can write the new mode as numbers or as letters. Only the owner of a file (or root) may chmod it.
Octal notation: the 4-2-1 math
Each permission has a value: r = 4, w = 2, x = 1. Add them up for each class and you get three digits: user, group, others. So 7 is rwx (4+2+1), 5 is r-x (4+1), 6 is rw- (4+2) and 0 is nothing.
cd ~/perm-lab
chmod 640 notes.txt
chmod 750 script.sh
chmod 750 shared
ls -l
Now notes.txt shows -rw-r----- (owner reads and writes, group reads, others nothing) and script.sh shows -rwxr-x---. Octal is perfect when you want to set the whole mode in one go.
Symbolic notation: u, g, o with +, - and =
Letters are better when you want to change one bit and leave the rest alone. Pick who (u user, g group, o others, a all), an operator (+ add, - remove, = set exactly) and the permissions:
chmod u+x script.sh # owner may run it
chmod g-w notes.txt # group may no longer write
chmod o= notes.txt # others get nothing
chmod u=rw,g=r,o= notes.txt # same as 640, written with letters
No output means success. Check with ls -l after each one if you want to see the effect.
Recursive changes without breaking everything
The classic mistake is chmod -R 644 somedir. It removes x from every subdirectory, and since x is what lets you enter a directory, the whole tree becomes unreachable. There are two safe ways.
The capital X means “execute, but only for directories and for files that already have an x bit somewhere”:
chmod -R u=rwX,g=rX,o= ~/perm-lab
Or treat directories and files separately with find, which is what we use on web servers:
find ~/perm-lab -type d -exec chmod 750 {} +
find ~/perm-lab -type f -exec chmod 640 {} +
-type d matches directories, -type f regular files, and {} + passes many paths to a single chmod call, which is fast even on large trees.
Cheat sheet of common modes
| Mode | Letters | Typical use |
|---|---|---|
| 644 | rw-r–r– | public config files, static files on a single-user box |
| 640 | rw-r—– | files readable by one group only (web content with group www-data) |
| 600 | rw——- | secrets: SSH keys, .env files, private TLS keys |
| 755 | rwxr-xr-x | programs and public directories |
| 750 | rwxr-x— | directories for one owner and one group |
| 700 | rwx—— | private directories such as ~/.ssh |
| 2770 | rwxrws— | shared group directory, new files inherit the group |
| 1777 | rwxrwxrwt | /tmp: everybody writes, only owners delete |
chown and chgrp: changing who owns a file
Permission bits only make sense together with the owner and group, and chown (change owner) sets both. Only root can give a file to another user, so you need sudo:
sudo chown www-data notes.txt # new owner only
sudo chown deploy:www-data notes.txt # owner and group
sudo chgrp www-data shared # group only
ls -l
A few details that save time:
chown deploy: file(colon, nothing after it) sets the group to deploy’s login group.sudo chown -R deploy:www-data /var/www/example.comchanges a whole tree. By default-Rdoes not follow symbolic links into other places, which is what you want.- You can change a file’s group without sudo only if you own the file and are a member of the target group.
Adding a user to a group
Groups are how you share access without handing out ownership. To add deploy to the www-data group:
sudo usermod -aG www-data deploy
The -a (append) is critical: usermod -G www-data deploy without -a replaces all secondary groups, and you can lose your sudo group in one keystroke. Group membership is read at login, so log out and back in, then confirm with id. For the current shell only, newgrp www-data opens a subshell with the new group active.
Special bits: setuid, setgid and sticky
There is a fourth octal digit in front of the usual three, holding three special bits:
- setuid (4000): a program runs with the rights of its owner instead of the caller. That is how
passwd(owned by root) can update/etc/shadow. It shows as ansin the user execute slot. - setgid (2000) on a directory: new files created inside inherit the directory’s group instead of the creator’s primary group. This is the secret ingredient of shared folders and of our web layout below. It shows as an
sin the group execute slot. - sticky (1000) on a directory: in a directory everybody can write to, users can delete only their own files. It shows as a
tat the end.
ls -ld /tmp /usr/bin/passwd
chmod 2770 ~/perm-lab/shared
ls -ld ~/perm-lab/shared
drwxrwxrwt 10 root root 4096 Nov 10 08:00 /tmp
-rwsr-xr-x 1 root root 118168 Mar 20 2026 /usr/bin/passwd
drwxrws--- 2 deploy deploy 4096 Nov 10 08:00 /home/deploy/perm-lab/shared
Sizes and dates on your server will differ. A capital S or T means the special bit is set but the matching x is missing, usually a mistake. One GNU quirk: chmod 750 on a directory does not clear an existing setgid bit; use chmod g-s dir when you really want it gone.
umask: the default permissions for new files
When a program creates a file, it normally asks for mode 666 (files) or 777 (directories). The umask is a per-process mask of bits to take away from that request. It removes bits, it does not subtract digits: umask 022 turns 666 into 644 and 777 into 755.
umask
umask -S
0022
u=rwx,g=rx,o=rx
What you see depends on the distribution and the account. Root gets 0022 on both. Regular users commonly get 0002 on Ubuntu, because of the “user private groups” rule applied at login by pam_umask (every user has their own group, so letting that group write is harmless), while Debian commonly gives 0022. Do not guess, just run umask.
To change it for the current shell, run umask 027. To make it permanent for your user, add that line to ~/.bashrc (interactive shells) or ~/.profile (login shells). The system-wide default lives in UMASK inside /etc/login.defs, applied at login by pam_umask.
Important: umask is inherited from parent to child process, so your shell setting does not affect services started by systemd. For a service, set it in a drop-in file:
sudo systemctl edit myapp.service
Add these lines in the editor that opens, save, then run sudo systemctl restart myapp.service:
[Service]
UMask=0027
Fixing 403 Forbidden and “permission denied” on a web server
Now the practical payoff. First, know who is asking for the files. nginx starts a master process as root (it needs root to bind ports 80 and 443) and serves requests from worker processes that run as www-data:
ps -o user,cmd -C nginx
USER CMD
root nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
www-data nginx: worker process
For every request, the worker opens the file as www-data. The kernel then checks every directory in the path for x and the file itself for r:
A very common trap: serving a site from your home directory. Debian 12 and newer create home directories with mode 0700, Ubuntu 21.04 and newer with 0750, so on both www-data cannot traverse /home/deploy. The clean answer is to keep sites under /var/www. If you really must serve from home, grant traversal only (no listing, no reading) with an ACL, explained later: sudo setfacl -m u:www-data:x /home/deploy.
Here is the procedure we follow on every 403, in six steps:
Step 1: Read the error log
Never guess, the log tells you exactly what failed:
sudo tail -n 20 /var/log/nginx/error.log
The lines that matter look like this:
open() "/var/www/example.com/public/index.html" failed (13: Permission denied)
"/var/www/example.com/public/index.html" is forbidden (13: Permission denied)
(13: Permission denied) means a filesystem permission problem, carry on with step 2. Two lookalikes are not file permission problems:
directory index of "/var/www/example.com/public/" is forbiddenmeans there is noindex.html(orindex.php) and directory listing is off. Add the file or fix theindexdirective.connect() to unix:/run/php/php8.4-fpm.sock failed (13: Permission denied)is a PHP-FPM socket issue, covered in troubleshooting below.
On Apache the log is /var/log/apache2/error.log and the typical message is AH00035: access to / denied ... because search permissions are missing on a component of the path, which is exactly the “missing x on a directory” case.
Step 2: Walk the path with namei
namei -l (part of util-linux, installed on both distributions) prints the permissions of every component of a path, which is precisely what the kernel checks:
namei -l /var/www/example.com/public/index.html
f: /var/www/example.com/public/index.html
drwxr-xr-x root root /
drwxr-xr-x root root var
drwxr-xr-x root root www
drwx------ deploy deploy example.com
drwxr-xr-x deploy deploy public
-rw------- deploy deploy index.html
Read it top to bottom as www-data. /, var and www give x to others, fine. example.com is drwx------: www-data is neither owner nor in group deploy, and others get nothing. That is the 403. And even past that, index.html is 600, so it would fail again. Two problems, both now visible.
Step 3: Test as the web server user
Confirm the diagnosis by asking the kernel directly, as www-data:
sudo -u www-data test -r /var/www/example.com/public/index.html && echo "readable" || echo "NOT readable"
sudo -u www-data ls /var/www/example.com/public/
Before the fix you will get NOT readable and ls: cannot access '/var/www/example.com/public/': Permission denied. www-data has no login shell, but sudo -u runs the command directly, so this works. The same trick works for any service user: postgres, your app user and so on.
Step 4: Fix ownership
Our recommended layout: your deploy user owns the code (so you can edit, git pull or rsync without sudo), the group is www-data (so the web server can read), and others get nothing:
sudo chown -R deploy:www-data /var/www/example.com
Notice what we are not doing: we are not making www-data the owner. If the web server owned the code, any vulnerability in a PHP script could rewrite your entire site.
Step 5: Fix directory and file modes
Directories get 2750 (owner full, group enter and list, setgid so new files keep group www-data), files get 640 (owner read and write, group read):
sudo find /var/www/example.com -type d -exec chmod 2750 {} +
sudo find /var/www/example.com -type f -exec chmod 640 {} +
If the application must write somewhere (uploads, cache, sessions), open only that directory to the group:
sudo chmod 2770 /var/www/example.com/public/uploads
Files PHP-FPM creates there will be owned by www-data, with group www-data thanks to setgid. Your deploy user can still delete them (it owns the directory), but not edit them; the ACL section shows how to fix that too. If you use an app that updates its own code from the web UI, it needs write access to the code, which is a conscious security tradeoff; many people prefer updating from the command line instead.
Step 6: Verify
Permissions are checked on every request, so nginx does not need a reload for this fix (only if you also changed its configuration, in which case run sudo nginx -t && sudo systemctl reload nginx). Test as the user, then as a client:
namei -l /var/www/example.com/public/index.html
sudo -u www-data test -r /var/www/example.com/public/index.html && echo "readable"
curl -I -H "Host: example.com" http://127.0.0.1/
namei should now show drwxr-s--- with group www-data on the site directories and -rw-r----- on the file, the test prints readable, and curl answers HTTP/1.1 200 OK.
ACLs: when basic permissions are not enough
The classic model has a hard limit: one owner and one group per file. Sooner or later you need “deploy owns it, www-data reads it, and alice, the second developer, may edit it”, or “the backup user may read this tree, nothing more”. Changing the group would break the web server, and adding alice to www-data would give her access to every site on the server. Access Control Lists (ACLs) solve this by attaching extra per-user and per-group entries to a file, on top of the normal bits.
Installing the ACL tools
sudo apt install acl
Ubuntu server images usually ship the acl package already, minimal Debian installs sometimes do not; the command is harmless either way. The filesystems you will meet on a VPS (ext4, XFS, Btrfs) support ACLs out of the box, no mount option needed.
Reading and setting ACLs
Give alice read and write access to the whole site, plus x on directories (capital X again):
sudo setfacl -R -m u:alice:rwX /var/www/example.com
getfacl /var/www/example.com/public/index.html
# file: var/www/example.com/public/index.html
# owner: deploy
# group: www-data
user::rw-
user:alice:rw-
group::r--
mask::rw-
other::---
-m means modify (add or update entries), u:alice:rwX is the entry, g:name:... would be a group. The normal owner, group and other bits are still there as user::, group:: and other::. In ls -l the file now shows a + at the end, -rw-rw----+, which is your hint to run getfacl. The same mechanism handles the home directory case from earlier, sudo setfacl -m u:www-data:x /home/deploy, which lets nginx pass through without listing or reading anything there.
Default ACLs: making new files inherit access
The command above only touched files that exist today. A file created tomorrow will not have alice’s entry. Default ACLs, set on directories, are templates that new files and subdirectories inherit:
sudo setfacl -R -m u:alice:rwX,d:u:alice:rwX /var/www/example.com
getfacl /var/www/example.com/public
# file: var/www/example.com/public
# owner: deploy
# group: www-data
# flags: -s-
user::rwx
user:alice:rwx
group::r-x
mask::rwx
other::---
default:user::rwx
default:user:alice:rwx
default:group::r-x
default:mask::rwx
default:other::---
The d: prefix makes an entry a default; with -R, setfacl applies defaults to directories only. Note that when a directory has a default ACL, the umask is ignored for new files inside it, the default entries decide instead.
The same trick fixes the uploads problem from step 5, so deploy can edit files that PHP creates:
sudo setfacl -R -m u:deploy:rwX,d:u:deploy:rwX /var/www/example.com/public/uploads
The ACL mask gotcha
The mask:: line is the maximum any named user or group entry can get. The catch: once a file has an ACL, chmod on the group bits changes the mask, not the group entry. Watch:
sudo chmod 640 /var/www/example.com/public/index.html
getfacl /var/www/example.com/public/index.html
user::rw-
user:alice:rw- #effective:r--
group::r--
mask::r--
other::---
Alice’s entry still says rw-, but the mask cut it down to r--. That is why re-running the find ... chmod 640 from step 5 on a tree with ACLs silently takes write access away. Rule of thumb: run your chmod commands first, then your setfacl commands, which recalculate the mask. To fix just the mask: sudo setfacl -m m::rw file.
Backing up, copying and removing ACLs
getfacl -R -p /var/www/example.com > ~/example-acl.txt
sudo setfacl --restore="$HOME/example-acl.txt"
The first command saves every ACL with absolute paths (run it with sudo if some directories are not readable by you), the second restores them. To remove entries: sudo setfacl -x u:alice file removes one entry, sudo setfacl -R -k dir removes default ACLs, sudo setfacl -R -b dir removes all ACLs and leaves only the classic bits.
Copy tools need to be told to keep ACLs: cp -a keeps them, rsync needs -A (rsync -aAX), and tar needs --acls both when creating and when extracting:
sudo tar --acls -cpzf ~/site-backup.tar.gz -C /var/www example.com
sudo chown "$USER": ~/site-backup.tar.gz
The chown matters: an archive created with sudo is owned by root, and you want to be able to copy it off the server as your normal user. Restore with sudo tar --acls -xpzf ~/site-backup.tar.gz -C /var/www.
Troubleshooting and common mistakes
- “I ran chmod 777 and it works now.” It works because now every user and every process on the server can modify those files, including a compromised PHP script. Undo it with steps 4 and 5.
chmod -R 644made the site disappear. Directories lostx. Re-run the twofindcommands from step 5.usermod -Gremoved my sudo access. You forgot-a. Log in as root from your provider’s console and add the groups back withusermod -aG sudo deploy.- I added myself to a group, nothing changed. Group lists are read at login. Log out and back in, or use
newgrp, then checkid. ./script.sh: Permission denied. The file lacksx(chmod u+x script.sh), or it lives on a filesystem mounted withnoexec. Check withfindmnt -T ./script.sh -o TARGET,OPTIONS;bash script.shstill runs it, because then bash only needs to read it.sudo echo "x" > /etc/filesays permission denied. The redirection is done by your shell, not by sudo. Useecho "x" | sudo tee /etc/file > /dev/null.- PHP pages give 502 and the log shows the socket error. The PHP-FPM socket is
/run/php/php8.4-fpm.sockon Debian 13 and/run/php/php8.3-fpm.sockon Ubuntu 24.04 (service namesphp8.4-fpmandphp8.3-fpm). Checklisten.owner,listen.groupandlisten.modein/etc/php/<version>/fpm/pool.d/www.conf; the defaults (www-data,www-data,0660) match nginx. Restart PHP-FPM after changes. - Docker container cannot write to a bind mount. Inside the container the app runs with a numeric UID that may differ from yours; names do not matter, numbers do. Find it with
docker compose exec app idand runsudo chown -R <uid>:<gid> ./dataon the host. - Files show as
nobody:nogroupand chown fails with “Operation not permitted”, even as root. You are probably in an unprivileged LXC container, where root inside is mapped to an unprivileged user on the host. Files from a host bind mount owned by unmapped UIDs cannot be changed from inside; ownership must be fixed on the host side. Normal chmod, chown and ACLs on the container’s own filesystem work fine. - Modes are right, access still denied. Both Debian and Ubuntu enable AppArmor by default. If a program has an AppArmor profile, it may block paths outside it regardless of the mode bits. Look for
apparmor="DENIED"insudo journalctl -k | grep -i apparmor. ls -lgroup bits look wrong on a file with+. That column shows the ACL mask, not the group entry. Trustgetfacl.
Security notes
- Least privilege by default. The web server should read code, not write it. Give write access only to the directories that truly need it.
- No 777, no 666. Audit for world-writable files from time to time:
sudo find / -xdev -type f -perm -0002 -ls 2>/dev/null. On a healthy server this list is short or empty. - Know your setuid binaries.
sudo find / -xdev -type f -perm -4000 -ls 2>/dev/nulllists programs that run with their owner’s rights. Anything unexpected there deserves a closer look. - Secrets are 600 (or 640 with a dedicated group).
.envfiles, database passwords, private keys.chmod 600 .envtakes a second. - SSH is strict on purpose. With the default
StrictModes yes, sshd ignores your keys if your home,~/.sshorauthorized_keysare writable by others. Usechmod 700 ~/.sshandchmod 600 ~/.ssh/authorized_keys. - Prefer narrow ACLs over wide groups. One ACL entry for one user on one tree is safer than adding that user to a group that unlocks many trees.
- Keep a record. Save
getfacl -R -poutput before large changes, so you can roll back.
So, where does this leave you?
Linux file permissions stop being scary the moment you see them as the kernel does: who is asking, which of the three classes they fall into, and whether every directory on the way has its x. With ls -l, stat and namei you can read any situation, with chmod and chown you fix it precisely, umask keeps new files sane, and ACLs cover the cases where one owner and one group are not enough. Next time a 403 appears, skip the 777 and walk the six steps.
Want a playground to practise on? Spin up a VPS with us in a few minutes and break permissions safely. If you would rather have someone else design a clean user, group and permission layout for your sites, our server administration team does exactly that every day.
FAQ
What is the difference between chmod 755 and 644?
755 (rwxr-xr-x) adds the execute bit for everyone, which on a directory means “can enter it”. 644 (rw-r--r--) has no execute bit. Use 755 style modes for directories and programs, 644 style modes for ordinary files. On a web server with group www-data, the stricter 750 and 640 are even better.
Is chmod 777 ever okay?
Practically never on a server. The only place where world-writable is normal is shared temporary directories like /tmp, and those also carry the sticky bit (1777) so users cannot delete each other’s files. For everything else, fix ownership and groups instead.
Why does my site still return 403 after I changed permissions?
Check the error log again. Either another directory in the path still lacks x (run namei -l), the message is actually “directory index is forbidden” (missing index file), or something other than file modes is blocking access, such as an nginx deny rule or an AppArmor profile.
Do ACLs work on every filesystem?
On the common Linux filesystems (ext4, XFS, Btrfs) yes, by default. Some network and foreign filesystems, such as FAT or certain NFS setups, do not support POSIX ACLs. If setfacl returns “Operation not supported”, the filesystem is the reason.