A self-hosted application can have the right address, the right ports, the right container image, and the right configuration, and still fail because one process cannot cross one directory or create one file.
The application may reduce that entire failure to two words:
Permission denied
That message is a symptom, not a diagnosis. It does not tell you whether the process has the wrong numeric identity, a parent directory blocks traversal, the target is mounted read-only, an ACL mask removes write access, a container mount is declared read-only, or a security policy rejects the path after the ordinary mode bits allow it.
The usual reaction is to search for a command that makes the error disappear. That is how directories end up owned by root, containers end up running with more privilege than they need, and entire storage trees end up set to 777. The service may begin working, but the operator no longer knows which access was required, which boundary was removed, or whether the next file will recreate the same failure.
Linux permissions are not random, and they are not a collection of magic numbers. They are a predictable access-control system built around four questions:
- Which process is attempting the operation?
- Which numeric identity does that process actually use?
- What operation is it attempting on which path?
- Which access-control layer denies it first?
This guide builds that mental model from the ground up, then applies it to Docker, systemd services, shared media directories, NFS, SMB, ACLs, SELinux, AppArmor, and real self-hosting failures.
The goal is not to make every service able to write everywhere. The goal is to give each service exactly the access it needs, make that access survive restarts and updates, and leave enough evidence to troubleshoot failures safely.
Who this guide is for
This guide is written for:
- someone deploying a first Linux server;
- a Docker user confused by
PUID,PGID, or numeric ownership; - a homelab operator sharing storage between several services;
- an administrator troubleshooting NFS or SMB access;
- anyone who has ever used
chmod -R 777because a tutorial said it would fix the problem; - experienced users who want a repeatable permissions design instead of one-off repairs.
You do not need to memorize every command before the guide becomes useful. Start with the access decision, learn how directories differ from regular files, and use the later sections as references when Docker, ACLs, network shares, or security policies enter the picture.
The examples use fabricated users, groups, paths, addresses, and service names. Adapt them to your environment rather than copying identifiers blindly.
The complete permissions model
A useful way to think about file access is as a stack of gates. A process must pass every gate that applies. Opening one gate does not cancel a denial at another.
| Gate | Question |
|---|---|
| Requested operation | Is the process reading, traversing, creating, renaming, deleting, linking, executing, or changing metadata? |
| Process identity | Which effective UID, primary GID, and supplementary groups does the live process actually have? |
| Path resolution | Which real host path, mount, symlink target, or container path is being used? |
| Path traversal | Can the process search every parent directory in the path? |
| File ownership and mode | Which owner, group, or other class applies, and does that class allow the operation? |
| ACL | Does an access-control entry and its mask grant the effective access expected? |
| Mount state | Is the filesystem mounted read-write, and do mount options allow the operation? |
| Filesystem behavior | Does the filesystem support Unix ownership, modes, ACLs, links, and the required operation? |
| Mandatory access control | Does SELinux or AppArmor permit this process to use this path? |
| Application sandbox | Does systemd, Docker, or another runtime impose an additional restriction? |
| Remote storage policy | Does the NFS or SMB server map the request to another identity or enforce another policy? |
| Application logic | Is the application itself rejecting the path, extension, owner, or workflow? |
Traditional owner, group, and other bits are discretionary access controls. They are important, but they are only one layer. A file can show 777 and still be inaccessible because the filesystem is read-only, a parent directory cannot be traversed, an SELinux label is wrong, an AppArmor profile denies the path, a systemd unit makes the path read-only, or an NFS server maps the request to an anonymous identity.
Root is also not a universal bypass. A privileged process can override many discretionary checks, but it can still be constrained by read-only mounts, immutable attributes, mandatory access control, user namespaces, NFS root squashing, container restrictions, or application behavior.
That is why changing mode bits repeatedly is not a reliable diagnostic method. First identify the exact operation and live identity. Then test the gates in order.
A safe practice area
Commands in this guide can alter ownership and access. Learn the mechanics in a disposable directory before applying them to application state or irreplaceable data.
# Run creation, inspection, and cleanup in the same shell.
LAB_DIR=$(mktemp -d "${TMPDIR:-/tmp}/permissions-lab.XXXXXX")
mkdir -p "$LAB_DIR/parent/child"
touch "$LAB_DIR/parent/child/example.txt"
stat -c '%A %a %U:%G %u:%g %n' "$LAB_DIR/parent/child/example.txt"
Review the exact temporary directory before removing it. The random suffix avoids reusing a pre-existing folder, and rm -r deliberately does not use force:
printf 'Disposable lab path: %s\n' "$LAB_DIR"
find "$LAB_DIR" -maxdepth 3 -print
# Only after confirming this is the lab created above:
rm -r -- "$LAB_DIR"
If the shell session or LAB_DIR value is lost, locate and inspect the test directory again rather than guessing a deletion path.
Do not practice recursive ownership or mode changes against /, /etc, /home, /var/lib, Docker’s internal storage, a mounted NAS, or a production data tree.
Part I: Linux permission fundamentals
Processes have identities
Linux evaluates file access for a process, not for the account name shown in a web interface or the user who launched a management command.
A process carries several identity values. For ordinary permission troubleshooting, the most important are:
- an effective user ID, or UID;
- an effective primary group ID, or GID;
- zero or more supplementary groups;
- capabilities and other security attributes that may grant narrowly scoped privilege.
The effective identity is what ordinarily matters for access checks. A program can begin under one account and deliberately drop privileges, switch effective IDs, or launch a worker process under another identity. Inspect the live process whenever possible instead of assuming its runtime identity from the installer, Compose file, or service name.
Run id to inspect the current shell:
id
Example:
uid=1000(operator) gid=1000(operator) groups=1000(operator),2000(media-services)
For machine-readable values:
id -u
id -g
id -G
Those commands describe the current shell. They do not prove that a native daemon or containerized application uses the same IDs. Later sections show how to inspect systemd and Docker processes directly.
User and group names are labels supplied by account databases. Local Linux filesystems generally store numeric owner and group IDs in inode metadata. Two systems can display different names for the same number, or the same name can refer to different numbers.
Inspect an account through the system’s configured name service:
getent passwd example-service
getent group media-services
This is more reliable than reading only /etc/passwd or /etc/group because getent also works with identities supplied through services such as LDAP or other Name Service Switch sources.
Names are for humans; numbers cross boundaries
Suppose a bind-mounted directory is owned by UID 2101 and GID 2000. A container process running as UID 2101 and carrying GID 2000 can match those ownership fields even if the account has a different name, or no name at all, inside the container.
The opposite is also true. A user named media on two different machines does not automatically have the same access. One host might assign that name UID 1005; another might assign it UID 2103. NFS and bind-mounted storage will not infer that the names were intended to represent the same person or service.
Use numeric output when investigating a mismatch:
ls -ln /srv/data
stat -c 'owner=%u group=%g mode=%a path=%n' /srv/data
A numeric owner that appears unfamiliar is evidence, not something to immediately “repair.” It may belong to a container image, a restored system, an NFS client, or a user-namespace mapping. Identify the writer before changing it.
Files, names, and metadata
A regular file contains data, but Linux also tracks metadata including:
- owner UID;
- group GID;
- permission and special-mode bits;
- file type;
- timestamps;
- inode number;
- link count;
- extended attributes;
- ACLs, when supported;
- security labels, when supported.
A filename is a directory entry that points to an inode. This distinction matters because operations such as renaming and deleting usually modify directory entries, while reading or changing file contents operates on the file itself. It also explains how two hardlink names can reference the same inode.
View the familiar summary with:
ls -l /srv/data
Example:
-rw-r----- 1 downloader media-services 734003200 Jul 29 09:15 example-video.mkv
The first field describes the file type and permission bits:
-rw-r-----
The next relevant fields identify the owner and group:
downloader media-services
The 1 after the mode is the link count. For a regular file, it tells you how many directory entries reference the inode. That becomes useful when verifying whether a media import created a hardlink rather than a second copy.
Use stat when you need a less ambiguous view:
stat /srv/data/example-video.mkv
A compact troubleshooting format is often easier to scan:
stat -c 'type=%F mode=%A (%a) owner=%U:%G ids=%u:%g inode=%i links=%h path=%n' \
/srv/data/example-video.mkv
Reading the mode string
Consider:
-rwxr-x---
The first character is the file type.
| Character | Type |
|---|---|
- | Regular file |
d | Directory |
l | Symbolic link |
b | Block device |
c | Character device |
p | Named pipe |
s | Socket |
The remaining nine positions are divided into three permission classes:
- rwx r-x ---
owner group other
| Class | Bits | Meaning |
|---|---|---|
| Owner | rwx | Used when the process UID matches the file owner |
| Group | r-x | Used when a process group matches the file group |
| Other | --- | Used when neither owner nor group matches |
Special bits can replace an x position with s, S, t, or T. Those are covered later. For now, read the nine ordinary positions as three separate sets of r, w, and x.
Linux selects one ordinary permission class
A critical detail is that owner, group, and other permissions are not added together.
Ignoring ACLs for the moment, Linux chooses the applicable class in order:
- If the process UID matches the file owner, evaluate the owner bits.
- Otherwise, if the file GID matches the process’s primary or any supplementary group, evaluate the group bits.
- Otherwise, evaluate the other bits.
Suppose a file is:
----rw-rw- 1 operator media-services 0 Jul 29 09:30 example
The owner class is ---, while group and other are both rw-.
- A process owned by
operatormatches the owner and receives no ordinary access. - A different process in
media-servicesuses the group bits and receives read/write access. - A process that matches neither uses the other bits and also receives read/write access.
Linux does not fall through to a broader class when the selected class denies access. ACLs extend this selection model, but they do not turn the three ordinary classes into cumulative permissions.
Ownership is not the same as permission to change ownership
Owning a file lets the owner change many of its mode bits, subject to filesystem and policy restrictions. It does not normally let that owner assign the file to any arbitrary user. Changing a file’s UID usually requires privilege. Group changes are also constrained to groups the caller is permitted to use.
This is why an application can own and edit a file yet receive Operation not permitted from chown.
Read, write, and execute on regular files
For regular files:
| Permission | Effect |
|---|---|
Read, r | Open and read the file’s contents |
Write, w | Modify, overwrite, append to, or truncate the file’s contents, subject to the operation and application |
Execute, x | Ask the kernel to execute the file as a program or script |
These permissions apply to the file’s contents. They do not independently control the filename stored in its parent directory.
Write permission on a file does not necessarily allow deleting or renaming it. Those operations modify directory entries, so the parent directory’s permissions matter. Conversely, a process may be able to delete a file it cannot read when it can modify the parent directory and no additional rule, such as the sticky bit,prevents deletion.
Execute permission also has a nuance for scripts. Running ./script.sh requires execute permission and a usable interpreter declaration. Running bash script.sh asks Bash to read the file as input, so read access to the script matters even when the kernel is not executing the script path directly. noexec mounts and security policies can add further restrictions.
Read, write, and execute on directories
Directory permissions describe access to the directory’s entries, not the contents of every file below it.
| Permission | Directory meaning |
|---|---|
Read, r | List the names stored in the directory |
Write, w | Add, remove, or rename directory entries, normally when combined with execute |
Execute, x | Search or traverse the directory and access known entries beneath it |
For most self-hosted workflows:
| Desired operation | Directory permissions ordinarily needed |
|---|---|
| List filenames | r plus x for useful metadata access |
| Access a known child name | x |
| Create a new file | w and x on the parent |
| Create a subdirectory | w and x on the parent |
| Rename within one directory | w and x on that directory |
| Move between two directories on one filesystem | w and x on both source and destination directories |
| Delete an entry | w and x on the parent, subject to sticky bit and policy |
| Read a child’s contents | x on each parent plus r on the child file |
This creates combinations that seem strange until you separate listing from traversal.
Read without execute
A process may be able to retrieve a directory’s stored names but be unable to inspect, open, or reliably use the entries:
dr--r-----
Commands may display names while failing to stat them, producing question marks or permission errors. For ordinary service data, directory read without execute is rarely a useful design.
Execute without read
A process may access a known filename but be unable to list the directory:
d--x--x---
This can be useful for narrowly designed traversal paths, but it is usually confusing in general-purpose self-hosted storage. An application that discovers files by scanning needs directory read as well as traversal.
Write without execute
Write permission alone is generally not enough to create, delete, or rename entries. Directory modification ordinarily needs both w and x.
This is why drw-rw---- is not a useful collaborative directory mode. The group appears to have write access, but it cannot search the directory to operate on entries.
Deletion belongs to the parent directory
A user may delete a read-only file if they have the required access to its parent directory. Conversely, a user may be able to edit a file but not delete it if the directory is not writable.
Example:
drwxrwx--- root media-services movies/
-r--r----- downloader media-services movies/example.mkv
A process in media-services can ordinarily remove example.mkv because the group can write and search movies/, even though the file itself is not writable. The sticky bit, an ACL, immutable attribute, mount state, or security policy can still change that outcome.
This distinction explains many failed media imports. An application may be able to read a downloaded file while lacking permission to rename it into a library directory, hardlink it, create the destination, or remove the source entry afterward.
Observed test: a locked file can still be deleted
I verified the parent-directory rule on October 30, 2026 in a disposable Linux container filesystem, not on a production host. The fixture created a root-owned temporary directory and a root-owned file with mode 000. A subprocess running as unprivileged UID/GID 65534, with supplementary groups cleared, attempted os.unlink() on that file. Only the directory mode changed between attempts.
| Parent directory mode | File mode | Observed unlink result |
|---|---|---|
0711 | 000 | Denied with PermissionError: [Errno 13]; file remained |
0733 | 000 | Succeeded; directory entry disappeared |
This demonstrates that removing a directory entry is controlled by access to the containing directory, not by the file’s read/write bits. The second mode grants write and search permission to other users, which is not a recommended production configuration. It was used only to isolate the rule in a throwaway fixture, which was cleaned up automatically.
The test did not cover sticky directories, extended ACLs, immutable attributes, read-only mounts, network shares, or SELinux/AppArmor restrictions. Those can change the result. For the authoritative error conditions, see unlink(2) and path_resolution(7).
Every parent directory matters
To open:
/srv/data/media/movies/example.mkv
a process needs search permission on every directory component:
/;/srv;/srv/data;/srv/data/media;/srv/data/media/movies.
It then needs the appropriate permission on example.mkv for the requested operation.
Inspect the entire path with:
namei -l /srv/data/media/movies/example.mkv
Example output may reveal a single restrictive parent:
f: /srv/data/media/movies/example.mkv
drwxr-xr-x root root /
drwxr-xr-x root root srv
drwx------ root root data
drwxrws--- root media-services media
drwxrws--- root media-services movies
-rw-r----- downloader media-services example.mkv
The final file and lower directories may look correct, but /srv/data blocks every non-root process. Changing the file to 777 would not grant traversal through that parent.
namei -l is one of the fastest ways to find a hidden traversal failure. Run it against the host path and, when relevant, inspect the corresponding path inside the container as well.
Symlinks can move the permission boundary
A path that looks like it lives under an application directory may actually traverse a symbolic link into another tree. Changing the permissions of the visible directory does not repair a restrictive parent on the resolved target. Worse, a recursive ownership repair made without checking links and mount boundaries may affect more data than intended.
Before changing metadata, inspect the path and resolve it on the machine where the application runs:
namei -l /srv/example/shared/record.txt
realpath -e /srv/example/shared/record.txt
findmnt -T /srv/example/shared/record.txt -o TARGET,SOURCE,FSTYPE,OPTIONS
namei -l shows path components, including symlinks and their destinations. GNU realpath -e resolves symlinks but fails if any component does not exist. That is useful for an existing file; for a file the service is trying to create, inspect the existing parent directory instead. findmnt -T identifies the filesystem for the path. If a symlink crosses into a different mount, confirm the resolved location before considering chown, chmod, or an ACL change.
Isolated validation, October 27, 2026: In a disposable Linux directory, a link named shared pointed to ../data. namei -l displayed that link and the target directory; realpath -e resolved app/shared/record.txt to data/record.txt. The test did not touch production storage. This establishes the behavior of those inspection commands in that fixture, not a guarantee about an arbitrary container or NAS.
A host path and a path inside a container can also resolve differently. Inspect both namespaces rather than assuming the container’s /data is the host’s /data. For a service that writes a new file, check the real parent directory, its traversal and creation permissions, and the mount backing it. Do not follow an unexpected symlink into a sensitive directory merely to make a permission error disappear.
See GNU realpath and namei(1) for command behavior.
Octal permission notation
Numeric modes assign values to the three ordinary permission bits:
| Permission | Value |
|---|---|
| Read | 4 |
| Write | 2 |
| Execute | 1 |
Add the values within each class:
| Value | Symbolic form |
|---|---|
| 7 | rwx |
| 6 | rw- |
| 5 | r-x |
| 4 | r-- |
| 3 | -wx |
| 2 | -w- |
| 1 | --x |
| 0 | --- |
A three-digit mode assigns one value to owner, group, and other:
7 5 0
│ │ └─ other: ---
│ └─── group: r-x
└───── owner: rwx
A leading zero is commonly written to make it clear that the number is octal:
chmod 0750 /srv/example
A fourth octal digit can represent setuid, setgid, and sticky bits. For example, 2770 means setgid plus rwxrwx---. Those special bits are covered in Part II.
Common modes include:
| Mode | Typical use |
|---|---|
600 | Private file writable only by its owner |
640 | Owner can write; group can read |
644 | Owner can write; everyone can read |
660 | Owner and group can read and write |
700 | Private directory or private executable |
750 | Owner full access; group can read and traverse |
755 | Publicly traversable directory or publicly runnable executable |
770 | Shared directory restricted to owner and group |
775 | Shared writable directory with read and traversal for others |
These are patterns, not universal recommendations. A public web asset, private key, database directory, download workspace, and media library should not receive the same mode merely because one number is familiar.
Always separate the question “What access is required?” from “Which number represents it?” Decide the workflow first, then translate it into owner, group, and other bits.
Symbolic permission notation
Symbolic modes are often safer for maintenance because they communicate intent and change only selected bits.
Classes:
u: owning user;g: owning group;o: others;a: all classes.
Operations:
+: add bits;-: remove bits;=: replace the selected class’s bits.
Examples:
chmod g+w shared-file
chmod o-rwx private-file
chmod u=rw,g=r,o= config.yaml
chmod -R g+rwX shared-tree
The capital X adds execute only to directories and to files that already have at least one execute bit. That makes it much safer than recursively adding lowercase x to every file.
Compare the intent:
# Dangerous for a data tree: makes every regular file executable.
chmod -R g+rwx /srv/data
# More appropriate when the group needs read/write and directory traversal.
chmod -R g+rwX /srv/data
Even g+rwX can be too broad for a mixed tree containing secrets, databases, backups, and shared data. Scope recursive operations to one well-understood data class.
Why chmod -R 777 is the wrong repair
777 grants read, write, and execute to the owner, group, and every other local process.
On a multi-service host, “other” may include:
- web applications;
- monitoring agents;
- compromised containers;
- temporary maintenance accounts;
- unrelated service users;
- any code that gains execution under one of those identities.
Recursive 777 also marks ordinary documents, media files, database files, and secrets as executable even though execution is unnecessary.
It can appear to fix the problem because it bypasses part of the discretionary access model. It does not identify:
- which identity needed access;
- which operation failed;
- whether the selected owner, group, or other class was wrong;
- whether a parent directory blocked traversal;
- whether new files will inherit the same problem;
- whether a mount, ACL, sandbox, or security policy is the real cause.
It may not fix the problem at all. A read-only mount, immutable attribute, SELinux denial, AppArmor profile, systemd sandbox, NFS mapping, or incorrect container path can still reject the operation after every ordinary mode bit is enabled.
A better diagnostic is to test the exact operation as the service identity:
sudo -u example-service sh -c '
id
test -r /srv/data/media/example.mkv && echo readable
test -x /srv/data/media && echo traversable
touch /srv/data/media/permission-test
'
Then inspect the path, applicable class, ACL, mount, and policy layer that explains the result. Remove the test file after validation.
If a broad permission change is ever used in a disposable lab to prove a hypothesis, record the original metadata, isolate it from real data, reverse it immediately, and continue until the narrow required access is understood. It is not a production configuration.
Part I checkpoint
Before continuing, make sure these statements are clear:
- permissions are evaluated for a live process identity;
- numeric IDs matter across containers and hosts;
- owner, group, and other are selected classes, not cumulative grants;
- file write permission and parent-directory write permission control different operations;
- directories require execute permission for traversal;
- every parent directory participates in path access;
chmodchanges mode bits but does not repair identity, mounts, ACL masks, or security policy;- durable permission design must control both existing entries and the next files an application creates.
Part II turns those fundamentals into a shared ownership and group design.
Part II: Ownership, groups, and shared access
Changing ownership with chown
Change the owner:
sudo chown example-service /srv/example
Change owner and group:
sudo chown example-service:example-service /srv/example
Change only the group:
sudo chown :media-services /srv/data/media
or:
sudo chgrp media-services /srv/data/media
Before changing a large tree, inspect it:
find /srv/data/media -maxdepth 2 -printf '%u:%g %m %p\n' | head -n 100
Then make the narrowest change that solves the actual design problem.
Recursive ownership changes are powerful
This command changes every reachable entry under the path:
sudo chown -R example-service:media-services /srv/data
That may be correct for a newly created empty tree. It may be disastrous on a mature tree containing database data, application configuration, backups, or files intentionally owned by several users.
Before using -R:
- Confirm the exact path.
- Confirm the mount is present so you are not modifying an empty fallback directory on the root filesystem.
- Check whether the tree crosses into another mount.
- Check for symbolic links and understand the command’s traversal behavior.
- Record a sample of current ownership.
- Prefer a targeted
findexpression when only some entries are wrong.
Example: change only entries currently owned by an obsolete UID:
sudo find /srv/data -xdev -uid 2105 -exec chown 2101:2000 {} +
-xdev prevents the search from descending into other mounted filesystems.
Building a shared service group
When several services legitimately need access to the same data, a shared group is usually cleaner than making one service own everything.
Create a group with an explicitly documented GID:
sudo groupadd --gid 2000 media-services
Add interactive and native service users:
sudo usermod --append --groups media-services operator
sudo usermod --append --groups media-services downloader
sudo usermod --append --groups media-services library-manager
Existing login sessions do not automatically gain new supplementary groups. Sign out and back in, restart the service, or use a new shell created with the appropriate group context.
Verify:
id operator
id downloader
id library-manager
For a systemd service, restart it after changing group membership:
sudo systemctl restart example-service
Inspect the running process rather than assuming the account change took effect:
pid=$(systemctl show --property MainPID --value example-service)
grep -E '^(Uid|Gid|Groups):' "/proc/$pid/status"
The setgid bit on shared directories
A shared directory often needs new files and subdirectories to inherit the directory’s group rather than each creator’s primary group.
Set the group and setgid bit:
sudo chgrp media-services /srv/data/media
sudo chmod 2770 /srv/data/media
The leading 2 enables setgid.
Displayed symbolically, a setgid directory may look like:
drwxrws---
New entries created beneath it inherit the media-services group on typical Linux filesystems. Newly created subdirectories usually inherit the setgid behavior as well.
Apply the group and setgid behavior to an existing directory tree carefully:
sudo find /srv/data/media -xdev -type d -exec chgrp media-services {} +
sudo find /srv/data/media -xdev -type d -exec chmod 2770 {} +
Do not apply 2770 to regular files. Files generally need a mode such as 660, not directory execute permissions.
sudo find /srv/data/media -xdev -type f -exec chgrp media-services {} +
sudo find /srv/data/media -xdev -type f -exec chmod 0660 {} +
The sticky bit
The sticky bit protects entries in a directory that multiple users can write.
The standard example is /tmp:
drwxrwxrwt
Without the sticky bit, a user with write and execute permission on a shared directory could delete or rename another user’s files. With the sticky bit, deletion and renaming are generally limited to the file owner, directory owner, or a privileged process.
Set it with:
sudo chmod 1777 /path/to/shared-dropbox
A sticky world-writable directory is appropriate for some temporary workflows. It is not a substitute for a deliberate shared-group design when services need to move and replace each other’s files.
Observed isolated Linux fixture, November 2, 2026: In a disposable directory with mode 1777, two files had different unprivileged owners. A process running as the owner of one file attempted to unlink both. Deleting the other user’s file failed with EPERM (Operation not permitted), while deleting its own file succeeded. The directory and remaining fixture file were removed afterward. This checks the sticky-directory ownership boundary on the test filesystem, not NFS/SMB behavior or a production service’s effective permissions. The Linux unlink(2) manual describes the sticky-bit restriction and the privileged-process exception.
Setuid and setgid on executable files
On executable files, setuid and setgid can cause a program to run with the file owner’s or group’s effective identity.
These bits are security-sensitive. Do not add them to self-hosted application scripts to “fix permissions.” A writable setuid program can become a direct privilege-escalation path, and many script interpreters intentionally ignore setuid scripts because the model is unsafe.
Find special permission bits in a tree:
find /srv -xdev -perm /6000 -ls
Shared self-hosted data usually needs setgid on directories, not setuid on executables.
Part III: How new files receive permissions
Fixing existing files is only half the problem. A durable design must control what happens when new files are created.
The creation mode and umask
Applications request an initial mode when creating a file or directory. The process’s umask removes selected permission bits from that request.
For a parent directory without a default POSIX ACL, the creation rule is:
requested mode AND NOT umask = resulting mode
This rule needs that qualification. If the parent has a default POSIX ACL, Linux inherits that ACL and limits it by the application’s requested mode; the process umask is ignored for that creation. Do not diagnose an inherited-ACL problem by changing umask alone. See umask(2) and acl(5).
Typical applications request:
666for regular files, because files do not need execute by default;777for directories, because traversal is required.
With a 022 umask:
| Entry | Requested | Umask | Typical result |
|---|---|---|---|
| File | 666 | 022 | 644 |
| Directory | 777 | 022 | 755 |
With a collaborative 002 umask:
| Entry | Requested | Umask | Typical result |
|---|---|---|---|
| File | 666 | 002 | 664 |
| Directory | 777 | 002 | 775 |
With a private 077 umask:
| Entry | Requested | Umask | Typical result |
|---|---|---|---|
| File | 666 | 077 | 600 |
| Directory | 777 | 077 | 700 |
View the shell’s current umask:
umask
umask -S
Temporarily set a collaborative umask:
umask 0002
A process cannot gain bits the application never requested. Umask only removes permissions; it does not add them.
Isolated validation, October 29, 2026: I created two files in an automatically deleted temporary Linux directory using Python’s os.open, with a process umask of 027 and no default ACL configured. The file requested as 0666 was observed as 0640; the file requested as 0600 remained 0600. This checks the ordinary mode/umask rule only. It is not a test of POSIX default ACL inheritance, container identity mapping, or production service behavior. Reproduce in a disposable directory and inspect stat output before relying on it.
Umask for systemd services
A native service may receive its umask from its unit configuration rather than your interactive shell.
Inspect the effective unit:
systemctl cat example-service
systemctl show example-service --property UMask
Add a drop-in:
sudo systemctl edit example-service
Example:
[Service]
UMask=0002
Then reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart example-service
Use a group-writable umask only when the service’s group and data boundaries are intentional. A database or secrets directory may need a private 0077 policy instead.
Umask in containers
Container images handle umask in different ways. Some expose an environment variable such as UMASK; some set it in an entrypoint script; some ignore similarly named variables entirely.
Do not assume PUID, PGID, or UMASK are universal Docker settings. They are conventions implemented by particular images.
Check the image’s official documentation and inspect the running process. A Compose environment variable has no effect unless the image reads it.
When the image supports a documented setting, it may look like:
services:
example:
image: vendor/example:version
environment:
PUID: "2101"
PGID: "2000"
UMASK: "002"
Another image may require the standard Compose user field instead:
services:
example:
image: vendor/example:version
user: "2101:2000"
These are not interchangeable in every image. An image that expects to start as root and drop privileges after initialization may fail if it is forcibly launched with user. An image that does not understand PUID will simply receive an unused environment variable.
Default ACLs for inherited access
Setgid preserves group ownership, but it does not guarantee group write permission. A process with a restrictive umask can still create group-read-only files.
A default ACL can define permissions inherited by new children.
Install ACL utilities where needed:
sudo apt install acl
View ACLs:
getfacl /srv/data/media
Grant the shared group access to the existing directory and define defaults for new entries:
sudo setfacl -m g:media-services:rwx /srv/data/media
sudo setfacl -m d:g:media-services:rwx /srv/data/media
sudo setfacl -m d:m:rwx /srv/data/media
A practical collaborative directory often uses:
sudo chgrp media-services /srv/data/media
sudo chmod 2770 /srv/data/media
sudo setfacl -m g:media-services:rwx /srv/data/media
sudo setfacl -m d:u::rwx,d:g::rwx,d:g:media-services:rwx,d:m::rwx,d:o::--- /srv/data/media
Test inheritance rather than trusting the command:
sudo -u downloader touch /srv/data/media/permission-test
getfacl /srv/data/media/permission-test
rm /srv/data/media/permission-test
Creation ACLs are not destination ACLs
Linux applies a parent directory’s default ACL when a new filesystem object is created there, and then limits inherited permissions according to the creation mode. Moving an existing file with rename(2) within one mounted filesystem changes its directory entry, not its identity or access ACL. A file created under downloads and renamed into media therefore normally keeps its original access ACL.
This can break a media workflow that sets a reader ACL only on the media destination. Give the read-only identity the necessary access at the source creation point too, or deliberately apply a verified ACL after import. Inspect with getfacl before and after a disposable test rename. If the move crosses a filesystem boundary, rename(2) fails with EXDEV and higher-level tools may copy and remove instead; that requires separate validation of ACL and metadata behavior.
Do not assume a default ACL can add permissions excluded by an application’s file-creation mode, or that a read-only Docker mount grants underlying Unix read permission. Both the host access rules and the runtime mount must permit the intended read operation.
Primary references: Linux acl(5), Linux rename(2), and Docker bind mounts.
Verify creation modes in a disposable directory
A parent default ACL is not a promise that every newly created file will be readable by a shared group. Linux combines the inherited default ACL with the file creation mode requested by the application. A program requesting 0600 can leave a named reader or group without effective access even when the parent default ACL grants read access. Inspect the new file rather than relying on the parent directory.
For an authorized test directory named acl-test, use a pre-existing, nonprivileged group that represents the intended reader. The following example assumes a group called example-readers already exists; replace it with an actual authorized test group before running the commands:
getent group example-readers
mkdir -m 700 acl-test
setfacl -m g:example-readers:rx,d:g:example-readers:rx,d:m:rwx acl-test
getfacl -e acl-test
The access ACL entry permits that group to traverse the test directory; the default entry supplies an inherited candidate for newly created objects. Use a fresh, disposable directory in a filesystem supporting POSIX ACLs. Do not reuse an application data directory or put sensitive files in this test location. If the group lookup fails, stop and choose an existing authorized group rather than inventing a production identity.
Then use a disposable Python fixture to request two different creation modes:
import os
from pathlib import Path
directory = Path("acl-test")
for name, mode in (("ordinary", 0o666), ("restrictive", 0o600)):
fd = os.open(directory / name, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode)
os.close(fd)
This example assumes the directory already exists, has an intentional default ACL, and contains neither test filename. Inspect results with getfacl -p acl-test/ordinary acl-test/restrictive and stat -c '%a %u:%g %n' acl-test/*. Confirm effective access using the intended reader identity, not just the ACL text. Run the fixture only in a disposable directory; the output is not a claimed test result.
Verify the ACL that the reader actually receives
After creating disposable files, compare the parent default ACL with each file’s access ACL. Use -e to display effective rights explicitly, including entries that the ACL mask restricts:
getfacl -e -p acl-test acl-test/ordinary acl-test/restrictive
stat -c '%a %u:%g %n' acl-test/ordinary acl-test/restrictive
Check both the named reader or group entry and mask::. The displayed r-- on a named entry is not sufficient if the effective rights comment says ---. Also verify execute/search permission on every parent directory before attributing a read failure to the file itself. A default ACL on the parent affects new objects; it is not a retroactive fix for existing files.
When a parent has a default ACL, Linux derives the new access ACL from that default and the application’s requested creation mode. In that case, changing only the process umask is not a reliable fix for an application requesting 0600. Fix the application creation policy, select an appropriate service identity, or apply a narrowly scoped, verified post-creation ACL where the workflow permits. Test the real reader’s effective access using an authorized identity and representative path. A host test with a different group list is not proof that a container process can read the file.
This is a validation procedure, not a claimed measurement. See acl(5), getfacl(1), and umask(2).
Test same-filesystem renames separately from cross-filesystem moves. A rename preserves the original file object’s access ACL. Copying can create a new object, and ACL preservation then depends on the tool, options, and destination filesystem. Do not assume a destination default ACL repairs an already existing file.
References: Linux ACL behavior, open(2) creation mode, and getfacl(1).
Observed Linux fixture: default ACL versus requested creation mode
A separate disposable test on November 1, 2026 checked the creation-mode boundary with a named-user ACL, rather than assuming that the earlier group-based example would behave identically. A temporary directory received both an access ACL and a default ACL granting UID 65534 read and traversal rights. The directory’s ACL mask permitted those rights. A Python fixture created two files using os.open(..., O_CREAT | O_EXCL | O_WRONLY, mode) under a process umask of 027. ACLs were applied and read through the installed Linux libacl library, and file readability was checked with runuser as the unprivileged nobody account.
| Requested file mode | Observed file mode | Named-user ACL after creation | Read as UID 65534 |
|---|---|---|---|
0666 | 0640 | user:nobody:r-x, effective r-- | Succeeded |
0600 | 0600 | user:nobody:r-x, effective --- | Denied |
The parent default ACL was identical for both creations. The requested mode constrained the inherited ACL mask on the new files, so the restrictive creation did not become readable merely because the parent had granted read access. The test directory and files were removed after the fixture. This is an observed local Linux POSIX ACL result, not a Docker, NFSv4, Samba, or production-service test. The specific numeric modes are fixture observations, not promises for every filesystem or application. Recheck the actual file, its ACL mask, and the intended service identity on the target system.
The result agrees with the documented creation rules in Linux acl(5) and open(2). It also explains why changing umask alone is not a reliable remedy when the application explicitly requests 0600.
An ACL match does not automatically fall back to other
When diagnosing a denied read, do not look at the other:: entry and assume it rescues every identity. The access-check algorithm in acl(5) first checks the owning user and matching named-user entries, then the applicable group entries, and only uses other when none of those identity classes matches. A named-user match with insufficient effective rights is a denial, not an invitation to use a more permissive other entry.
This is why the useful diagnostic is effective identity + matching ACL entry + mask + parent traversal, not the most permissive line printed by getfacl. Check supplementary groups and the actual application worker identity before making an ACL change.
Understanding the ACL mask
An ACL output may contain:
user::rwx
user:example-service:rwx
group::r-x
group:media-services:rwx
mask::r-x
other::---
The mask limits the effective rights of named users, named groups, and the owning group entry. In this example, an entry may display rwx but have effective r-x because the mask removes write access.
getfacl usually shows this explicitly:
group:media-services:rwx #effective:r-x
When an ACL appears correct but access still fails, inspect the mask.
Update it deliberately:
sudo setfacl -m m:rwx /srv/data/media
Why changing the group mode can change named ACL access
When an extended ACL contains a mask, the group permission triplet shown by ls -l and stat corresponds to the ACL mask, not necessarily to the owning group’s raw group:: entry. This is why a mode change can have consequences beyond the one group an operator had in mind.
For example, chmod g-w on such a file changes the mask and can remove effective write access from named users and named groups whose entries still say rw-. Conversely, chmod g+w can make previously masked write permission effective again for more than one ACL entry. It does not grant a right that the underlying named entry never contained.
Before and after any authorized mode adjustment, inspect both representations:
stat -c '%A %a %n' /srv/data/media
getfacl -e -p /srv/data/media
Do not assume that a familiar chmod 775 or chmod g+w repair is narrow when named ACL entries exist. Decide which identity should gain or lose access, review the full mask, and make the smallest tested change. This relationship follows the Linux ACL mode-bit correspondence and the getfacl effective-rights display.
Removing all extended ACL entries can change the permission model significantly:
sudo setfacl -b /srv/data/media
Do not run that recursively until you have captured and reviewed the current ACLs.
Back up ACL metadata before major changes:
sudo sh -c 'umask 077; getfacl -R -p /srv/data > /root/data-acl-backup.txt'
Restore from compatible getfacl output with:
sudo setfacl --restore=/root/data-acl-backup.txt
Verify an ACL recovery plan before you need it
The command above is a metadata restore, not a backup of file contents. The saved getfacl -R -p output records paths and ACL-related ownership information. It does not recreate deleted files, restore their contents, repair a missing mount, or recover SELinux labels and application configuration.
Before applying a saved ACL snapshot to a real service tree, inspect the snapshot privately and confirm that every referenced path still points to the intended filesystem. The -p option preserves absolute pathnames, so a restore can target live paths directly. Do not assume that a file copied from a different host, mount layout, or backup date is safe to replay unchanged.
The setfacl utility provides a non-writing preview:
sudo setfacl --test --restore=/root/data-acl-backup.txt
Its documented --test mode lists the resulting ACLs without applying them. This is a preview, not a substitute for a controlled restore drill. Review the scope, expected owners and groups, effective ACL masks, and any errors. A real restore can also attempt to restore owners and groups from getfacl comments. The manual states that it sets setuid, setgid, and sticky flags from saved flags comments, or clears those bits when such comments are absent. That behavior makes blind restores especially risky.
For a recovery drill, use a disposable filesystem tree and a deliberately prepared snapshot for that tree. Verify both metadata and actual service-identity access afterward. Keep a separate, tested backup for file data and other security policy layers. Do not run a recursive ACL restore against production merely because a preview command exited successfully.
Part IV: A safe permissions architecture for self-hosting
A good design separates private application state from intentionally shared data.
Separate configuration, state, cache, and shared content
Example layout:
/srv/selfhosted/
example-app/
compose.yaml
config/
cache/
/srv/data/
downloads/
media/
documents/
These categories need different policies.
| Data type | Typical access model | Reason |
|---|---|---|
| Compose files | Administrator-owned; limited write access | Changes alter deployment behavior |
| Secrets | Owner-readable only where possible | May contain credentials or tokens |
| Application config | Owned by the service identity; not shared broadly | Contains state and private settings |
| Database data | Dedicated service owner, commonly 700 or 750 directories | Database engines expect controlled ownership and modes |
| Cache | Service-owned; usually replaceable | Should not be shared unless documented |
| Downloads | Downloader writes; importers need controlled read/write access | Shared workflow data |
| Media library | Media services may share a group; playback service may need read-only access | Supports import, organization, and playback |
| Backups | Backup process writes; services may not need access | Protects recovery copies from compromised apps |
Do not solve a shared-media problem by giving every service access to every configuration directory.
Use least privilege by workflow
Map required actions before setting modes.
Example:
| Service role | Downloads | Media library | App config | Backups |
|---|---|---|---|---|
| Downloader | Read/write | No access | Own config | No access |
| Library manager | Read/write and remove completed items | Read/write and rename | Own config | No access |
| Media server | Read-only | Read-only | Own config and metadata | No access |
| Backup agent | Read as required | Read as required | Read as required | Write |
A media server usually does not need write access to movie files merely to play them. It may need separate writable locations for metadata, cache, subtitles, or transcodes depending on configuration.
When an application supports read-only library mounts, use them:
services:
media-server:
image: vendor/media-server:version
user: "2103:2000"
volumes:
- type: bind
source: /srv/selfhosted/media-server/config
target: /config
- type: bind
source: /srv/data/media
target: /media
read_only: true
That restriction reduces the damage possible if the application is compromised or misconfigured.
A reference shared-data pattern
The following pattern works well when several trusted services collaborate on one data tree:
- Create one documented shared group.
- Assign a stable GID.
- Add native service accounts or configure container processes to use that GID.
- Make shared directories group-owned.
- Apply setgid to directories.
- Use a
0002or0007umask where group write is required. - Use default ACLs if applications create modes that umask alone cannot make collaborative.
- Keep configuration and database directories outside the shared tree.
- Test creation, rename, deletion, and traversal as each relevant identity.
Example preparation:
sudo groupadd --gid 2000 media-services
sudo groupadd --gid 2001 media-readers
sudo install -d -o root -g media-services -m 2770 /srv/data/downloads
sudo install -d -o root -g media-services -m 2770 /srv/data/media
Default ACL option:
sudo setfacl -m g:media-services:rwx,g:media-readers:rx,m:rwx /srv/data/downloads /srv/data/media
sudo setfacl -m d:g:media-services:rwx,d:g:media-readers:rx,d:m:rwx /srv/data/downloads /srv/data/media
The read-only consumer must actually carry GID 2001 (or receive equivalent named-user ACL access). The host’s parent directories must also allow traversal. Do not put a media-only reader into the writer group. Apply ACLs to existing content separately after inspecting it; a directory’s default ACL is not retroactive. For a native media service, assign the media-readers supplementary group and restart the service. For a container, see the group_add example below.
The defaults above apply to files created in either downloads or media. That matters when a library manager renames an imported file within the same filesystem: the file keeps the ACL it received at creation, rather than acquiring the destination directory’s default ACL. A cross-filesystem move may instead copy data and create a new file, so validate that path separately.
Validate with representative users:
sudo -u downloader touch /srv/data/downloads/downloader-created
sudo -u library-manager mv \
/srv/data/downloads/downloader-created \
/srv/data/media/imported-by-library-manager
sudo -u media-server test -r /srv/data/media/imported-by-library-manager
sudo -u media-server test ! -w /srv/data/media/imported-by-library-manager
sudo -u library-manager rm -- /srv/data/media/imported-by-library-manager
Each command tests an actual workflow step rather than only inspecting metadata.
Part V: Docker permissions without guesswork
A container does not erase host permissions
A bind mount exposes a host path inside a container. Processes in the container still perform filesystem operations against the host filesystem.
The path changes; the underlying ownership and access rules do not disappear.
Example:
services:
example:
image: vendor/example:version
volumes:
- /srv/data/media:/media
Inside the container, /media refers to /srv/data/media on the host. If the container process lacks access to the host directory, changing the container path does not fix the problem.
Inspect the mount:
docker inspect example --format '{{json .Mounts}}'
Inspect identity inside the container:
docker exec example id
Inspect the host path numerically:
ls -ldn /srv/data/media
Those three observations usually reveal the mismatch.
Container root and host access
With a conventional rootful Docker daemon and no user-namespace remapping, UID 0 in a container has powerful access to bind-mounted host files. Container isolation still applies in other ways, but “it runs as root only inside Docker” is not a safe reason to mount sensitive host directories read-write.
Never mount broad host paths such as /, /etc, /home, or the Docker socket into an untrusted container merely to avoid permission work.
The Docker socket is especially sensitive:
/var/run/docker.sock
A process that can control the Docker daemon can commonly create privileged containers and access host data. Socket access should be treated as host-administrator-level privilege.
The Compose user field
Compose can set the user used to run the container process:
services:
example:
image: vendor/example:version
user: "2101:2000"
You can also use names if they exist inside the image, but numeric IDs are clearer when matching host ownership.
Before forcing a user:
- read the image documentation;
- check whether its entrypoint requires temporary root privileges;
- identify every writable path the application needs;
- make sure the image can resolve or tolerate the numeric identity;
- test upgrades, migrations, and first-run initialization.
Inspect the configured user:
docker inspect example --format '{{.Config.User}}'
An empty result does not necessarily mean the final application process runs as root. The entrypoint may drop privileges internally. Verify the live process:
docker exec example sh -c 'id; ps -eo user:20,pid,comm,args'
Minimal images may not contain ps. In that case, inspect /proc or use host-side docker top:
docker top example -eo user,pid,ppid,comm,args
PUID and PGID are image-specific conventions
Some container publishers provide environment variables named PUID and PGID. Their entrypoint uses those values to create or modify an internal account, adjust selected ownership, and launch the application under that identity.
Docker itself does not interpret these variables.
This will do nothing unless the image explicitly supports it:
services:
example:
environment:
PUID: "2101"
PGID: "2000"
Use the method documented by the image publisher. Do not combine user, PUID, and unrelated privilege-dropping settings without understanding their interaction.
Supplementary groups in Compose
A process may need its private primary group plus access to a shared data or device group.
Compose supports supplementary groups through group_add:
services:
example:
image: vendor/example:version
user: "2101:2101"
group_add:
- "2000"
This is useful when the service should keep its own primary group but also access media-services data.
It is also common for hardware devices. For example, a render device may be group-owned by a host group whose numeric GID must be supplied to the container. Discover the host’s actual device and group rather than copying a number from another system:
stat -c '%n %U:%G %u:%g %a' /dev/dri/renderD128
Device paths and group IDs vary across distributions and hosts.
Named volumes versus bind mounts
A bind mount exposes a path you manage directly:
volumes:
- /srv/selfhosted/example/config:/config
A named volume is managed within Docker’s storage area:
services:
example:
volumes:
- example-config:/config
volumes:
example-config:
Named volumes do not eliminate permissions. The process inside the container still needs access to the volume’s contents. They do reduce the temptation to manipulate the data casually from the host.
Inspect a named volume:
docker volume inspect example-config
Do not recursively change Docker’s internal storage directories while the daemon is running. Use a temporary container or documented application procedure when inspecting or repairing a named volume.
Example read-only inspection:
docker run --rm \
--mount type=volume,source=example-config,target=/data,readonly \
alpine:latest \
sh -c 'ls -lan /data'
Bind mounts can hide image content
Mounting a host directory over a non-empty image directory hides the files that were packaged at that container path.
If an image expects default files in /config and you bind an empty host directory there, the defaults may appear to “vanish.” That is mount behavior, not necessarily a permission failure.
Confirm whether the image initializes an empty directory or expects you to copy files first.
In Swarm, the node is part of the storage contract
A bind mount refers to a path on the node running the task. Swarm can reschedule tasks to another eligible node, and Docker documents that a bind source must exist on each node that might run the service. An identically named directory on two nodes does not prove it contains the same files or is backed by the same filesystem.
If a network share is expected, a plain directory can exist even when its remote mount is absent. Avoid writing production data into an unintended local fallback directory. Confirm the filesystem source, not just the path’s existence.
Read-only diagnostics:
# On a manager: locate the task and its node.
docker service ps example_service --no-trunc
# On the node that actually runs the task:
findmnt -T /srv/example-data
stat -c '%u:%g %a %n' /srv/example-data
namei -l /srv/example-data
These names and paths are illustrative. Check mount source and filesystem type, numeric identity, groups, ACLs, and any security-policy denials on the actual node. Placement constraints can restrict eligible nodes, but they cannot create shared storage or grant access.
Do not infer that a failed service has a mode-bit problem until the mount and task placement are verified. The checks above are documentation-derived; they are not claims of an observed Swarm incident.
Rootless Docker and user namespaces
Rootless Docker and userns-remap change how container UIDs and GIDs map to the host.
In rootless mode, container UID 0 maps to the host user running the rootless daemon. Additional container IDs are generally mapped through subordinate ID ranges configured in /etc/subuid and /etc/subgid.
That means a directory owned by your host user may appear as owned by root inside a rootless container, while another container UID maps to a high host UID.
Inspect subordinate mappings:
grep "^$(id -un):" /etc/subuid /etc/subgid
Do not “fix” high-number ownership created by a user namespace until you understand the mapping. Changing those files to your ordinary UID may break the container’s expected access.
Avoid startup-time recursive chown
Some images recursively change ownership every time they start. On a small config directory this may be acceptable. On a multi-terabyte media tree it can:
- delay startup significantly;
- generate unnecessary metadata writes;
- cross unexpected mounts;
- overwrite a carefully designed multi-user ownership model;
- make every restart risky.
Mount only the paths the application needs, and avoid pointing ownership-fixing entrypoints at broad shared trees.
Hardlinks and media imports
Hardlinks are often used so a downloader and library manager can reference the same data blocks without duplicating storage.
A hardlink requires:
- source and destination on the same filesystem;
- sufficient write and execute access to the destination directory;
- access to the source path;
- a consistent container path design that does not make one filesystem appear as unrelated mounts.
Separate bind mounts can obscure the shared filesystem relationship from applications. A common pattern is to mount one shared parent:
services:
downloader:
volumes:
- /srv/data:/data
library-manager:
volumes:
- /srv/data:/data
Then use paths such as:
/data/downloads
/data/media
This does not grant permission by itself. It makes the path and filesystem model consistent so the permissions design can work as intended.
For a deeper explanation, see Why Container Paths Have to Match Across the Media Stack.
Part VI: Native systemd service permissions
Containers are not the only place where access can be restricted. systemd can define identity, directories, umask, capabilities, and filesystem sandboxing.
Inspect the service definition
systemctl cat example-service
systemctl show example-service \
--property User \
--property Group \
--property SupplementaryGroups \
--property UMask \
--property DynamicUser \
--property ProtectSystem \
--property ProtectHome \
--property ReadWritePaths
A unit may specify:
[Service]
User=example-service
Group=example-service
SupplementaryGroups=media-services
UMask=0007
Let systemd create service directories
systemd can create and manage directories with the correct ownership for a service:
[Service]
User=example-service
Group=example-service
StateDirectory=example-service
CacheDirectory=example-service
LogsDirectory=example-service
RuntimeDirectory=example-service
These map to standard locations such as /var/lib, /var/cache, /var/log, and /run, depending on the directive.
This is often safer than placing broad mkdir, chmod, and chown commands in startup scripts.
Filesystem sandboxing can override ordinary expectations
A service may have correct Unix permissions and still receive a denial because its unit includes hardening such as:
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/example-data
ProtectSystem can make large parts of the filesystem read-only inside the service’s mount namespace. ProtectHome can hide or restrict home directories. ReadWritePaths can create explicit writable exceptions.
Inspect the effective unit properties before changing file ownership:
systemctl show example-service \
-p User -p Group -p SupplementaryGroups -p UMask \
-p DynamicUser -p ProtectSystem -p ProtectHome \
-p ReadWritePaths -p ReadOnlyPaths -p InaccessiblePaths
ProtectSystem=strict establishes a broadly read-only filesystem view for the unit, subject to documented exceptions. ProtectHome= can restrict home-directory trees, and InaccessiblePaths= can hide selected paths. ReadWritePaths= is a namespace exception, not a grant of Unix permissions: the process still needs ordinary write access, and it cannot make a read-only underlying filesystem writable. DynamicUser= also affects the service identity lifecycle and implies additional hardening; managed StateDirectory=, CacheDirectory=, LogsDirectory=, and RuntimeDirectory= are often preferable for service-owned writable paths.
The correct repair may be a narrow writable-path exception or an appropriate managed service directory, not broader chmod or chown. Namespace hardening is also not an unconditional security boundary when privileged processes or mount propagation are involved. Consult the systemd documentation for the actual unit configuration. Validate the write through the service’s intended operation, not just an unrelated interactive shell.
Part VII: Network shares and NAS storage
Remote storage adds an identity and policy boundary. Always determine whether the denial originates on the client, during identity mapping, or on the server filesystem.
NFS uses Unix-style identities
NFS commonly authorizes filesystem operations using numeric UIDs and GIDs supplied or mapped for the request. A user named operator on the client and a user named operator on the server do not necessarily match unless their IDs and NFS identity configuration align.
Check both systems:
id operator
ls -ln /export/path
A classic symptom is files that appear owned by an unexpected numeric ID or an anonymous account.
Root squashing is a security boundary
NFS exports commonly use root_squash, which maps requests from client UID 0 to an anonymous identity. This prevents root on a client from automatically acting as root on the server’s exported filesystem.
Do not disable root squashing merely because a root-running container cannot write to an export. A safer solution is usually to run the application with an explicit non-root UID and GID that the server recognizes.
Export options such as all_squash, anonuid, and anongid can intentionally map requests to a designated identity. Use them as part of a documented access model, not as trial-and-error flags.
Example concept:
/srv/exports/media 192.0.2.0/24(rw,sync,root_squash)
Use documentation-only networks such as 192.0.2.0/24 in published examples. Replace them with a deliberately scoped network in a real configuration.
After modifying exports:
sudo exportfs -ra
sudo exportfs -v
Diagnose NFS from both sides
Client checks:
findmnt -T /mnt/media
mountpoint /mnt/media
stat -c '%u:%g %a %n' /mnt/media
sudo -u example-service touch /mnt/media/nfs-permission-test
Server checks:
sudo exportfs -v
stat -c '%u:%g %a %n' /srv/exports/media
journalctl --since '10 minutes ago' | grep -i nfs
Separate four checks before editing an export or changing ownership:
- Export authorization: Is the client permitted to mount this export with the configured access flags?
- Mount state: Is the client actually using that export, and is it mounted read-write rather than read-only?
- Server-evaluated identity: Which numeric or mapped identity reaches the NFS server, including root squashing?
- Filesystem authorization: Does that identity have the required traversal, directory-write, and file access on the server?
A writable export allows qualifying requests; it does not bypass the server’s filesystem permission checks. A client-side chmod or chown can fail because the server does not permit that identity to change metadata. The server remains authoritative. Use only a disposable, authorized location for a write test, and remove test artifacts afterward.
NFSv4 ACLs are not POSIX default ACLs
The getfacl and setfacl examples earlier in this guide describe POSIX ACLs. An NFSv4 server may instead expose NFSv4 access control entries (ACEs), with allow/deny rules, inheritance flags, and separate permissions for operations such as deleting a file or deleting a child from a directory. Do not assume a POSIX default ACL or its mask:: field describes an NFSv4 share’s complete authorization policy.
First identify the actual mount and negotiated protocol:
findmnt -T /mnt/media -o TARGET,SOURCE,FSTYPE,OPTIONS
If this is an NFSv4 mount and the client and server support NFSv4 ACLs, the nfs4-acl-tools utilities provide a protocol-specific, read-only inspection command:
nfs4_getfacl /mnt/media
The command can be unavailable or unsupported for a particular client, export, or NAS implementation. In that case, inspect the server’s authoritative ACL interface and documentation rather than assuming that getfacl output, a numeric mode string, or an empty ACL display proves access is unrestricted.
NFSv4 ACEs distinguish principals such as OWNER@, GROUP@, and EVERYONE@; their permissions and inheritance rules are not interchangeable with POSIX user::, group::, mask::, and default: entries. A server may also normalize or reorder ACEs when storing them. Always validate effective access from an authorized client identity and inspect the server-side result.
Recovery boundary: A getfacl -R snapshot and setfacl --restore are not a portable backup-and-restore method for NFSv4 ACLs. Use a protocol- and server-appropriate ACL backup procedure, test it on disposable data, and preserve a separate content backup. Never paste an NFSv4 ACL into a POSIX setfacl command or assume a NAS ACL editor will preserve the same semantics.
Primary references: nfs4_getfacl(1), nfs4_acl(5), and nfs4_setfacl(1). These describe documented behavior, not a newly executed NAS test.
SMB has Windows and Unix permission layers
A Samba share can involve:
- SMB authentication;
- share-level allow and deny rules;
- mapping from the SMB account to a Unix identity;
- server-side Unix modes and ACLs;
- Samba
create maskanddirectory masksettings; - optional forced user or group behavior;
- client mount options that affect how ownership and modes are displayed.
A Linux CIFS client may mount a share with options such as uid, gid, file_mode, and dir_mode. These can control the ownership and permissions presented to the client, especially when the server does not supply Unix metadata. They do not necessarily change the authoritative server-side owner.
Example credential-file mount concept:
//fileserver.example/media /mnt/media cifs credentials=/root/.smb-media,uid=2101,gid=2000,file_mode=0660,dir_mode=0770,nofail,x-systemd.automount 0 0
Protect the credential file:
sudo chown root:root /root/.smb-media
sudo chmod 600 /root/.smb-media
Do not place SMB passwords directly in world-readable /etc/fstab entries.
Samba creation masks
On the server, Samba settings can shape new entries:
[media]
path = /srv/shares/media
read only = no
force group = media-services
create mask = 0660
directory mask = 0770
Treat this as a conceptual example, not a universal configuration. Share authentication, valid-user rules, ACL behavior, and the underlying filesystem still matter.
A mask limits bits; it does not grant them. Samba calculates creation modes and applies create mask and directory mask with a bitwise AND. A requested bit not present in the calculated mode does not appear merely because it is allowed by the mask. force create mode and force directory mode then add explicitly configured bits with a bitwise OR. For shared directories that require setgid inheritance, establish and verify the intended parent directory mode and server-side ACLs instead of assuming directory mask forces setgid. Samba’s inherit permissions = yes changes the creation model: directories inherit the parent’s mode (including setgid) and files inherit the parent’s read/write bits, subject to documented behavior. Do not combine these settings blindly.
For a disposable test file and directory created through SMB as the intended account, inspect the results on the server:
stat -c '%a %U:%G %n' /srv/shares/media/example-test-item
getfacl -p /srv/shares/media/example-test-item
Use an authorized, non-production test path. Inspect the effective Samba share configuration as well as the resulting host permissions. A successful local write does not establish SMB authorization, and a successful SMB write does not establish that a different local service identity can access the result.
force user makes all file operations occur as one Unix account and can erase useful accountability. Use it only when that identity model is intentional and documented.
NAS interfaces can hide the real model
Commercial NAS interfaces often provide presets such as “read/write,” “media,” or “everyone.” Those labels may configure several layers at once.
When troubleshooting, determine:
- the numeric owner and group on the NAS;
- whether ACLs are enabled;
- which protocol is in use;
- how the client identity maps to the NAS;
- whether the share is read-only;
- whether root squashing or SMB user mapping applies;
- whether snapshots or immutable policies restrict changes.
Do not assume the permissions displayed by a Linux client fully describe the server’s policy.
Part VIII: SELinux, AppArmor, mount options, and hidden restrictions
SELinux can deny access after mode bits allow it
SELinux evaluates security labels and policy in addition to ordinary Unix permissions.
Inspect labels:
ls -Zd /srv/data/media
ls -Z /srv/data/media | head
Check enforcement state:
getenforce
sestatus
Look for recent denials:
sudo ausearch -m AVC,USER_AVC -ts recent
A denial does not mean SELinux should be disabled. It means the process, path, and policy need to be examined.
For a path whose default label is known, restore it with:
sudo restorecon -Rv /path
For a persistent custom path, define the mapping and then restore labels:
sudo semanage fcontext -a -t appropriate_type_t '/srv/example(/.*)?'
sudo restorecon -Rv /srv/example
The correct type depends on the application and distribution policy. Do not copy a label from an unrelated service merely because it permits access.
Docker :z and :Z labels
On SELinux systems, Docker bind mounts can use label options:
:zfor content shared among multiple containers;:Zfor content private to one container.
Example:
services:
example:
volumes:
- /srv/example:/data:Z
These options relabel the host path. Relabeling broad system directories can damage host operation. Never apply :Z casually to paths such as /home, /usr, or /etc.
Service-mode caveat: Docker’s bind-mount documentation warns that when using services, :z, :Z, and even :ro can be ignored. Do not assume that an ordinary docker run example proves the effective behavior of a Docker Swarm service. Inspect the deployed service’s mounts and validate access from the service context. Never rely on an unverified read-only flag as the sole security boundary.
AppArmor confines applications by profile
Ubuntu commonly uses AppArmor. A profile can deny access even when owner, group, mode, and ACL checks appear correct.
Inspect status:
sudo aa-status
Look for denials:
sudo journalctl -k --since '15 minutes ago' | grep -i apparmor
A denial often identifies the profile, operation, and path. Adjust the profile narrowly or use the application’s documented data location.
Do not permanently disable AppArmor as the first troubleshooting step. That removes a protection boundary and leaves the original path-design problem unresolved.
Read-only and restrictive mount options
Inspect the filesystem containing a path:
findmnt -T /srv/data/media -o TARGET,SOURCE,FSTYPE,OPTIONS
Relevant options include:
ro: read-only;rw: read-write;noexec: do not execute binaries from the mount;nosuid: ignore setuid and setgid effects on executable files;nodev: do not interpret device special files;- filesystem-specific ownership, ACL, and permission options.
A service may report Permission denied when attempting to execute from a noexec mount even though the file has x bits.
Check whether the filesystem was remounted read-only after errors:
findmnt -T /srv/data/media
journalctl -k --since today | grep -Ei 'read-only|filesystem error|I/O error'
Do not force a failing filesystem back to read-write until storage health and recovery implications are understood.
Filesystems without native Unix ownership
FAT-derived filesystems and some NTFS or exFAT configurations do not store Unix ownership and mode bits in the same way as ext4, XFS, Btrfs, or ZFS. Linux may present synthetic ownership and permissions based on mount options such as uid, gid, umask, fmask, and dmask.
On those filesystems, chown or chmod may appear ineffective because the mount policy determines the displayed mode.
They can be appropriate for removable interoperability. They are usually a poor default for Linux application databases, secrets, or service state that depends on native ownership, links, ACLs, or durable mode bits.
Extended attributes and immutable files
An immutable attribute can block modification even when ordinary permissions allow it.
Inspect attributes:
lsattr /srv/data/media/example-file
An i indicates immutability on supporting filesystems.
Remove it only when you know why it was set:
sudo chattr -i /srv/data/media/example-file
Also check storage capacity and inodes. Applications sometimes report generic write failures when the filesystem is full:
df -hT /srv/data
df -i /srv/data
Part IX: The permission troubleshooting playbook
Do not begin with chmod. Begin by defining the failed operation.
Find the denying layer before choosing the repair
The same message, Permission denied, can originate in multiple layers. This decision table is a diagnostic map, not a claim that each configuration has been tested on every distribution.
| Symptom or clue | Inspect first | Narrow response | Do not assume |
|---|---|---|---|
| An unprivileged process cannot cross a parent directory | namei -l, id, getfacl on each parent | Restore intended traversal or group/ACL access on the specific path | Giving the target file write access fixes traversal |
| An ACL entry appears to allow access, but the operation fails | getfacl effective rights and ACL mask | Correct the relevant entry or mask only after checking intended principals | A named ACL entry always has its full listed permissions |
| Host access works but a container cannot write | Container UID/GID/groups, bind source, mount mode, user namespace mapping | Align intended identities and paths without changing unrelated data | A container username equals the host username or host root |
| NFS mount is writable but a service gets denied | Export, mount, root squash, server-side identity and mode/ACL | Correct the responsible export or filesystem identity layer | rw on the export bypasses Unix permissions |
| New SMB-created directories lack expected group access | Effective Samba mask/force/inheritance configuration and server ACLs | Fix the intended creation policy and test through SMB | A directory mask guarantees every bit it mentions |
| Native systemd service is denied while an interactive test succeeds | systemctl show, sandbox directives, UMask, unit-owned directories | Fix the intended service sandbox or managed write path | chmod 777 can override a read-only namespace |
| SELinux AVC denial names the process and path | getenforce, labels, audit record | Apply the documented, narrow label/policy correction | Disabling SELinux is required |
| AppArmor denial names a restricted operation | aa-status, applicable profile, audit/kernel logs | Revise the appropriate rule or application path | Disabling AppArmor globally is a valid first step |
When the evidence points to multiple layers, change one thing at a time and rerun the same application operation. A writable interactive shell path does not prove that a restricted service or container has the same filesystem view.
Step 1: State the exact operation
Replace “the app cannot access storage” with something testable:
- cannot list a directory;
- cannot open an existing file for reading;
- cannot create a file;
- cannot create a subdirectory;
- cannot rename a file;
- cannot delete the source after import;
- cannot execute a script;
- cannot change ownership;
- cannot create a hardlink;
- can write locally but not through NFS or SMB.
Different operations require different permissions.
Step 2: Identify the live process identity
Native process:
ps -eo user:20,group:20,pid,ppid,comm,args | grep '[e]xample-service'
systemd:
systemctl show example-service --property User --property Group --property SupplementaryGroups --property MainPID
Docker:
docker inspect example --format 'configured-user={{.Config.User}}'
docker exec example id
docker top example -eo user,group,pid,ppid,comm,args
Do not rely only on Compose environment values. Confirm the running process.
Step 3: Resolve the real path and mount
Host:
readlink -f /srv/data/media
findmnt -T /srv/data/media
Container:
docker inspect example --format '{{range .Mounts}}{{println .Source "->" .Destination "rw=" .RW}}{{end}}'
Confirm that the expected disk or network share is actually mounted. A service can start before a mount and write into an empty local directory with the same path. When the real filesystem later mounts over it, the files disappear from view and ownership seems inconsistent.
Use systemd mount dependencies or Compose startup checks where appropriate; do not treat nofail as proof that storage is ready.
Step 4: Inspect every path component
namei -l /srv/data/media/movies/example.mkv
Then inspect numeric metadata and ACLs:
stat -c '%A %a %U:%G %u:%g %n' /srv/data/media/movies/example.mkv
getfacl -p /srv/data/media/movies/example.mkv
For creation or deletion, inspect the parent directory too:
stat -c '%A %a %U:%G %u:%g %n' /srv/data/media/movies
getfacl -p /srv/data/media/movies
Step 5: Test as the service identity
For a native user:
sudo -u example-service id
sudo -u example-service test -r /srv/data/media/example.mkv
sudo -u example-service test -w /srv/data/media
These test checks do not create or modify application files. Inspect their exit status, not just their lack of output. A successful test -w is a preflight, not proof that a real write will succeed: the application may use different groups, a sandbox, a different mount namespace, or a filesystem that later rejects creation for another reason. To test an actual write, use a separately approved disposable target, verify that the service identity can traverse its parents, and remove only the specific test file afterward. Do not create a permission-test file in live application data merely to follow a guide.
Include supplementary groups as the service would receive them. sudo -u normally initializes a target user context according to system policy, but compare id output to the live process when accuracy matters. The sudo(8) manual documents how the target identity, group vector, and environment are selected.
For Docker, test inside the running container at the mounted path:
docker exec example sh -c '
id
test -r /media/example.mkv && echo readable
test -w /media && echo directory-writable
'
Remove test files after validation.
Step 6: Check mount and filesystem conditions
findmnt -T /srv/data/media -o TARGET,SOURCE,FSTYPE,OPTIONS
df -hT /srv/data/media
df -i /srv/data/media
For network storage, repeat relevant checks on the server.
Step 7: Check ACL and security policy
getfacl -p /srv/data/media
ls -Zd /srv/data/media 2>/dev/null || true
sudo journalctl -k --since '15 minutes ago' | grep -Ei 'denied|apparmor|avc'
A + after the traditional mode in ls -l commonly indicates an extended ACL:
drwxrws---+ 4 root media-services 4096 Jul 29 09:30 media
Step 8: Read the application’s real error
Container logs:
docker logs --since 15m example
systemd logs:
journalctl -u example-service --since '15 minutes ago'
Look for the exact path and system call. “Import failed” may hide a failed rename, hardlink, database update, or deletion.
Step 9: Trace only when needed
When logs are insufficient, strace can reveal the failing system call:
sudo strace -f -e trace=file -p PROCESS_ID
Reproduce the failure briefly, capture the relevant denial, then stop tracing. Avoid collecting sensitive filenames or secrets in material that may be published or shared.
Common results include:
EACCES: permission denied;EPERM: operation not permitted, often involving privilege, attributes, or policy;EROFS: read-only filesystem;ENOENT: path not found, which can also indicate a wrong container path;EXDEV: cross-device operation, relevant to renames or hardlinks across filesystems;ENOSPC: no space left, including inode exhaustion;EDQUOT: quota exceeded.
Step 10: Make one narrow change and retest
Examples of narrow repairs:
- add one service to one group;
- correct one directory’s group owner;
- add setgid to shared directories;
- correct a default ACL mask;
- make one bind mount read-write;
- fix one systemd
ReadWritePathsentry; - restore one SELinux context;
- correct one NFS identity mapping;
- wait for the required network mount before starting the service.
Do not combine recursive ownership changes, mode changes, container recreation, security-policy disabling, and mount edits in one attempt. If the problem disappears, you will not know which action fixed it or which action weakened the system.
Part X: Real-world self-hosting scenarios
Scenario 1: A media server can see folders but cannot play files
Symptoms:
- library scan discovers directory names;
- individual files fail to open;
- the application logs a permission error.
Likely causes:
- directory read permission allows listing, but a parent lacks execute for traversal;
- files are group-owned correctly but the container lacks the supplementary group;
- files use
640and the service is neither owner nor in the group; - an ACL mask removes effective read access;
- the media bind mount points to a different host path than expected.
Diagnostic sequence:
docker exec media-server id
namei -l /srv/data/media/movies/example.mkv
stat -c '%A %a %u:%g %n' /srv/data/media/movies/example.mkv
getfacl -p /srv/data/media/movies/example.mkv
Preferred repair:
- give the playback identity read and traversal access through a dedicated group or ACL;
- keep the library mount read-only if the server does not need to modify media;
- keep cache, metadata, and transcode paths separately writable.
Scenario 2: A downloader finishes, but the library manager cannot import
Symptoms:
- the completed file exists;
- the library manager can see it;
- import, rename, move, or delete fails.
Likely causes:
- downloader creates files as
600or directories as700; - both services use different primary groups;
- the shared directory lacks setgid;
- an application uses a restrictive umask;
- download and library paths are presented as different filesystems inside containers;
- NFS root squashing maps a root-running container to an anonymous identity.
Preferred design:
- shared
media-servicesgroup; - both processes receive the shared GID;
- shared directories use setgid;
- new files are group-writable through
002umask or default ACLs; - both containers mount a common parent such as
/srv/datato/data; - configuration directories remain private to each service.
See The Definitive Guide to the *arr Stack on Ubuntu with Docker for the complete media-stack workflow.
Scenario 3: Files suddenly become owned by root
Symptoms:
- files created through one service show UID
0; - other services lose write access;
- manually fixing ownership works until the next download or scan.
Likely causes:
- a container runs as root and writes directly to the bind mount;
- an entrypoint is not applying the documented UID and GID settings;
- the configured environment variables are unsupported by that image;
- a scheduled task or administrator command runs as root;
- a network-share mapping converts the request to a different identity.
Preferred repair:
- Identify the process that creates the file.
- Configure that process to use the intended UID, GID, or supplementary group.
- Fix creation policy so future files are correct.
- Repair existing ownership narrowly.
- Verify after a real application-created file, not only
touchfrom your shell.
Scenario 4: An application can write on the host but not in Docker
Symptoms:
touchworks from the administrator shell;- the application reports read-only or permission denied;
- host ownership appears correct.
Likely causes:
- the shell user and container user differ;
- the bind mount is declared read-only;
- the container sees a different path;
- SELinux labeling blocks the container domain;
- the image runs under a remapped rootless UID;
- the application needs write access to a subdirectory that was not mounted.
Test the container identity directly:
docker exec example sh -c 'id; touch /data/container-test; rm /data/container-test'
Compare with:
sudo -u '#2101' sh -c 'touch /srv/data/host-test && rm /srv/data/host-test'
Use numeric sudo syntax only after confirming the intended UID exists or is safe to test.
Scenario 5: A database container refuses to start after a restore
Symptoms:
- the data directory was copied from backup;
- the database reports ownership or mode errors;
- broad group permissions appear to make no difference.
Likely causes:
- restored files have the backup process’s owner;
- numeric IDs differ from the original deployment;
- directory modes are broader or narrower than the database accepts;
- SELinux contexts were not restored;
- the backup captured inconsistent live database files rather than a supported backup;
- the restored data version does not match the engine version.
Preferred repair:
- follow the database’s official restore procedure;
- determine the database process’s actual UID and GID;
- restore required ownership only within the database data directory;
- restore security labels;
- avoid adding the database to a general media or application group;
- never treat recursive
777as a database recovery step.
Permissions cannot make an inconsistent database backup valid.
Scenario 6: NFS works for one container but not another
Symptoms:
- both containers mount the same host path;
- one can write and one cannot;
- both appear to run as root inside the container.
Likely causes:
- the containers use different effective or remapped host identities;
- root squashing maps UID
0to an anonymous server identity; - one process has a supplementary group that the other lacks;
- one Compose mount is read-only;
- server ACLs grant access to one mapped identity.
Inspect numeric IDs inside each container and ownership on the NFS server. Do not assume container UID 0 produces the same server request in every runtime configuration.
Scenario 7: A Swarm task fails after moving to another node
Symptoms: the service worked on its previous node but now cannot locate or write the expected files. The configured bind path exists in both places.
- Locate the scheduled task with
docker service ps. - On the assigned node, use
findmnt -Tto confirm the expected mounted filesystem, not only a matching directory name. - Inspect UID, GID, supplementary groups, ACLs, labels, and mount flags of that source.
- Compare these properties with a known-good eligible node, without copying ownership blindly.
- If writes might be going to the wrong local directory, prevent further production writes before repair.
- Restore the intended filesystem dependency, then use an authorized disposable test item to validate the required operation.
- Reschedule deliberately, repeat application-level validation, and document which nodes are safe for placement.
Do not run recursive chown on the fallback path. That can disguise a missing mount instead of recovering access to the intended data. A directory can have perfectly valid Unix permissions and still be the wrong directory.
Part XI: Common anti-patterns and safer replacements
| Anti-pattern | Why it fails | Safer replacement |
|---|---|---|
chmod -R 777 /srv/data | Grants every local process full access and does not fix identity or inheritance | Shared group, setgid directories, controlled umask, or ACL |
| Run every container as root | Expands impact of compromise and creates root-owned files | Use documented non-root identities and narrow mounts |
chown -R the entire storage tree on every restart | Slow, destructive to mixed ownership, and hides design flaws | Prepare ownership once and fix creation policy |
| Give all services the same UID | Removes accountability and over-shares private state | Separate UIDs with purpose-built shared groups |
| Put app config and shared media in one writable tree | A compromised app can alter unrelated service state | Separate private state from shared workflow data |
| Disable SELinux or AppArmor permanently | Removes a security boundary without proving the root cause | Read denials and add a narrow policy or correct label |
| Disable NFS root squashing | Lets client root act with excessive server authority | Map a non-root service identity deliberately |
Store SMB passwords directly in /etc/fstab | Exposes credentials to readers of the file | Root-owned 600 credentials file or a secret manager |
| Copy UID and GID numbers from a tutorial | IDs vary by host and image | Discover and document IDs locally |
| Trust usernames across machines | Filesystems and NFS care about numeric identity | Align IDs or configure explicit mapping |
| Set execute on every file recursively | Makes data executable and obscures intended modes | Separate directory and file changes or use capital X |
| Change several permission layers at once | Destroys evidence and makes the repair unrepeatable | Test one gate, make one reversible change, retest |
Part XII: A reusable deployment procedure
Use this procedure whenever a new self-hosted service needs persistent storage.
1. Classify every path
For each mount or directory, record whether it contains:
- immutable application code;
- configuration;
- secrets;
- database state;
- replaceable cache;
- shared workflow data;
- user-created content;
- backups.
2. Define required operations
For every service and path, choose:
- no access;
- read and traverse;
- create only;
- read/write;
- rename and delete;
- execute;
- metadata ownership changes.
Do not use “full access” as a default category.
3. Choose identities
Document:
| Component | UID | Primary GID | Supplementary groups |
|---|---|---|---|
| Downloader | 2101 | 2101 | 2000 |
| Library manager | 2102 | 2102 | 2000 |
| Media server | 2103 | 2103 | 2000 if required |
These are example values, not reserved standards. Check for conflicts before creating accounts:
getent passwd 2101
getent group 2000
4. Prepare directories deliberately
Private configuration:
sudo install -d -o 2101 -g 2101 -m 0700 /srv/selfhosted/downloader/config
sudo install -d -o 2102 -g 2102 -m 0700 /srv/selfhosted/library-manager/config
Shared data:
sudo install -d -o root -g 2000 -m 2770 /srv/data/downloads
sudo install -d -o root -g 2000 -m 2770 /srv/data/media
Read-only application access can be enforced through the runtime mount even when the process belongs to the shared group.
5. Configure the runtime using documented controls
Example Compose pattern:
services:
downloader:
image: vendor/downloader:version
user: "2101:2101"
group_add:
- "2000"
volumes:
- /srv/selfhosted/downloader/config:/config
- /srv/data:/data
library-manager:
image: vendor/library-manager:version
user: "2102:2102"
group_add:
- "2000"
volumes:
- /srv/selfhosted/library-manager/config:/config
- /srv/data:/data
media-server:
image: vendor/media-server:version
user: "2103:2103"
group_add:
- "2001" # media-readers, read/traverse via the prepared ACL
volumes:
- /srv/selfhosted/media-server/config:/config
- /srv/data/media:/media:ro
Pin versions according to your update policy rather than using an unexplained floating tag in production.
6. Test before importing real data
Use only an authorized, disposable test file and verify that all paths are the expected test mounts. Do not start or recreate a production stack merely to run this example. Work from the directory containing a reviewed Compose file, confirm the project and service names, and ensure the test paths are disposable and not shared with live application data. The service names below are placeholders to replace with names from docker compose config --services.
docker compose exec targets a Compose service, rather than assuming the container has a literal name such as downloader. The -T option disables pseudo-TTY allocation. It runs a shell using the service container’s configured user unless overridden; that user may not match the application’s actual worker identity. A passing shell test is therefore limited evidence, not proof that the application can complete its workflow.
# Read-only preflight: confirm the selected Compose project and services.
docker compose config --services
docker compose ps
# Run only after confirming these are disposable test mounts.
docker compose exec -T downloader sh -c 'id; touch /data/downloads/from-downloader'
docker compose exec -T library-manager sh -c '
id
mv /data/downloads/from-downloader /data/media/from-library-manager
'
docker compose exec -T media-server sh -c '
id
test -r /media/from-library-manager
test ! -w /media/from-library-manager
'
The example assumes the three services are already running, their test paths map to the same intended storage, and their images contain sh, touch, mv, and test. If those assumptions fail, stop and adapt the procedure to the real image and mounts rather than changing production permissions to make the example pass. For syntax and service targeting, see Docker Compose exec and Compose project naming.
A root shell can make the permission test lie
A container shell’s id output is part of the test result, not decorative context. In particular, test -r and test -w may report success for a privileged process even when an ordinary service identity would be denied by Unix mode bits. Container root can also be subject to user namespaces, dropped capabilities, read-only mounts, or security policies, so neither a pass nor a denial should be generalized without checking the runtime identity.
A disposable Linux fixture was executed on October 31, 2026. Two files were created in a traversable temporary directory: one with mode 000 and one with mode 0444. The shell built-in test was run first as UID 0 and then as unprivileged UID 65534 with supplementary groups cleared.
| Check | UID 0 observed | UID 65534 observed |
|---|---|---|
test -r on mode 000 | Passed | Denied |
test -w on mode 0444 | Passed | Denied |
Scope of this observation: This was a local Linux discretionary-access fixture, not a Docker Compose deployment, an ACL test, or a production application test. It demonstrates why a green shell check run as root is insufficient evidence of least privilege.
For the Compose workflow above, verify the actual application worker UID, GID, supplementary groups, and mount mapping. Run the read/write checks as that authorized identity when the image permits it. If a service intentionally runs as root, document that fact and verify its mount and security boundaries separately. A successful touch or test is not proof that the full application import workflow is authorized. For the Linux permission-bypass mechanism and the limits of access checks, see capabilities(7) and access(2).
Cleanup is a separate, verified operation. Resolve the Compose test mount to its actual host path before touching anything. The following is a read-only inspection template, not a literal path to delete:
# Replace this placeholder with the verified host path of the disposable file.
findmnt -T /path/to/verified-test-file
stat -- /path/to/verified-test-file
Confirm the resolved filesystem, filename, and identity against the test plan. Remove only that known disposable artifact through an authorized identity. There is intentionally no copy-and-paste deletion command here because the correct host path varies with the Compose mount mapping. If removal is denied, treat that as another permission finding instead of reflexively escalating to sudo rm. The test also needs an application-level import/read check before it can justify a production rollout.
7. Test restart and upgrade behavior
Only after the disposable test environment and service identity have been confirmed, plan a restart test for the specific test service. A bare docker compose restart can restart the whole project, including production dependencies, and should not appear in a permission-check recipe without a strict scope.
# Only in the approved disposable Compose project:
docker compose ps
docker compose restart --no-deps downloader
docker compose ps
Repeat the file-creation and effective-access checks after the test service returns. Confirm that startup scripts did not recursively change ownership or ACLs. A restart does not apply changes to a Compose service definition; validate configuration changes through a separately planned recreate test in a nonproduction environment. See Docker’s Compose restart reference.
8. Document the decision
Record:
- UID and GID assignments;
- group purpose;
- path ownership;
- expected modes;
- default ACLs;
- mount options;
- container user controls;
- SELinux or AppArmor requirements;
- NFS or SMB mappings;
- validation commands;
- rollback procedure.
A six-month-old permission choice is much easier to trust when the reason is written beside it.
Quick-reference command sheet
Identity
id
id example-service
getent passwd example-service
getent group media-services
Ownership and modes
ls -lan /path
stat -c '%A %a %U:%G %u:%g %n' /path
namei -l /path/to/file
sudo chown user:group /path
sudo chgrp group /path
sudo chmod 0640 /path/to/file
sudo chmod 2750 /path/to/directory
Safe tree inspection
find /path -xdev -maxdepth 3 -printf '%y %m %u:%g %p\n'
find /path -xdev -type d -perm -0002 -ls
find /path -xdev -type f -perm -0002 -ls
find /path -xdev -perm /6000 -ls
ACLs
getfacl -p /path
sudo setfacl -m g:group:rwx /path
sudo setfacl -m d:g:group:rwx /path
sudo setfacl -m m:rwx /path
getfacl -R -p /path > acl-backup.txt
Mounts and capacity
findmnt -T /path -o TARGET,SOURCE,FSTYPE,OPTIONS
mountpoint /path
df -hT /path
df -i /path
Docker
docker inspect container --format '{{.Config.User}}'
docker inspect container --format '{{json .Mounts}}'
docker exec container id
docker top container -eo user,group,pid,ppid,comm,args
docker logs --since 15m container
systemd
systemctl cat service-name
systemctl show service-name --property User --property Group --property SupplementaryGroups --property UMask --property MainPID
journalctl -u service-name --since '15 minutes ago'
Security layers
getfacl -p /path
ls -Zd /path
sudo ausearch -m AVC,USER_AVC -ts recent
sudo aa-status
sudo journalctl -k --since '15 minutes ago' | grep -Ei 'denied|apparmor|avc'
lsattr /path
Final checklist
Before declaring a permission problem fixed, verify:
- The exact failed operation is known.
- The live process UID, GID, and supplementary groups are known.
- The host and container paths map where expected.
- Every parent directory allows required traversal.
- The owner, group, and applicable mode class are correct.
- ACL entries and the ACL mask have been checked.
- New files inherit the intended group and permissions.
- The filesystem is mounted read-write and has free space and inodes.
- NFS or SMB identity mapping has been checked on the server.
- SELinux, AppArmor, and systemd sandboxing have been checked.
- The repair grants only the required access.
- The application can complete a real create, read, rename, and delete workflow as appropriate.
- The behavior survives a service or container restart.
- Temporary test files and temporary broad permissions have been removed.
- The final UID, GID, mode, ACL, and mount design has been documented.
The mental model to remember
Most Linux permission failures can be solved by answering these questions in order:
- What operation is failing? Reading, creating, traversing, renaming, deleting, executing, linking, or changing metadata are different operations.
- Which identity performs it? Use the live process UID, GID, and supplementary groups, not the username you expected.
- Which path is actually used? Resolve symlinks, mounts, container mappings, and network shares.
- Can the process traverse every parent? Directory execute permission is easy to miss.
- Which permission class applies? Owner, then matching group, then other; the classes are not cumulative.
- What will happen to the next file? Ownership inheritance, umask, setgid, and default ACLs determine whether the fix lasts.
- Which additional gate remains? Mount state, filesystem behavior, ACL mask, SELinux, AppArmor, systemd, Docker, NFS, or SMB may still deny access.
Once those questions become routine, Permission denied stops being a vague Linux problem. It becomes a specific failed check with a specific, testable repair.
Related guides
- The Definitive Guide to Jellyfin on Ubuntu with Docker
- The Definitive Guide to the *arr Stack on Ubuntu with Docker
- Why Container Paths Have to Match Across the Media Stack
- The Definitive Guide to Building a Homelab from Scratch
- The Checklist I Use Before Updating a Container
Publication QA: prove the identity and the mount before changing access
Before publishing a permission fix, the example should answer three separate questions: which identity performed the failing operation, which filesystem received it, and which access check actually failed. A command that succeeds as root answers none of those questions for an unprivileged service.
For a read-only preflight, run the following on the host that owns the affected path. Replace the example path only after confirming that it belongs to the intended service. The output may reveal local usernames and mount details, so do not paste it into a public issue without sanitizing it.
target=/srv/example-service/data
id
namei -l -- "$target"
findmnt -T "$target" -o TARGET,SOURCE,FSTYPE,OPTIONS
stat -c '%n %u:%g %a %A' -- "$target"
The four commands report different facts. id identifies the current shell, not necessarily the application. namei -l exposes traversal permissions on each component. findmnt -T identifies the filesystem and mount options selected for that path. stat reports numeric ownership and mode bits, but not the effective result of a POSIX ACL, SELinux, AppArmor, a remote server policy, or a container mount.
If the application is containerized, compare its documented runtime UID/GID and the actual mount mapping with this host-side evidence. Do not assume a shell entered with docker exec represents the process that originally failed. A writable host path can still be presented read-only inside a container, and the reverse is not established by a successful host test.
Publication check: any claim that a change fixed an application must be supported by a retest of the same failing operation as the intended service identity. Otherwise, describe the result as a hypothesis or a host-side inspection, not a confirmed repair. Do not run chmod -R, chown -R, or an ACL restore merely because the first inspection looks suspicious.
This is a documentation-backed diagnostic sequence, not a claimed production test. Primary references: namei(1), findmnt(8), stat(1), and Docker bind mounts.
Publication QA: separate access checks from an application repair
Before treating a permissions incident as resolved, record four distinct observations. They answer different questions and should not be collapsed into a single “works” result.
| Check | Evidence to capture | What it cannot establish |
|---|---|---|
| Host path inspection | Numeric owner/group, mode, ACL if present, mount source and options | Whether a confined container process can use the path |
| Service identity | Effective UID, GID, supplementary groups, and any user namespace mapping | Whether a write is permitted by the mount or security policy |
| Disposable access probe | Read, create, or rename as the relevant identity in an approved test directory | Whether the real application workflow is fixed |
| Application retest | The original failed operation, retried with authorized test data | Whether every other path and deployment will behave identically |
Suggested publication evidence record: note the filesystem type, the path’s mount boundary, the identity actually used for each probe, whether ACLs or a mandatory access control policy apply, and the observed error or success. If an experiment was performed only in a disposable local directory, say so explicitly. Never convert a hypothetical troubleshooting recipe into a reported production fix.
Recovery gate: before changing a real application’s data tree, identify who can restore access if the change locks out the service, confirm that backup and metadata restoration cover the intended filesystem, and plan a narrowly scoped rollback. A permission change is not a backup. An ACL export does not necessarily preserve SELinux labels, extended attributes, or remote-server policy. Avoid broad recursive changes until the actual denial mechanism has been identified.
This is an editorial validation checklist, not a claim that the guide’s examples have all been tested. See Linux access(2), Linux stat(2), and Docker bind mounts for the underlying distinctions.
Final review: distinguish metadata evidence from authorization evidence
A permission incident is not closed merely because ls -l shows the expected owner and mode. Capture the identity of the process that actually performs the operation, the entire resolved path, the mount and filesystem type, and the relevant access-control layer. In particular:
- Confirm the effective UID, GID, and supplementary groups inside the runtime, not only in an administrator’s shell. For a container, user-namespace mappings may make a host-side numeric identity misleading.
- Inspect every parent directory for search (
x) permission. A readable target file is inaccessible if the process cannot traverse its path. - If POSIX ACLs are present, inspect
getfacloutput and the effective permissions after the ACL mask. The group permission bits shown byls -lcan represent the ACL mask, not the owning group’s full nominal ACL entry. - Check the mount itself, including read-only state and filesystem-specific semantics. POSIX ACL commands do not establish NFSv4 ACL behavior, and a successful mode check does not establish write capability on a read-only mount.
- Separate discretionary access control from SELinux or AppArmor denials. Do not recommend disabling a security policy merely because a Unix mode appears permissive.
- Reproduce only in a designated disposable location using the application’s actual identity. Record the exact operation and exit status, then confirm the application itself can complete its intended task after the approved repair.
Publication evidence boundary: This checklist is a synthesis of documented Linux permission semantics and the isolated tests described elsewhere in this guide. It is not a claim that every listed combination has been tested on a production host. A successful test -w, access(2), or shell probe is supporting evidence, not a substitute for the real operation: filesystem state, credentials, and application behavior can change between the check and use.
For primary semantics, see acl(5), path_resolution(7), access(2), and Docker bind mounts.
Sources and further reading
- GNU Coreutils manual: file attributes,
chown,chgrp, andchmod - GNU Coreutils manual: symbolic permission notation
- Linux manual page:
setfacl - Docker documentation: bind mounts
- Docker Compose service reference
- Docker documentation: rootless mode
- Docker documentation: rootless UID and GID mapping
- Linux manual page: NFS exports
- Samba documentation:
smb.conf - Ubuntu Server documentation: AppArmor
- Red Hat documentation: SELinux policies for containers
Security note: Commands that change ownership, permissions, ACLs, mount options, or security labels can expose data or make services unusable when applied to the wrong path. Confirm the target, capture current metadata, use the narrowest possible scope, and maintain a tested recovery path before changing production data.
AI transparency: AI assisted with outlining, cross-checking terminology, and editorial refinement. The examples distinguish documented behavior from explicitly identified isolated tests. Commands identified as procedures are not claimed to have been executed against a production environment unless the surrounding text explicitly says so.