Standard Linux permissions give you three slots. Owner, group, everyone else. That covers most of what you need, right up until the afternoon somebody asks you to give one service account read access to one directory, and you realise there’s nowhere to put it.
You could add the service account to the owning group. Now it has access to everything else that group owns. You could loosen the “other” bits. Now the whole machine can read it. Neither answer is good, and both are how directories quietly end up world readable.
If the standard model is still new to you, Linux commands and directory structure covers the ground this article builds on. Access Control Lists solve this properly. They let you attach permissions for as many extra users and groups as you like, per file, without touching the existing ownership. The two commands are getfacl to read them and setfacl to change them.
Everything below was run on AlmaLinux 9.8 with acl 2.4.0, though none of it is distribution specific and it behaves the same on Ubuntu, Debian and Rocky. The output is copied from the terminal rather than typed from memory, which matters here because one of the behaviours involved surprises almost everyone the first time they meet it.
Checking your system is ready
The tools live in a package called acl on every major distribution:
# Debian and Ubuntu
sudo apt install acl
# RHEL, Rocky, AlmaLinux, Fedora
sudo dnf install acl
The filesystem has to support ACLs too, though on anything modern that’s already sorted. ext4, XFS and Btrfs all enable ACL support by default, so unless you’re on something unusual or a deliberately restricted mount, there’s nothing to configure. If setfacl comes back with “Operation not supported”, that’s when to go looking at your mount options.
Reading an ACL
Start with a plain file that has no ACL on it at all:
$ ls -l index.html
-rw-r--r-- 1 asif webteam 6 Aug 23 09:14 index.html
$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
group::r--
other::r--
Those three lines are just the normal permissions written differently. user::rw- is the owner, group::r-- is the owning group, other::r-- is everyone else. Same information ls -l gave you, different presentation.
Granting access to one user
Now give the nginx user read access, without changing ownership and without touching the group:
$ setfacl -m u:nginx:r index.html
$ ls -l index.html
-rw-r--r--+ 1 asif webteam 6 Aug 23 09:14 index.html
Look closely at the permission string. There’s a + on the end now. That plus sign is the only hint ls gives you that a file carries an ACL, and it’s easy to scroll straight past. When permissions on a server aren’t behaving the way the mode bits suggest they should, that + is the first thing worth looking for.
$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r--
group::r--
mask::r--
other::r--
Two things changed. There’s a user:nginx:r-- entry, which is what we asked for. And a mask::r-- line appeared, which we didn’t ask for and which is about to become the most important line in this article.
The mask
The mask is a ceiling. The acl(5) manual page describes it as the maximum access rights that can be granted by named user entries, named group entries, and the owning group. Anything above that ceiling gets clipped.
Add a group entry with write permission and watch what the mask does:
$ setfacl -m g:apache:rw index.html
$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r--
group::r--
group:apache:rw-
mask::rw-
other::r--
The mask widened to rw- on its own, because setfacl recalculates it as the union of everything you’ve granted. That’s usually what you want and it’s why the mask stays invisible most of the time.
Note that the file owner and “other” are not subject to the mask at all. It only constrains the entries in the middle.
The chmod trap
Here’s the part that catches people, and it’s worth understanding before you deploy ACLs anywhere that matters.
Once a file has an ACL with a mask, the group permission bits you see in ls -l are no longer the owning group’s permissions. They’re the mask. The manual is explicit about it: if the ACL has a mask entry, the group permissions correspond to the permissions of the mask entry. That single sentence in acl(5) explains most of the confusion people have with ACLs.
Which means a perfectly ordinary chmod does this:
$ chmod g-w index.html
$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r--
group::r--
group:apache:rw- #effective:r--
mask::r--
other::r--
The apache group still has rw- sitting there in the ACL. It just can’t use it. The mask dropped to r--, and getfacl is now annotating that entry with #effective:r-- to tell you the write permission is being clipped.
Nothing errored. Nothing warned you. The entry you carefully added is still visible in the ACL, which is exactly why this is so confusing to debug: you check the ACL, your grant is right there, and it still doesn’t work.
Any #effective: annotation in getfacl output means the mask is clipping something. Fix it by setting the mask back:
$ setfacl -m m::rw index.html
$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r--
group::r--
group:apache:rw-
mask::rw-
other::r--
The annotation is gone and the grant works again. The practical rule: once a file has an ACL, stop reaching for chmod on it. Use setfacl for everything, including the mask.
Default ACLs and inheritance
An ACL on a directory only covers the directory itself. To have new files inside it pick up permissions automatically, you set a default ACL with -d:
$ setfacl -d -m u:nginx:rx uploads
$ getfacl uploads
# file: uploads
# owner: asif
# group: webteam
user::rwx
group::r-x
other::r-x
default:user::rwx
default:user:nginx:r-x
default:group::r-x
default:mask::r-x
default:other::r-x
The default: entries are a template. They don’t grant anything themselves, they get copied onto anything created inside afterwards:
$ touch uploads/new.html
$ getfacl uploads/new.html
# file: uploads/new.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r-x #effective:r--
group::r-x #effective:r--
mask::r--
other::r--
The nginx entry came through. But there’s that #effective: annotation again, and this one trips people up badly, because nobody ran chmod here.
The reason is that touch creates files with mode 644, no execute bit. That creation mode narrows the mask down to r--, so the execute permission in the inherited entry gets clipped immediately. The default ACL was applied correctly. The file’s creation mode then trimmed it.
This is why granting execute through a default ACL rarely works the way people expect on regular files. For directories it’s fine, because directories are created with the execute bit set.
Existing files are not touched
Worth being clear about, because it’s a common misreading. Adding a default ACL changes nothing that already exists:
$ setfacl -d -m u:apache:rx uploads
$ getfacl uploads/older.html
# file: uploads/older.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r-x #effective:r--
group::r-x #effective:r--
mask::r--
other::r--
No apache entry. The default only fires at creation time. For files already sitting there, apply the ACL directly and recursively:
$ setfacl -R -m u:apache:r uploads
$ getfacl uploads/older.html
# file: uploads/older.html
# owner: asif
# group: webteam
user::rw-
user:apache:r--
user:nginx:r-x
group::r-x
mask::r-x
other::r--
In practice you usually want both: -R to fix what exists, and -d so new files keep working. They’re two separate operations and skipping either one leaves you with a directory that half works.
Preview before you commit
On a live server, --test shows you the resulting ACL without writing anything:
$ setfacl --test -m u:mail:rwx index.html
index.html: u::rw-,u:mail:rwx,u:nginx:r--,g::r--,g:apache:rw-,m::rwx,o::r--,*
Notice the mask went to rwx in that preview. Running it for real would have widened the ceiling for every entry, not just the one you added. Ten seconds of --test before an -R across a directory tree is cheap insurance.
Removing entries
# Remove one named entry
$ setfacl -x u:nginx index.html
# Remove every extended entry, keep the base permissions
$ setfacl -b index.html
# Remove only the default ACL from a directory
$ setfacl -k uploads
Those three cover almost everything. The setfacl(1) manual page lists the rest, including --set, which replaces an ACL outright rather than modifying it.
After -b the file is back to plain permissions, mask and all:
$ getfacl index.html
# file: index.html
# owner: asif
# group: webteam
user::rw-
group::r--
other::r--
The + disappears from ls -l at that point too. Note that -x takes an entry without permissions: u:nginx, not u:nginx:r. Including them is a common syntax error.
Backing up and restoring ACLs
getfacl -R produces a text dump that setfacl --restore reads back. This is genuinely useful before any bulk permission change:
$ getfacl -R . > /tmp/acl-backup.txt
$ head -20 /tmp/acl-backup.txt
# file: .
# owner: asif
# group: webteam
user::rwx
group::r-x
other::r-x
# file: index.html
# owner: asif
# group: webteam
user::rw-
user:nginx:r--
group::r--
group:apache:rw-
mask::rw-
other::r--
Wipe everything, then put it back:
$ setfacl -R -b .
$ setfacl --restore=/tmp/acl-backup.txt
Warning: option --restore=file is unsafe without option -P (--physical) as it traverses symbolic links in pathnames
That warning is worth reading rather than dismissing. Without -P, the restore follows symbolic links, so a symlink pointing somewhere outside the tree you backed up can end up having permissions rewritten. On a directory tree you don’t fully control, use setfacl -P --restore=file.
Copying files without losing the ACL
This is where ACLs quietly disappear, usually during a backup or a deploy, and usually nobody notices until something breaks in production.
A plain cp drops them:
$ cp index.html plain-copy.html
$ getfacl plain-copy.html
# file: plain-copy.html
# owner: asif
# group: webteam
user::rw-
group::r--
other::r--
Gone. No warning, no error, and the copy looks fine in ls -l because there’s no + to notice missing. You need to ask for them explicitly:
$ cp --preserve=all index.html full-copy.html
$ getfacl full-copy.html
# file: full-copy.html
# owner: asif
# group: webteam
user::rw-
user:nginx:rw-
group::r--
mask::rw-
other::r--
Same story for the other tools, each with its own flag:
# tar
$ tar --acls -cf /tmp/site.tar index.html
$ tar --acls -xf /tmp/site.tar -C /tmp/untar
# rsync
$ rsync -A index.html rsync-copy.html
If rsync is part of your routine, it is worth reading alongside the rsync command in Linux, since the same flag question comes up with every sync. Both preserve the ACL correctly. The catch with tar is that --acls is needed on both the create and the extract. Get it right on one and forget it on the other and your ACLs still vanish, which is a frustrating way to discover this during a restore.
| Tool | Default | Preserves ACLs with |
|---|---|---|
cp |
drops them | --preserve=all |
rsync |
drops them | -A (implies -p) |
tar |
drops them | --acls on create and extract |
mv |
keeps them | nothing needed on the same filesystem |
Troubleshooting
| Symptom | Cause |
|---|---|
| Entry exists but access still denied | The mask is clipping it. Look for #effective: in getfacl. |
| ACL stopped working after a routine change | Someone ran chmod. It rewrites the mask. |
| New files not inheriting | You set a normal ACL, not a default one. Needs -d. |
| Default ACL set but old files unaffected | Expected. Defaults apply at creation only. Use -R for existing files. |
| Execute bit missing on inherited files | The file creation mode narrowed the mask. Normal for regular files. |
| ACLs lost after a copy or restore | cp, rsync and tar all drop them unless told otherwise. |
Operation not supported |
Filesystem or mount doesn’t have ACL support. |
setfacl -x rejects your syntax |
Remove the permissions from the entry. Use u:nginx, not u:nginx:r. |
| Can’t tell which files have ACLs | Look for the + in ls -l. |
When not to bother
ACLs are a good answer to a specific question: one or two extra identities needing access to a particular place. They’re a poor answer to “our permissions are a mess”.
If you find yourself adding named entries to dozens of directories, the real problem is usually your group design, and a well-chosen group would solve it more simply and be far easier for the next person to understand. ACLs are also invisible to anyone who only looks at ls -l, which makes them a genuine documentation burden on a shared system.
Use them where they earn their keep. A web server user that needs read access to one application directory is the textbook case, and it’s the case they handle beautifully.
Wrapping up
The commands themselves take about ten minutes to learn. getfacl to look, setfacl -m to grant, -x and -b to remove, -d for inheritance.
What takes longer is the mask, and it’s worth the effort, because nearly every “ACLs don’t work” problem turns out to be the mask doing exactly what it was designed to do. Watch for #effective: in the output, keep chmod away from files that carry an ACL, and remember that your copy tools throw ACLs away by default.
Get those three right and ACLs are reliable, boring infrastructure. Which is exactly what you want permissions to be.
For the other half of Linux file control, the attribute layer that sits underneath permissions entirely, see the chattr command. And file management in Linux covers the everyday operations these permissions apply to.


