"Permission denied" is one of the most common errors on a Linux server, and one of the most commonly "fixed" the wrong way, with chmod -R 777. The right fix is almost always small: one directory missing the execute bit, a file owned by the wrong user, or a key that is too open. In this tutorial you will learn to read Linux permissions, find exactly which part of a path blocks access, and fix typical cases for web servers, scripts and SSH on Ubuntu 24.04.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS. The commands are the same on Debian 12 and Rocky Linux 9, except where noted.
  • A non-root user with sudo privileges. This guide calls it your_user.
  • For the web examples, Nginx or Apache installed. Both run as the www-data user on Ubuntu and Debian (nginx or apache on RHEL-family systems).

Step 1 - Reading permissions and ownership

Every file and directory has an owner, a group, and three sets of permissions: for the owner (u), the group (g) and everyone else (o). List them with ls -l:

ls -l /var/www/your_domain
drwxr-xr-x 3 your_user www-data 4096 Sep 25 10:20 public
-rw-r----- 1 your_user www-data  612 Sep 25 10:18 config.php

The first character is the type (- for a file, d for a directory, l for a symlink), followed by three groups of rwx. stat shows the same information in octal form, which is what chmod accepts:

stat -c '%A %a %U:%G %n' /var/www/your_domain/config.php
-rw-r----- 640 your_user:www-data /var/www/your_domain/config.php

Each octal digit adds read (4), write (2) and execute (1): 640 means read-write for the owner, read for the group, nothing for others.

The same letters mean different things on directories, and this is where most confusion comes from:

PermissionOn a fileOn a directory
rRead the contentsList the names of entries (ls)
wModify the contentsCreate, rename and delete entries inside it
xRun it as a programEnter it and access entries by name (traverse)

Two consequences matter in practice. To open any file, you need x on every directory in its path. And deleting a file depends on w on the directory, not on the file itself.

Finally, check which user and groups you are acting as:

id
uid=1000(your_user) gid=1000(your_user) groups=1000(your_user),27(sudo),33(www-data)

Group membership changes only apply to new login sessions, so after usermod -aG you must log out and back in before id shows the new group.

Step 2 - Finding which part of the path blocks access

When access fails, check every component of the path at once with namei -l, part of util-linux and installed by default:

namei -l /home/your_user/site/index.html
f: /home/your_user/site/index.html
drwxr-xr-x root      root      /
drwxr-xr-x root      root      home
drwxr-x--- your_user your_user your_user
drwxrwxr-x your_user your_user site
-rw-rw-r-- your_user your_user index.html

The file itself is world-readable, but /home/your_user has no permissions for others (---). Any user that is neither your_user nor in the your_user group, such as www-data, stops there. Ubuntu 21.04 and later create home directories with mode 750, so this is exactly why Nginx returns 403 errors for sites served from a home directory, with this line in /var/log/nginx/error.log:

open() "/home/your_user/site/index.html" failed (13: Permission denied)

To confirm the diagnosis, repeat the access as the user the service runs as:

sudo -u www-data cat /home/your_user/site/index.html
cat: /home/your_user/site/index.html: Permission denied

Testing as the service's user is more reliable than reasoning about permission bits, because it also takes group memberships and ACLs into account. Use sudo -u www-data ls for directories and sudo -u www-data touch to test write access.

The cleanest fix here is to serve the site from /var/www as described in Step 4. If it must stay in a home directory, give www-data traverse-only access to it with an ACL (Step 6) instead of opening the home directory to everyone.

Step 3 - Changing ownership and modes safely

Use chown to change the owner and group, and chmod to change the mode. Symbolic modes change only the bits you name, which is safer than overwriting everything with an octal value:

sudo chown your_user:www-data /var/www/your_domain/config.php
sudo chmod g+r,o-rwx /var/www/your_domain/config.php

Verify the result:

stat -c '%A %U:%G %n' /var/www/your_domain/config.php
-rw-r----- your_user:www-data /var/www/your_domain/config.php

When you need to fix a whole tree, treat files and directories separately. chmod -R 755 would make every file executable, and chmod -R 644 would make every directory impossible to enter. Use find with -type instead:

sudo find /var/www/your_domain -type d -exec chmod 755 {} +
sudo find /var/www/your_domain -type f -exec chmod 644 {} +

Step 4 - Setting up a web root for a deploy user and the web server

A common setup is that you (or a deploy tool) own the code, and the web server only reads it, except for a few directories such as uploads or cache where the application must write. Create the site directory, owned by your user with the web server's group:

sudo mkdir -p /var/www/your_domain
sudo chown -R your_user:www-data /var/www/your_domain

Set directories to 2755 and files to 644. The leading 2 is the setgid bit: new files and directories created inside inherit the www-data group automatically, instead of the creator's primary group:

sudo find /var/www/your_domain -type d -exec chmod 2755 {} +
sudo find /var/www/your_domain -type f -exec chmod 644 {} +

Give the web server write access only where the application needs it, for example an uploads directory:

sudo mkdir -p /var/www/your_domain/uploads
sudo chmod 2775 /var/www/your_domain/uploads

Protect files that contain secrets so that other local users cannot read them:

sudo chmod 640 /var/www/your_domain/config.php

Verify the result from the web server's point of view:

sudo -u www-data touch /var/www/your_domain/uploads/test && echo "uploads writable"
sudo -u www-data touch /var/www/your_domain/test
uploads writable
touch: cannot touch '/var/www/your_domain/test': Permission denied

