Docker Deep Dive · Prerequisites · P2 of 7

Files, Permissions & root

Every Permission denied you will ever meet — in a container, on a server, in a Dockerfile — comes from one kernel rule. Learn to read it, predict it, and fix it in one command.

⏱ ~25 min hands-on · repo chapter p2-files-permissions-and-root · cheatsheet: Linux & Shell

prereq P2 / 7

Before you start

cd docker-deep-dive
git pull
cd p2-files-permissions-and-root
docker run -it --name permbox -v "$(pwd)":/labs ubuntu:24.04 bash
docker run -it --name permbox -v "${PWD}:/labs" ubuntu:24.04 bash

That last line is your disposable Linux box for this lesson. The -v part mounts this chapter folder at /labs inside, so the self-grader travels with you — it's the bind mount from Lesson 5; if you're doing prerequisites first, paste now, understand later. Nothing you break in there can touch your computer.

1Everything is a file, and every file has an address

Linux organizes everything into one tree rooted at / — programs, configuration, logs, even your hardware. There's no C: drive, no Registry, no "Application Support": when you know which branch holds what, you can find your way around any Linux system, including the inside of every container you'll ever exec into. The layout is a standard (the Filesystem Hierarchy Standard¹), which is why it looks the same in Ubuntu, Alpine, and the postgres image.

/ /bin · /usr/bin the programs — where PATH looks (P1) /etc configuration — plain text files; L3's COPY lands app config here /var data that grows — logs, databases; L6's volume mounts here /tmp scratch space, world-writable, wiped on reboot /home · /root people's files — root's home lives apart, locked drwx------ /dev devices as files — /dev/null is a file you can write to forever /proc · /sys the kernel's live window — P5 opens it; L10's cgroups live here one tree, standardized (FHS) — the same map inside every image this course uses
The branches you'll actually visit. Config in /etc, growing data in /var, programs in /usr/bin, people in /home — and two "fake" branches (/dev, /proc) where the kernel pretends to be files.¹

Take the tour inside your permbox:

ls /
head -3 /etc/os-release
ls /var/log
ls -l /dev/null
ls -ld /root /tmp
bin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var

PRETTY_NAME="Ubuntu 24.04.4 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"

alternatives.log  apt  bootstrap.log  btmp  dpkg.log  faillog  lastlog  wtmp

crw-rw-rw- 1 root root 1, 3 Jul 17 03:14 /dev/null

drwx------ 2 root root 4096 Jun 10 02:16 /root
drwxrwxrwt 2 root root 4096 Jun 10 02:16 /tmp

Notice /dev/null starts with c — a character device pretending to be a file. "Everything is a file" isn't a slogan; it's the design. And here's the tie to everything you've built so far: a Docker image is exactly one of these trees, zipped into layers (Lesson 2). Prove it from your host terminal (second tab or window):

docker run --rm alpine ls /
docker run --rm ubuntu:24.04 ls /
alpine:  bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
ubuntu:  bin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var

Two different distros, one standard map. When Lesson 3's COPY app.py /app/ runs, it's writing into this tree; when you docker exec into Harbor Log, this is the terrain.

2Identity: you are a number

Permissions are meaningless without identity. Every process runs as a user — really a uid (number) plus a primary group (gid) and supplementary groups.² Ask who you are:

id
uid=0(root) gid=0(root) groups=0(root)

uid 0 — you're root, and every container starts this way

Root is not "an admin account"; it's uid 0, the identity the kernel exempts from permission checks. Unless an image says otherwise, containers run as root by default — you've been root in every lab since Lesson 1. Why that's a problem, and the USER instruction that fixes it, is Lesson 11's opening move.⁵

Where do users live? In a plain text file — the everything-is-a-file payoff:

head -4 /etc/passwd
useradd -m -s /bin/bash dev
grep dev /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin

dev:x:1001:1001::/home/dev:/bin/bash

useradd didn't do anything mystical — it appended a line: name : x : uid : gid : comment : home : shell. (Ubuntu 24.04 images ship a ready-made ubuntu user at uid 1000, so dev lands at 1001.) Now feel the difference identity makes:

su - dev
id
touch /etc/evil
exit
uid=1001(dev) gid=1001(dev) groups=1001(dev)
touch: cannot touch '/etc/evil': Permission denied

Same machine, same command, different uid — different answer. That's the whole game.

3Read the mode string

