Home RSS Feed My CV

Building Containers from Scratch (Part 1)
The Foundation - chroot and Filesystem Isolation

Table of Contents

1 Introduction

My learning method follows a similar path most of the time. First, I start using the technology and learn my way into it. Once I feel comfortable, I try to recreate this technology myself. Not necessarily in the same way—my goal isn't to create something production-ready or at the same level of maturity as the original technology. But I focus on the building blocks that make this technology what it is.

In this blog series, I will demonstrate how to build containers from scratch using nothing but Linux primitives. No Docker, no Podman, no container runtimes. Just raw system calls and command-line tools. The goal is to understand what actually happens under the hood when you type docker run.

By the end of this series, you will have built:

  • A filesystem-isolated environment using chroot
  • Network isolation using network namespaces (ip netns)
  • Resource limits using cgroups
  • Multi-layered filesystems using OverlayFS
  • Bridge networking connecting multiple containers

This is Part 1: The Foundation. We start with the oldest and simplest container primitive: chroot.

2 Understanding chroot

chroot — short for "change root" — is the simplest form of filesystem isolation in Linux. It has been around since the early days of Unix (Version 7, 1979), making it one of the oldest isolation mechanisms available.

The idea simply is: chroot changes the root directory (/) for a process and all its children. Once a process is inside a chroot, every path it resolves starts from this new root. It literally cannot reference anything above it in the directory tree.

How It Works

container_filesystem

The process inside the chroot cannot see anything outside its new root. This is the foundation of filesystem isolation — and the first building block of every container.1

3 Building Your First Container

