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.
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.
/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:³
rwxr-xr-- = 754.³One subtlety carries half the debugging value: the letters mean different things on directories:
| bit | on a file | on a directory |
|---|---|---|
r | read the contents | list the names inside |
w | change the contents | create/delete entries — even files you don't own |
x | run it as a program | enter 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:
- cat asks the kernel to open the file — every file touch is a syscall (P5 opens that door).
- First question: does the process uid match the file's owner? 1001 ≠ 1000 — move on.
- Second: is the file's group among the process's groups? dev isn't in "app" — move on.
- Fall through to the others triad: --- grants nothing. EACCES — the "Permission denied" you saw as dev.
- Root (uid 0) skips the checks entirely — that's why everything "just works" in your containers… so far.
- Two honest fixes: make dev the owner, or grant the group bit and put dev in the group. Pick one; don't chmod 777.
"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
- Images ship identities. Well-built images create a user and switch
to it. The postgres image you ran in Lesson 6 did exactly that:
docker run --rm postgres:16-alpine id postgresuid=70(postgres) gid=70(postgres) groups=70(postgres),70(postgres) USERandCOPY --chown(Lessons 8 & 11) are justuseradd/chownbaked into image layers — you now know precisely what they write and why.⁶- The classic production bug: app runs as uid 1001 (good, non-root),
writes to a volume owned by uid 0 →
Permission denied→ crash-loop. You can now both read that failure and fix it (chownthe mount point in the Dockerfile, or match uids).
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
- Navigate any Linux tree by its standard map — /etc, /var, /usr/bin, /proc.
- Decode
-rwxr-x---on sight, in symbols or octal, for files and directories. - Predict which triad the kernel will use — and why first-match-wins never falls back.
- Fix a
Permission deniedhonestly withchmod/chowninstead of 777. - Explain why containers run as root and where
USERenters the story.
7Check yourself
You run chmod 640 app.conf (owner alice, group web).
What does that grant?
- alice and web may both write
- alice writes; web may only read
- everyone reads; only alice may write
- 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?
- read succeeds via the group triad
- read succeeds; owners always may read
- read denied; the owner triad rules
- read denied; cat requires execute bit
To cd into a directory, which permission must you
hold on it?
- the read bit r
- the write bit w
- the execute bit x
- 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
- Filesystem Hierarchy Standard 3.0 (what each top-level directory is for)
- man7 — credentials(7) (uid/gid, process identity)
- Shotts — The Linux Command Line, ch. 9 (mode strings, chmod, chown, su)
- man7 — path_resolution(7) (the owner→group→other check, directory execute bit)
- OWASP — Docker Security Cheat Sheet (containers-as-root risk, USER)
- Docker Docs — Dockerfile reference (USER, COPY --chown)