The second failure is what you want: a compromised PHP script cannot overwrite your code. Remove the test file with sudo rm /var/www/your_domain/uploads/test.

Step 5 - Fixing scripts that will not run

Running a script without the execute bit fails like this:

bash: ./deploy.sh: Permission denied

Check the mode and add execute permission for the owner:

ls -l deploy.sh
chmod u+x deploy.sh
-rw-rw-r-- 1 your_user your_user 1284 Sep 25 11:02 deploy.sh

If the script is executable and still fails with "Permission denied", check whether the filesystem is mounted with noexec, which is common for /tmp on hardened servers:

findmnt -no OPTIONS --target ./deploy.sh
rw,nosuid,nodev,noexec,relatime

With noexec, nothing on that filesystem can be executed directly. Move the script to a normal location such as /usr/local/bin or your home directory, or run it through the interpreter with bash deploy.sh.

Step 6 - Fixing SSH key permissions

OpenSSH refuses keys whose files or directories other users could modify, because that would let them add their own keys. If key login suddenly fails and falls back to a password prompt, check the server's SSH log:

sudo journalctl -u ssh -n 20 --no-pager
Authentication refused: bad ownership or modes for directory /home/your_user/.ssh

Set the ownership and modes the SSH server expects: the home directory must not be writable by group or others, ~/.ssh must be private, and authorized_keys must be writable only by you:

sudo chown -R your_user:your_user /home/your_user/.ssh
sudo chmod go-w /home/your_user
sudo chmod 700 /home/your_user/.ssh
sudo chmod 600 /home/your_user/.ssh/authorized_keys

The client side has a similar check for private keys. This warning on your workstation means the private key is readable by others:

WARNING: UNPROTECTED PRIVATE KEY FILE!
Permissions 0644 for '/home/your_user/.ssh/id_ed25519' are too open.

Fix it with chmod 600 ~/.ssh/id_ed25519 and try again.

Step 7 - Granting access to one extra user with ACLs

Standard permissions allow one owner and one group. When a second user or service needs access without changing the owner or group, use an access control list (ACL). Ubuntu supports ACLs on ext4 and XFS out of the box. Install the tools if they are missing:

sudo apt install acl

Returning to the example from Step 2, give www-data permission to traverse your home directory without being able to list it, and read access to the site:

sudo setfacl -m u:www-data:x /home/your_user
sudo setfacl -R -m u:www-data:rX /home/your_user/site
sudo setfacl -R -d -m u:www-data:rX /home/your_user/site

The capital X grants execute only on directories (and on files that are already executable). The third command sets a default ACL, so files created later inherit the same rule. Show the ACL with getfacl:

getfacl /home/your_user
# file: home/your_user
# owner: your_user
# group: your_user
user::rwx
user:www-data:--x
group::r-x
mask::r-x
other::---

Files with an ACL show a + after the mode in ls -l, for example drwxr-x---+. Test again with sudo -u www-data cat /home/your_user/site/index.html. To remove all ACL entries from a file, use sudo setfacl -b path.

Step 8 - When the permissions look correct but access is still denied

If namei -l and id say access should work, look at the other layers that can deny it.

Immutable attribute: even root gets "Operation not permitted" when changing a file marked immutable. Check with lsattr:

lsattr /etc/resolv.conf
----i---------e------- /etc/resolv.conf

The i flag means immutable. Remove it with sudo chattr -i /etc/resolv.conf if the lock is no longer wanted.

AppArmor: Ubuntu confines services such as MySQL and some Snap packages with AppArmor profiles, which can block paths outside their expected directories regardless of file permissions. Denials are logged by the kernel:

sudo journalctl -k | grep 'apparmor="DENIED"'
audit: type=1400 apparmor="DENIED" operation="open" profile="/usr/sbin/mysqld" name="/data/mysql/ibdata1" requested_mask="r"

The fix is to add the path to the profile's local override file (for example /etc/apparmor.d/local/usr.sbin.mysqld) and reload it with sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.mysqld, not to disable AppArmor. On Rocky Linux and other RHEL-family systems, the equivalent is SELinux: check sudo ausearch -m avc -ts recent and fix file labels with restorecon or semanage fcontext.

Read-only filesystem: an error such as Read-only file system is not a permission problem. The kernel remounts a filesystem read-only after I/O errors, so check sudo dmesg | tail for disk errors.

Step 9 - Auditing risky permissions

After fixing an incident, check that nothing else on the server was left wide open. Find world-writable files outside the virtual filesystems:

sudo find / -xdev -type f -perm -0002 -not -path '/proc/*' 2>/dev/null

Find world-writable directories that lack the sticky bit (the sticky bit, as on /tmp, stops users from deleting each other's files):

sudo find / -xdev -type d -perm -0002 ! -perm -1000 2>/dev/null

List SUID and SGID programs, which run with their owner's privileges. Compare the list against what your distribution ships and investigate anything unexpected, especially in /home, /tmp or /var/www:

sudo find / -xdev -type f \( -perm -4000 -o -perm -2000 \) 2>/dev/null

Both world-writable searches should normally return nothing outside /tmp-like locations.

Conclusion

You learned how Linux permissions differ between files and directories, how to find the exact path component that blocks access with namei -l and sudo -u, and how to fix ownership and modes for web roots, scripts and SSH keys without resorting to 777. You also saw the layers beyond classic permissions: ACLs, immutable attributes and AppArmor. As next steps, apply the web root layout from Step 4 to your sites, and run the audit from Step 9 periodically.