Prerequisites

  • A Linux system (I'm using Rocky Linux 9+)
  • Root access via sudo
  • Basic familiarity with bash and the Linux filesystem

The First Attempt

Let's start simple. Create a directory called my_container and try to chroot into it:

mkdir my_container
sudo chroot my_container /bin/bash
chroot: failed to run command '/bin/bash': No such file or directory

This makes sense. When chroot sets my_container as the new root, the path /bin/bash resolves to my_container/bin/bash on the host — and that file doesn't exist yet.

The fix is obvious — we need to create a bin/ directory and copy the bash binary into it:

mkdir -p my_container/bin
cp /bin/bash my_container/bin/

Try again:

sudo chroot my_container /bin/bash
bash: error while loading shared libraries: libtinfo.so.6: cannot open shared object file: No such file or directory

Progress! The binary was found, but now it fails to load. The reason: every dynamically linked binary depends on shared libraries (.so files) at runtime. Without them, the dynamic linker has nothing to work with.

We can use ldd to discover exactly which libraries bash needs:2

ldd /bin/bash
linux-vdso.so.1 (0x......)
libtinfo.so.6 => /lib64/libtinfo.so.6 (0x....)
libc.so.6 => /lib64/libc.so.6 (0x....)
/lib64/ld-linux-x86-64.so.2 (0x....)

The output shows that bash needs libraries from /lib64/. Let's extract just the paths:

ldd /bin/bash | grep -o '/lib[^ ]*'
/lib64/libtinfo.so.6
/lib64/libc.so.6
/lib64/ld-linux-x86-64.so.2

Let's create the library directory inside our container and copy these files:

mkdir my_container/lib64

# Copy each library
cp /lib64/libtinfo.so.6 my_container/lib64/
cp /lib64/libc.so.6 my_container/lib64/
cp /lib64/ld-linux-x86-64.so.2 my_container/lib64/

Now try chroot again:

sudo chroot my_container /bin/bash

We're in. This is our first container — primitive, but functional.

bash-5.1# pwd
/
bash-5.1# cd /bin
bash-5.1# pwd
/bin
bash-5.1# exit

From inside the chroot, / points to my_container/ on the host. We only have bash and its built-in commands (cd, pwd) at this point — no ls, no cat, nothing else. That's expected: if a binary isn't copied into the container, it simply doesn't exist.

4 Building a More Complete Container

The first container proved the concept. Now let's build something we can actually work with.

But before moving foreward, pause and think about what we're really doing here: the bash process in the previous example is "contained" in the new directory, and to do that we're constructing a minimal Linux filesystem from scratch.

We want the process inside to feel like it's running in a full Linux installation — its own binaries, its own libraries, and eventually its own network stack.

That's what a container fundamentally is: a carefully constructed filesystem combined with isolation mechanisms that control what the process can see, how many resources it can consume, and what privileges it can gain.

For the rest of this series, we'll need a few more tools inside the container: ls, ps, echo for basic interaction, and nc, ip for the networking parts.

Directory Structure

CONTAINER_ID='my_container'
mkdir -p ${CONTAINER_ID}/{bin,sbin,lib,lib64}

Copy the Binaries

# Install netcat if not present
sudo dnf install -y nmap-ncat

cp -v /bin/{bash,ls,ps,echo,nc} ${CONTAINER_ID}/bin/
cp -v /sbin/ip                   ${CONTAINER_ID}/sbin/
'/bin/bash' -> 'my_container/bin/bash'
'/bin/ls'   -> 'my_container/bin/ls'
'/bin/ps'   -> 'my_container/bin/ps'
'/bin/echo' -> 'my_container/bin/echo'
'/bin/nc'   -> 'my_container/bin/nc'
'/sbin/ip'  -> 'my_container/sbin/ip'

Copy the Libraries

Each binary has its own set of dependencies. Let's resolve and copy all of them in one pass:

binaries=(/bin/bash /bin/ls /bin/ps /bin/echo /bin/nc /sbin/ip)

ldd "${binaries[@]}" \
    | grep -o '/lib[^ ]*' \
    | while read lib; do
        [ -f "$lib" ] || continue
        mkdir -p "${CONTAINER_ID}/$(dirname $lib)"
        cp -n "$lib" "${CONTAINER_ID}/${lib}"
    done

/lib/x86_64-linux-gnu/libtinfo.so.6
/lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2
/lib/x86_64-linux-gnu/libprocps.so.8
/lib/x86_64-linux-gnu/libsystemd.so.0
/lib/x86_64-linux-gnu/libnsl.so.1
/lib/x86_64-linux-gnu/libnss_files.so.2
/lib/x86_64-linux-gnu/libm.so.6
...

The -n flag tells cp to skip files that already exist — so shared libraries like libc are only copied once, even though multiple binaries depend on them.

Enter the Container

sudo chroot . /bin/bash
bash-5.1# ls /
bin  lib  lib64  sbin

bash-5.1# ls /bin
bash  echo  ls  nc  ps

bash-5.1# ls -lh /bin
total 7.7M
-rwxr-xr-x. 1 root root 1.4M Feb 15 10:23 bash
-rwxr-xr-x. 1 root root  37K Feb 15 10:23 echo
-rwxr-xr-x. 1 root root 145K Feb 15 10:23 ls
-rwxr-xr-x. 1 root root  43K Feb 15 10:23 nc
-rwxr-xr-x. 1 root root 143K Feb 15 10:23 ps

bash-5.1# exit

Everything works. The container has an isolated filesystem, functional binaries, and the tools we'll need going forward.

Cool, chroot It's one layer — filesystem isolation. The process inside can still see host processes, share the host network, and consume unlimited resources.

In the next parts, we'll add the missing layers: network namespaces, and resource limits …etc. Each one brings us closer to what docker run actually does.

5 Next in the Series

We'll create network namespaces, wire them together with virtual ethernet pairs, and test connectivity between containers using the nc and ip tools we just staged.

Footnotes:

1

chroot changes the filesystem view but is not a security boundary. A privileged process can escape it.

2

ldd prints shared library dependencies by inspecting the binary's ELF dynamic section. On most Linux distributions, it uses the dynamic linker to resolve paths without fully executing the binary.

Creative Commons License

Copyright © 2026 Zakaria Kebairia
Content licensed under CC-BY-SA 4.0 unless otherwise noted.