10 · Project: Build and Boot a Minimal Root Filesystem¶
Every lesson in this level talked about the filesystem hierarchy, permissions, and processes as properties of
the system you're logged into. This project builds a second, completely independent root filesystem from
nothing but a package tool and a network connection, and uses chroot to run commands inside it — which forces
"what does an OS actually need before it's usable" to stop being abstract.
What debootstrap actually does¶
debootstrap is the tool Debian/Ubuntu installers use internally: given a target directory, a release codename,
and a package mirror, it downloads and unpacks just enough .deb packages to produce a minimal, bootable-in-spirit
root filesystem — without needing to actually boot anything. It runs in two stages: an initial unpack of core
packages, then a second stage that runs inside the new filesystem via chroot, so that package postinst
scripts (Lesson 9) execute in the correct environment rather than against the host.
Building it¶
$ sudo debootstrap --variant=minbase --arch=arm64 noble /tmp/minirootfs http://ports.ubuntu.com/ubuntu-ports
I: Configuring libstdc++6:arm64...
...
I: Configuring apt...
I: Configuring libc-bin...
I: Base system installed successfully.
(Real output from this course's lab VM — this command genuinely downloads and assembles a working minimal Ubuntu
24.04 "Noble" filesystem; expect it to take a few minutes depending on your network.) --variant=minbase asks for
the smallest useful set of packages rather than a full default install; --arch=arm64 matches this course's lab
VM (use your own machine's architecture, or cross-build a different one, which is exactly how you'd prepare a root
filesystem for, say, a Raspberry Pi from an x86 desktop).
Entering it¶
$ sudo mount --bind /dev /tmp/minirootfs/dev
$ sudo mount -t proc proc /tmp/minirootfs/proc
$ sudo mount -t sysfs sys /tmp/minirootfs/sys
$ sudo chroot /tmp/minirootfs /bin/bash -c "
cat /etc/os-release
echo hostname-in-chroot: \$(hostname)
echo whoami: \$(whoami)
ls /bin | wc -l
apt list --installed 2>/dev/null | wc -l
"
PRETTY_NAME="Ubuntu 24.04 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04 LTS (Noble Numbat)"
ID=ubuntu
hostname-in-chroot: lima-linuxlab
whoami: root
293
91
(Real output.) cat /etc/os-release reports Noble 24.04 — a different, self-contained distro release from the
26.04 LTS host VM this project is running on — proving the new root filesystem really is independent, not a view
into the host's own files. hostname reports the host's hostname, lima-linuxlab — because plain chroot
changes only which directory a process treats as /; it does not create a new UTS namespace (the kernel
mechanism that isolates hostname), so the chrooted shell still sees the real machine's hostname. 293 binaries and
91 packages is the entire footprint of a "minimal" Debian-family Linux system — small enough to read through
dpkg -l in a few minutes, which is itself worth doing once, just to see what's actually considered load-bearing.
What breaks without the bind mounts, and why that's the lesson¶
$ sudo umount /tmp/minirootfs/proc /tmp/minirootfs/sys /tmp/minirootfs/dev
$ sudo chroot /tmp/minirootfs /bin/bash -c "ps aux"
Error, do this: mount -t proc proc /proc
$ sudo chroot /tmp/minirootfs /bin/bash -c "uname -a"
Linux lima-linuxlab 7.0.0-34-generic ... aarch64 aarch64 aarch64 GNU/Linux
$ uname -a
Linux lima-linuxlab 7.0.0-34-generic ... aarch64 GNU/Linux
(Real output.) Unmounting /proc and re-running ps inside the chroot fails outright — ps has nothing to read,
because /proc isn't automatically present inside a chroot; it has to be deliberately mounted there, which is
exactly why the earlier bind-mount step exists at all and why debootstrap's own second stage does the same thing
internally. The second pair of commands is the project's real payoff: uname -a inside the chroot and on the
host report the identical kernel release, 7.0.0-34-generic. chroot did not boot a new kernel, did not boot
anything at all — the minimal root filesystem is running entirely on the one kernel the host machine was already
running, with a different / underneath it. That's the whole mechanism behind chroot, and it's also most of the
mechanism behind how container runtimes work before you add namespaces and cgroups on top (Level 3's namespaces
lesson builds exactly that on top of this same foundation).
How It Actually Works¶
A chroot() syscall records a new "root directory" pointer in the calling process's kernel-level process
descriptor; every subsequent path resolution that starts from / for that process (and, after fork(), its
descendants) resolves relative to that recorded directory instead of the real filesystem root. Nothing about
memory, networking, process visibility, users, or the kernel itself changes — which is precisely why plain chroot
is not a security boundary (a well-known escape technique for a process running as root inside a chroot is to
open a file descriptor to a directory outside it before the chroot call, then use that descriptor afterward) and
why modern container tooling layers PID, network, mount, and user namespaces (Level 3) on top of the same
chroot-style root-swapping to get real isolation.
Common mistakes¶
- Forgetting the bind mounts and assuming the rootfs is "broken."
ps,df, networking tools, and anything reading/procor/syswill fail or behave strangely without those filesystems explicitly mounted inside the chroot — this is expected, not a sign the build failed. - Treating
chrootas a security sandbox. As covered above, it isolates the filesystem view only; a privileged process can escape it, and an unprivileged one can still see and potentially signal processes outside it, since there's no PID namespace involved. - Not cleaning up bind mounts before deleting the rootfs directory.
rm -rfon a directory with/proc,/sys, or/devstill bind-mounted inside it can delete far more than intended (or fail oddly) — alwaysumounteach bind mount first.
Exercise¶
Build your own minimal root filesystem with debootstrap (or pacstrap on Arch, or a manual rpm --root
bootstrap on Fedora, if you'd rather use a different family). Chroot into it, confirm uname -a matches your host
kernel exactly, then try running a statically-linked binary you copy in from outside versus a dynamically-linked
one — the dynamic one will fail unless its required shared libraries also exist inside the chroot, which is a
direct, hands-on demonstration of what "dependency" really means at the binary level, not just the package level
from Lesson 9.