Why Docker Uses pivot_root (and Not Just chroot)
When you run docker run alpine sh and type ls /, you see Alpine's files — bin, etc, lib — and nothing from your actual machine. Your home folder, your SSH keys, your /etc/shadow: none of them are there. The container acts as if that small Alpine filesystem is the entire world.
So how does Docker give each container its own /?
Most people guess chroot — the classic Unix command for changing a process's root directory. That guess is close, and for years it was even true. But modern container runtimes use something stronger: a system call named pivot_root. The difference between the two is small in code and large in security, and understanding it tells you a lot about how containers really work.
In this post we'll build the idea up from the ground, run a real demo you can try yourself, and see exactly why the container world moved from chroot to pivot_root.
What "the root" actually is
Every process on Linux has its own idea of where the root directory / lives. Normally it points at your real filesystem. When a program opens a file like /etc/hosts, the kernel starts walking from that root.
Here's the key insight: if you can change where a process thinks / is, you can box it in. Point its root at a folder, and from then on that folder looks like the whole system to it. It can't name anything above it, because as far as it knows, there is nothing above it.
That boxed-in folder is called a rootfs (root filesystem). It holds a minimal Linux layout — /bin, /etc, /lib, and so on — just enough to run programs. Every container image you pull is, at heart, a packaged rootfs.
Let's grab one so we have something real to work with. Alpine is perfect because it's tiny.
mkdir -p ~/rootdemo/newroot
cd ~/rootdemo
curl -L -o alpine.tar.gz \
https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/x86_64/alpine-minirootfs-3.20.3-x86_64.tar.gz
tar -xzf alpine.tar.gz -C newroot/
ls newroot/
Now we have a folder that looks like a root filesystem. The rest of the post is about how to make a process live inside it — first the old way, then the way Docker actually does it.
The old way: chroot
chroot is the original tool for this. Give it a directory, and it becomes a process's new /.
sudo chroot ~/rootdemo/newroot /bin/sh
Inside that shell:
ls /
cat /etc/os-release
It works. The process now treats Alpine's folder as the whole filesystem. On the surface, this already feels like a container.
So why did Docker move away from it?
chroot was never a security boundary
This is the heart of the matter, and it's worth stating plainly: chroot changes where / points, but it does not take the rest of the filesystem away. Your host's real root is still mounted. It's still sitting there underneath. chroot just moved the process's pointer so it no longer looks at it.
That design has a well-known consequence, documented for decades: a privileged process inside a chroot can work its way back to the host filesystem. The exact techniques aren't the point here — the point is why they're possible at all. They're possible because the thing you're trying to hide was never removed. It was only hidden behind a pointer, and a sufficiently privileged process can move pointers.
The official Linux documentation says as much. The chroot man page itself warns that it is not intended to be used for any kind of security purpose. It was built to isolate a process's view of the filesystem for convenience and sanity, not to contain a hostile or compromised one.
For containers, that's a dealbreaker. A container's whole promise is that even if something inside it misbehaves, it stays inside. If the host filesystem is one clever move away, you don't have a boundary — you have a suggestion.
That's the weakness pivot_root closes.
The stronger way: pivot_root
pivot_root does something chroot fundamentally cannot. Instead of just repointing /, it swaps the root mount itself — and then lets you throw the old root away.
The sequence is:
- Put the new rootfs in place as a real mount.
- Call
pivot_root, which makes the new rootfs the current/and tucks the old root into a subdirectory (say/oldroot). - Unmount
/oldroot.
After step 3, the host filesystem isn't merely hidden — it is detached from this process's world entirely. There's no pointer left to chase, because the old root is no longer mounted anywhere the process can reach. The escape routes that haunt chroot have nowhere to lead: you can't climb back to a filesystem that isn't mounted.
This is exactly the reasoning the container developers gave when they made the switch. In the original commit that moved Docker's runtime from chroot to pivot_root, the author noted that if you broke out of a chroot you could reach the host filesystem — but after a pivot_root, the host filesystem is fully invisible to the namespace. Same goal, categorically stronger result.
The missing ingredient: a mount namespace
There's one prerequisite. pivot_root rearranges mounts, and we absolutely do not want to disturb the host's mounts while doing it. So before pivoting, the process enters a fresh mount namespace — its own private copy of the system's mount table. Any mount change it makes stays inside that namespace and never leaks out to the host.
The unshare command creates one for us. And this is precisely the order a real runtime follows: new mount namespace first, then pivot. The namespace is what makes the whole thing safe to perform on a live, running system.
Hands-on: build a container's filesystem yourself
No C code, no compiler — just unshare, mount, and pivot_root from a shell. These steps mirror, almost line for line, what Docker's runtime does for every container you start.
First, enter fresh namespaces:
cd ~/rootdemo
sudo unshare --mount --pid --fork /bin/bash
You're now inside a shell in its own namespaces. Everything below runs inside it:
mount --make-rprivate /
mount --bind newroot newroot
cd newroot
mkdir -p oldroot
pivot_root . oldroot
cd /
mount -t proc proc /proc
umount -l /oldroot
rmdir /oldroot
Now look at what you've got:
ls /
cat /etc/os-release
ls /oldroot
That last line is the whole point. With chroot, the host filesystem would still be mounted underneath, merely out of view. Here, after umount -l /oldroot, it is gone from this namespace. Nothing to reach, nothing to climb back to.
You just hand-built, in seven commands, the filesystem isolation Docker gives every container.
So what does Docker actually do?
When you start a container, the work flows down a chain: Docker → containerd → runc. runc is the low-level piece that actually talks to the kernel, and for the root filesystem it does essentially what you just did by hand:
- Create a new mount namespace.
- Make its mounts private.
- Bind-mount and prepare the container's rootfs.
pivot_rootinto it.- Unmount and detach the old root.
There is also a fallback. On a few unusual setups — some hardened kernels, certain rootless edge cases — pivot_root can't be used, and runc falls back to a carefully fenced chroot. If you've ever seen the --no-pivot option, that's this path. It exists precisely because the two aren't equal: pivot_root is the default, and the fallback is the compromise you accept only when you must.
Why this matters for security
It's tempting to file pivot_root under "obscure kernel trivia," but it sits right on the line between a container that contains and one that only pretends to:
chrootrelocates a pointer.pivot_rootremoves the target. Withchroot, the host filesystem is still mounted — hidden, but present. Withpivot_rootplus an unmount, it's detached and unreachable. That's the difference between hiding a door and removing the wall.chrootis documented as not a security boundary. Treating it as one is a classic mistake. A privileged process inside a plainchrootcan regain access to the host filesystem, which is exactly what you don't want from something meant to confine untrusted code.- Filesystem isolation is only one layer.
pivot_roothandles the what can I see question. Containers rely on several other mechanisms working together — namespaces for what a process can observe, cgroups for what it can consume, and capabilities, seccomp, and user namespaces for what it's allowed to do.pivot_rootis one well-chosen piece of a layered defense, not the whole of it.
The next time you drop into a container and see that clean, empty-looking /, you'll know it isn't a host filesystem politely looking away. It's one that was pivoted out and unmounted — genuinely gone from the container's world. That's not a cosmetic trick. It's the boundary doing its job.
Try the demo, then run cat /proc/mounts right before and right after the umount -l /oldroot step, and watch the host's mounts disappear from your namespace. Seeing it happen makes the idea stick.
Comments
Join the discussionThe chroot-escapes-through-open-handles point is what sells pivot_root: chroot only changes where a process thinks root is, while the old root stays mounted and reachable, and pivot_root actually swaps the mount so the host tree can be unmounted. Isolation by removal beats isolation by redirection.