Every ls -l row leads with ten characters that answer "who may do what". Learn to read them once and you'll read them forever:³

- r w x r - x r - - app staff type - file · d dir l link · c device owner read write execute group read — execute others read only owner · group the two names ls -l shows next as octal: r=4 w=2 x=1, add each triad rwx = 4+2+1 = 7 r-x = 4+0+1 = 5 r-- = 4 → 754
The ten-character decoder. One type character, then three triads — owner, group, others — each answering read? write? execute? The octal shorthand just adds 4/2/1 per triad: rwxr-xr-- = 754.³

One subtlety carries half the debugging value: the letters mean different things on directories:

biton a fileon a directory
rread the contentslist the names inside
wchange the contentscreate/delete entries — even files you don't own
xrun it as a programenter it (cd) / traverse through it

Look back at the tour output: /root is drwx------ (root's home, nobody else may even enter) and /tmp is drwxrwxrwt — world-writable, with a trailing t (the sticky bit: everyone may create files, but you may delete only your own).

4How the kernel decides — watch a denial happen

When a process touches a file, the kernel runs one short algorithm — and a detail surprises almost everyone: it picks exactly one triad (the first that matches your identity) and never looks at the others.⁴ Step through a real denial:

process: cat data.db uid=1001(dev) · groups: dev /app/data.db app(1000):app · rw-r----- the kernel — permission check on every open() open("/app/data.db") — a syscall (P5) uid == owner? 1001 ≠ 1000 → no in group "app"? groups: dev → no others triad: --- EACCES · Permission denied same call as uid 0 → checks skipped (CAP_DAC_OVERRIDE) → opens fix: chown dev /app/data.db — or chmod g+r + usermod -aG app dev
  1. cat asks the kernel to open the file — every file touch is a syscall (P5 opens that door).
  2. First question: does the process uid match the file's owner? 1001 ≠ 1000 — move on.
  3. Second: is the file's group among the process's groups? dev isn't in "app" — move on.
  4. Fall through to the others triad: --- grants nothing. EACCES — the "Permission denied" you saw as dev.
  5. Root (uid 0) skips the checks entirely — that's why everything "just works" in your containers… so far.
  6. Two honest fixes: make dev the owner, or grant the group bit and put dev in the group. Pick one; don't chmod 777.
First match wins — then it stops. The kernel uses the owner triad if your uid matches, else the group triad, else others. It never falls back, even when a later triad would grant more.⁴

"Never falls back" sounds like trivia until you see it. Prove it — a file whose owner may do nothing but whose group may read, owned by dev in both:

touch /lab/proof.txt
chown dev:dev /lab/proof.txt
chmod 040 /lab/proof.txt          # owner: --- , group: r-- , others: ---
su - dev -c "cat /lab/proof.txt"
cat: /lab/proof.txt: Permission denied

dev is in group dev, and group may read — but dev owns the file, so only the owner triad (---) is consulted. First match wins.

5Lab — the fix-it loop

Build a small tree in /lab and drill the two commands you'll type for the rest of your career: chmod (change the mode) and chown (change the owner). Still inside permbox, as root:

mkdir /lab && cd /lab
echo 'echo "hello from $(whoami)"' > hello.sh   # writes the file — P3 explains >
./hello.sh
bash: ./hello.sh: Permission denied

Even root can't execute a file with no x bit anywhere — the one check uid 0 doesn't skip. (echo $? prints 126: "found but not executable" — P4 decodes exit codes.) Fix and confirm:

ls -l hello.sh
chmod u+x hello.sh                # symbolic: u=owner, +x=add execute
ls -l hello.sh
./hello.sh
-rw-r--r-- 1 root root 28 Jul 17 03:14 hello.sh
-rwxr--r-- 1 root root 28 Jul 17 03:14 hello.sh
hello from root

Now the octal drills — the three numbers you'll type on autopilot someday:

touch app.conf secret.env handoff.txt
mkdir public vault
chmod 644 app.conf                # rw-r--r-- world-readable config
chmod 600 secret.env              # rw------- owner-only secret
chmod 755 public                  # rwxr-xr-x enterable directory
chmod 600 vault                   # rw------- directory with NO x — a wall
chown dev:dev handoff.txt         # give it away (root only)
ls -l
-rw-r--r-- 1 root root    0 Jul 17 03:14 app.conf
-rw-r--r-- 1 dev  dev     0 Jul 17 03:14 handoff.txt
-rwxr--r-- 1 root root   28 Jul 17 03:14 hello.sh
drwxr-xr-x 2 root root 4096 Jul 17 03:14 public
-rw------- 1 root root    0 Jul 17 03:14 secret.env
drw------- 2 root root 4096 Jul 17 03:14 vault

Feel the walls from dev's side — three denials, each now predictable from the algorithm in section 4:

su - dev -c "cd /lab/vault"       # no x on the dir → can't enter
su - dev -c "cat /etc/shadow"     # the password store: rw-r----- root:shadow
mkdir /shared && chmod 777 /shared && touch /shared/root-owned.txt
su - dev -c "rm /shared/root-owned.txt" && echo "dev deleted root's file"
-bash: line 1: cd: /lab/vault: Permission denied
cat: /etc/shadow: Permission denied
dev deleted root's file

Read that last one again: dev deleted root's file. Deleting is a directory write (removing a name from the folder), so w on a 777 directory beats file ownership. That's why /tmp needs its sticky bit — and why chmod 777 is never the fix.

Now grade yourself — the chapter ships a self-checker:

bash /labs/check.sh
PASS  user dev exists
PASS  hello.sh is executable
PASS  hello.sh runs and greets
PASS  app.conf is 644 (rw-r--r--)
PASS  secret.env is 600 (rw-------)
PASS  public/ is a 755 directory
PASS  vault/ is a 600 directory (unenterable)
PASS  handoff.txt owned by dev:dev

8 passed, 0 failed

Any FAIL line tells you the exact fix. Re-run until green, then exit and docker rm -f permbox on your host.

6Why Docker cares about every bit of this

Honesty note for macOS & Windows users

Docker Desktop quietly translates file ownership on bind mounts between your host OS and the Linux VM, so some permission failures you'd hit on a real Linux server never appear on your laptop. The rules you practiced are exactly what runs in production — Desktop is just being polite. Named volumes and container-local paths (like /lab today) behave like real Linux, on every host.

You can now

7Check yourself

You run chmod 640 app.conf (owner alice, group web). What does that grant?

  1. alice and web may both write
  2. alice writes; web may only read
  3. everyone reads; only alice may write
  4. alice executes; web may only read

A file is mode 040, owned by dev:dev — and user dev is in group dev. dev runs cat on it. Result?

  1. read succeeds via the group triad
  2. read succeeds; owners always may read
  3. read denied; the owner triad rules
  4. read denied; cat requires execute bit

To cd into a directory, which permission must you hold on it?

  1. the read bit r
  2. the write bit w
  3. the execute bit x
  4. both read and write

Decode -rwxr-x---: type, each triad, and who is completely locked out.

A regular file (-). Owner: read+write+execute. Group: read+execute (no modifying). Others: nothing at all — anyone who is neither the owner nor in the group can't even read it. Octal: 750.

Why does "containers run as root by default" matter, and what's the Dockerfile-level fix?

The container's main process runs as uid 0, which skips permission checks — so a compromised app owns the whole container filesystem (and has more leverage against the host). Fix: create a user in the image and switch with the USER instruction (plus COPY --chown for the files it needs) — lessons 8 and 11.

8Go deeper

Primary source: The Linux Command Line (free PDF), chapter 9 "Permissions" — the clearest 20 pages ever written on this topic: linuxcommand.org/tlcl.php. For the kernel's-eye view, skim path_resolution(7).

Next: you've been quietly using > and pipes without being told what they are. P3 — Streams, Pipes & Text Tools makes them yours: stdin/stdout/stderr, redirection, and the pipeline tricks behind docker logs and half of every Dockerfile.

Stuck? Curious?

Bring questions to class, or open an issue on the course repo — include the command you ran and the output you got. The quizzes above are for self-checking: commit to an answer before revealing it, and re-try anything you missed tomorrow.

Sources

  1. Filesystem Hierarchy Standard 3.0 (what each top-level directory is for)
  2. man7 — credentials(7) (uid/gid, process identity)
  3. Shotts — The Linux Command Line, ch. 9 (mode strings, chmod, chown, su)
  4. man7 — path_resolution(7) (the owner→group→other check, directory execute bit)
  5. OWASP — Docker Security Cheat Sheet (containers-as-root risk, USER)
  6. Docker Docs — Dockerfile reference (USER, COPY --chown)