# 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.

```bash
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 `/`.

```bash
sudo chroot ~/rootdemo/newroot /bin/sh
```

Inside that shell:

```sh
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:

1. Put the new rootfs in place as a real mount.
2. Call `pivot_root`, which makes the new rootfs the current `/` and tucks the *old* root into a subdirectory (say `/oldroot`).
3. **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:

```bash
cd ~/rootdemo


sudo unshare --mount --pid --fork /bin/bash
```

You're now inside a shell in its own namespaces. Everything below runs *inside* it:

```bash

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:

```bash
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:

1. Create a new mount namespace.
2. Make its mounts private.
3. Bind-mount and prepare the container's rootfs.
4. `pivot_root` into it.
5. 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:

- `chroot` **relocates a pointer.** `pivot_root` **removes the target.** With `chroot`, the host filesystem is still mounted — hidden, but present. With `pivot_root` plus an unmount, it's detached and unreachable. That's the difference between hiding a door and removing the wall.
- `chroot` **is documented as *not* a security boundary.** Treating it as one is a classic mistake. A privileged process inside a plain `chroot` can 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_root` handles 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_root` is 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.*
