This is Part 9 of the Building a Homelab with AI series. Previously: Sudoers Demystified | Next: Automating Sudoers with Ansible
Why Permissions Are the Foundation
In Part 8, we looked at sudoers and saw that correct file permissions (0440 root:root) are what prevent users from escalating their own privileges. But sudoers is just one file. Every file and directory on a Linux system has permissions, and getting them wrong is one of the most common ways homelabs get compromised — or just break in confusing ways.
I’ve been running Linux systems since the late ’90s, and I still occasionally get tripped up by permission issues. Not because the system is hard to understand, but because it’s easy to get lazy. chmod 777 fixes the immediate problem. Six months later, it’s a security hole you’ve forgotten about.
This post is about building a solid mental model so you never need chmod 777.
The Permission Model
Every file and directory in Linux has three sets of permissions applied to three categories of users:
Owner Group Others
Read r r r
Write w w w
Execute x x x
When you run ls -l, you see this as a 10-character string:
$ ls -l /etc/sudoers.d/<user>
-r--r----- 1 root root 30 Feb 15 18:43 /etc/sudoers.d/<user>
Breaking this down:
| Character | Meaning |
|---|---|
- | File type: - = regular file, d = directory, l = symlink |
r-- | Owner (root): read, no write, no execute |
r-- | Group (root): read, no write, no execute |
--- | Others: no read, no write, no execute |
Octal Notation: The Shorthand
The three permission bits per category (read, write, execute) map to octal digits:
| Permission | Binary | Octal |
|---|---|---|
--- | 000 | 0 |
--x | 001 | 1 |
-w- | 010 | 2 |
-wx | 011 | 3 |
r-- | 100 | 4 |
r-x | 101 | 5 |
rw- | 110 | 6 |
rwx | 111 | 7 |
So 0440 means:
4= owner: read only (r--)4= group: read only (r--)0= others: nothing (---)
And the leading 0 is the special bits (setuid/setgid/sticky — more on these later).
The Common Patterns
Here are the octal modes you’ll use 95% of the time in a homelab:
| Mode | Symbolic | Use Case |
|---|---|---|
0644 | -rw-r--r-- | Regular config files, HTML, text |
0755 | -rwxr-xr-x | Executables, scripts, directories |
0600 | -rw------- | Private keys, credentials, .env files |
0700 | -rwx------ | Private directories (e.g., ~/.ssh/) |
0440 | -r--r----- | Sudoers files (read-only, root only) |
0400 | -r-------- | Ultra-sensitive files (SSL private keys) |
The mental shortcut: if you can’t explain why a file needs write permission for group or others, it shouldn’t have it. Default to the most restrictive mode that still works.
Ownership: The Other Half
Permissions are meaningless without understanding ownership. Every file has an owner (user) and a group:
$ ls -l /home/<user>/.ssh/authorized_keys
-rw------- 1 <user> <user> 92 Feb 15 18:43 /home/<user>/.ssh/authorized_keys
Owner: <user>. Group: <user>. Mode: 0600.
This means only the <user> user can read or write this file. Not even root’s group members (there are none, since it’s group <user>) can access it — but root itself can always read any file, permissions notwithstanding. Root bypasses the permission system entirely.
Why Ownership Matters for SSH
OpenSSH is one of the most permission-paranoid services on a Linux system. It will silently refuse to use key files if the permissions are wrong:
# These must be correct or SSH auth silently fails:
~/.ssh/ # 0700, owned by the user
~/.ssh/authorized_keys # 0600, owned by the user
~/.ssh/id_ed25519 # 0600, owned by the user
~/.ssh/config # 0600, owned by the user
If authorized_keys is world-readable (0644), SSH might still work on some configurations, but if the parent .ssh/ directory is group-writable, SSH will ignore authorized_keys entirely. No error message on the client side. The connection just fails to authenticate with key and falls back to password (which, if you’ve disabled password auth as you should, means you’re locked out).
This is one of the most common “my SSH keys suddenly stopped working” problems. The fix is always permissions:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chown -R <user>:<user> ~/.ssh
Our bootstrap playbook sets these explicitly:
# From bootstrap-via-pve.yml
- name: Create .ssh directory
ansible.builtin.command:
cmd: >
pct exec {{ item.vmid }} -- bash -c
'mkdir -p /home/{{ admin_user }}/.ssh
&& chmod 700 /home/{{ admin_user }}/.ssh
&& chown {{ admin_user }}:{{ admin_user }} /home/{{ admin_user }}/.ssh'
No ambiguity. No “it works on my machine.” Codified, consistent, correct.
Directory Permissions: The Subtle Difference
Permissions on directories work differently than on files, and this catches people:
| Bit | On a File | On a Directory |
|---|---|---|
r (read) | Can read file contents | Can list directory contents (ls) |
w (write) | Can modify file contents | Can create/delete files in directory |
x (execute) | Can run as a program | Can enter the directory (cd) and access files by name |
The x bit on directories is the unintuitive one. Without it, you can’t cd into the directory or access any file within it — even if you know the filename and the file itself has permissive modes. A directory with r-- lets you list filenames but not actually read any of them.
A Practical Example
# Create a test directory
$ mkdir /tmp/permtest
$ echo "secret" > /tmp/permtest/file.txt
$ chmod 644 /tmp/permtest/file.txt
# Remove execute from the directory
$ chmod 644 /tmp/permtest
# Try to read the file
$ cat /tmp/permtest/file.txt
cat: /tmp/permtest/file.txt: Permission denied
# Even though the file is world-readable, the directory blocks access
$ ls /tmp/permtest
file.txt # Can list it (read on directory) but can't open it (no execute on directory)
This is why directories almost always need the x bit: 0755 for public directories, 0700 for private ones.
Special Permission Bits: setuid, setgid, and Sticky
The leading octal digit we usually write as 0 can actually be 0-7, encoding three special bits:
setuid (4xxx)
When set on an executable, the program runs as the file’s owner, not as the user who invoked it. This is how sudo itself works:
$ ls -l /usr/bin/sudo
-rwsr-xr-x 1 root root 232416 ... /usr/bin/sudo
See the s in the owner’s execute position? That’s setuid. When any user runs /usr/bin/sudo, the process runs as root. The sudo binary then checks the sudoers policy to decide what the calling user is allowed to do.
Other setuid binaries you use constantly: passwd (needs root to write /etc/shadow), ping (needs raw socket access on older systems), mount (needs root for mounting filesystems).
Homelab tip: setuid binaries are a common privilege escalation vector. Periodically audit your systems:
$ sudo find / -perm -4000 -type f 2>/dev/null
/usr/bin/sudo
/usr/bin/passwd
/usr/bin/chfn
/usr/bin/chsh
/usr/bin/gpasswd
/usr/bin/mount
/usr/bin/umount
/usr/bin/newgrp
/usr/bin/su
If you see anything unexpected in that list — a script you wrote, a third-party binary, something in /tmp — investigate immediately. Attackers love to plant setuid root binaries.
setgid (2xxx)
On a file, setgid runs the program with the file’s group. On a directory, it does something more useful: files created inside the directory inherit the directory’s group instead of the creating user’s primary group.
This is handy for shared directories:
# Create a shared directory for the docker group
$ sudo mkdir /opt/shared
$ sudo chown root:docker /opt/shared
$ sudo chmod 2775 /opt/shared
# Now any file created in /opt/shared will have group "docker"
# regardless of who creates it
Sticky Bit (1xxx)
On a directory, the sticky bit prevents users from deleting files they don’t own, even if they have write access to the directory. The classic example:
$ ls -ld /tmp
drwxrwxrwt 12 root root 4096 ... /tmp
The t at the end is the sticky bit. Everyone can write to /tmp, but you can only delete your own files. Without the sticky bit, anyone with write access to the directory could delete anyone else’s files.
Permissions in Docker Contexts
Homelab operators running Docker stacks hit permission issues constantly. Docker adds two twists:
1. UID Mapping
Inside a container, processes run as a UID. That UID maps to whatever user happens to have that ID on the host. If a container process runs as UID 1000 and your host user is also UID 1000, they’re effectively the same user for filesystem access on bind-mounted volumes.
# docker-compose.yml
volumes:
- /opt/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
The :ro (read-only) mount flag is critical. Even if the container is compromised, it can’t modify the Caddyfile on the host. Without :ro, a compromised container could rewrite your reverse proxy config and redirect traffic anywhere.
2. Named Volumes vs. Bind Mounts
Named Docker volumes (caddy_data:) are managed by Docker and typically owned by root. Bind mounts (/opt/caddy/Caddyfile:) use the host filesystem permissions directly. This means bind-mounted files need permissions set on the host:
# The Caddyfile should be readable by the caddy user in the container
# but not writable from inside the container
$ ls -l /opt/caddy/Caddyfile
-rw-r--r-- 1 root root 1842 ... /opt/caddy/Caddyfile
Root owns it, world-readable (fine for a config file with no secrets — the Cloudflare API token is injected via environment variable, not in the file).
The Permission Audit Checklist
Here’s a quick checklist I run on every homelab host:
# 1. SSH directory permissions
ls -la ~/.ssh/
# Expected: drwx------ (700) owned by your user
# 2. Sudoers permissions
sudo ls -la /etc/sudoers /etc/sudoers.d/
# Expected: -r--r----- (440) root:root
# 3. Credential files
ls -la ~/.portainer.env <vault-password-file> 2>/dev/null
# Expected: -rw------- (600) owned by your user
# 4. No world-writable files in sensitive locations
sudo find /etc -perm -002 -type f 2>/dev/null
# Expected: nothing (or very few known exceptions)
# 5. No orphaned setuid binaries
sudo find / -perm -4000 -type f 2>/dev/null | sort
# Expected: only standard system binaries
# 6. Docker socket permissions
ls -l /var/run/docker.sock
# Expected: srw-rw---- root:docker
# WARNING: anyone in the docker group effectively has root access
That last point deserves emphasis. The Docker socket is root-equivalent. Anyone who can talk to the Docker socket can mount the host filesystem into a container and read/write anything. The docker group is functionally the same as the root group. Keep membership tight.
The umask: Your Default Permission Policy
The umask controls what permissions new files get by default. It’s a mask — bits that are removed from the default permissions:
$ umask
0022
# Default for new files: 0666 - 0022 = 0644 (-rw-r--r--)
# Default for new dirs: 0777 - 0022 = 0755 (drwxr-xr-x)
A umask of 0022 means new files are readable by everyone but only writable by the owner. This is the standard default and it’s reasonable for most homelab scenarios.
If you’re paranoid (and on a multi-user system), 0077 removes all group and other access:
$ umask 0077
# New files: 0600 (-rw-------)
# New dirs: 0700 (drwx------)
For a single-user homelab LXC, the default 0022 is fine. Just be aware it exists — when a newly created file has unexpected permissions, check the umask.
What’s Next
Permissions are the foundation, but configuring them manually on every host is error-prone. In Part 10, we’ll codify all of this with Ansible — idempotent playbooks that ensure every machine in the homelab has correct sudoers rules, SSH permissions, and credential file modes. No more auditing by hand.