What Is a Container, Really? Building One From Scratch With Linux Primitives

▶ View as deck

Run docker run alpine /bin/sh and you get a shell in what looks like a brand-new machine: its own filesystem, its own network, its own process tree, its own resource limits. It feels like a tiny virtual machine. It isn’t. There’s no hypervisor and no guest kernel — a container is just an ordinary Linux process that has been lied to about the world around it.

The most effective way to internalise that is to throw away Docker for an afternoon and assemble a container by hand from the kernel primitives it sits on top of: a copy-on-write filesystem, network namespaces, NAT, control groups, and namespaced process isolation. This is the walkthrough we ran at our FOSS Meet ’26 container workshop, reproduced here so you can follow along on any Linux box.

A container is the intersection of four ideas — an isolated filesystem, an isolated network, capped resources, and an isolated process tree. Docker is the friendly bow tied around all four.

1. The filesystem: a copy-on-write root

Every container needs a root filesystem — the / it sees when it starts. We want each container to share a common base image without being able to corrupt it, which is exactly what a copy-on-write filesystem like btrfs gives us. We carve out a 5 GB loopback disk, format it as btrfs, and mount it:

sudo apt install btrfs-progs

# A sparse 5G file, formatted as a btrfs filesystem
truncate -s 5G ./btrfs-disk.img
mkfs.btrfs -f ./btrfs-disk.img

mkdir -p ./btrfs-mount
sudo mount -o loop ./btrfs-disk.img ./btrfs-mount

Inside that filesystem we create a subvolume for the base image and unpack a minimal Alpine Linux root into it:

sudo btrfs subvolume create ./btrfs-mount/base-image

curl -o alpine.tar.gz \
  https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.1-x86_64.tar.gz
sudo tar -xf alpine.tar.gz -C ./btrfs-mount/base-image

The trick that makes containers cheap is the next line. Instead of copying the base image for each container, we take an instant, copy-on-write snapshot. The container writes to the snapshot; the pristine base image is never touched. This is the same idea behind Docker image layers.

CONTAINER_ID="my-container"
sudo btrfs subvolume snapshot \
  ./btrfs-mount/base-image \
  ./btrfs-mount/$CONTAINER_ID

2. Networking: a private wire into the host

A container with no network is not much use. We give it one with a bridge (a virtual switch on the host) and a veth pair — a virtual Ethernet cable with a plug on each end. One plug goes into the bridge on the host side; the other will be handed to the container.

# A bridge acting as the container's gateway
sudo ip link add name bridge0 type bridge
sudo ip addr add 10.0.0.1/24 dev bridge0
sudo ip link set bridge0 up

# The virtual cable: veth_host <-> veth_cont
sudo ip link add dev veth_host type veth peer name veth_cont
sudo ip link set veth_host master bridge0
sudo ip link set veth_host up

Now the isolation. A network namespace is a completely separate networking stack — its own interfaces, routing table, and firewall rules. We create one, move the container end of the cable into it, and configure it from the inside:

# An isolated network stack for the container
sudo ip netns add netns_$CONTAINER_ID
sudo ip link set veth_cont netns netns_$CONTAINER_ID

# Configure the interface from inside the namespace
sudo ip netns exec netns_$CONTAINER_ID ip link set dev lo up
sudo ip netns exec netns_$CONTAINER_ID ip addr add 10.0.0.2/24 dev veth_cont
sudo ip netns exec netns_$CONTAINER_ID ip link set dev veth_cont up
sudo ip netns exec netns_$CONTAINER_ID ip route add default via 10.0.0.1

# Point DNS at a public resolver
sudo bash -c "echo 'nameserver 8.8.8.8' > ./btrfs-mount/$CONTAINER_ID/etc/resolv.conf"

At this point the container can reach the host at 10.0.0.1, but not the wider internet — its 10.0.0.0/24 addresses are private.

3. NAT: a route to the outside world

To let the container talk to the internet, the host has to translate its private address to a real one and forward the traffic. That’s Network Address Translation, done with three iptables rules. (Find your real uplink with ip route | grep default — here it’s wlan0.)

DIF="wlan0"          # the host's real interface
BRIDGE_IFACE="bridge0"

# Masquerade container traffic as the host's
sudo iptables -t nat -A POSTROUTING -o $DIF -j MASQUERADE
# Allow container -> internet
sudo iptables -A FORWARD -i $BRIDGE_IFACE -o $DIF -j ACCEPT
# Allow the replies back
sudo iptables -A FORWARD -o $BRIDGE_IFACE -m state --state RELATED,ESTABLISHED -j ACCEPT

4. Control groups: capping the blast radius

Isolation isn’t only about what a process can see — it’s also about what it can consume. A runaway container shouldn’t be able to starve the host of CPU or memory. cgroups (control groups) enforce those limits.

sudo apt update && sudo apt install -y cgroup-tools

# cgroup v1 and v2 differ — check which you have
stat -fc %T /sys/fs/cgroup

sudo cgcreate -g cpu,memory:/$CONTAINER_ID
sudo cgset -r cpu.weight=50 $CONTAINER_ID          # relative CPU share
sudo cgset -r memory.max=536870912 $CONTAINER_ID   # hard cap at 512 MB

5. Putting it together: one process, four cages

Now the payoff. We launch a single shell wrapped in all four mechanisms at once. Read this command from the outside in — each layer adds one more cage:

sudo cgexec -g cpu,memory:$CONTAINER_ID \      # resource limits
  ip netns exec netns_$CONTAINER_ID \          # isolated network
  unshare --fork --pid --mount --uts --ipc --mount-proc \  # isolated PIDs/mounts/hostname
  chroot ./btrfs-mount/$CONTAINER_ID \         # isolated filesystem
  /bin/sh -c "mount -t proc proc /proc && hostname $CONTAINER_ID && /bin/sh"

What each layer does:

  • cgexec — runs the process inside the CPU/memory cgroup we created.
  • ip netns exec — drops it into the isolated network namespace.
  • unshare --pid --mount --uts --ipc — gives it a fresh process tree (its shell is PID 1), its own mount table, its own hostname, and its own IPC. This is the heart of the isolation.
  • chroot — makes the btrfs snapshot the new /.
  • the inner /bin/sh — mounts /proc, sets the hostname, and starts the actual shell. In Docker terms, this is your ENTRYPOINT + CMD.

Run it, and you’re sitting in a shell that believes it owns the machine. ps shows almost nothing. hostname returns my-container. ping 8.8.8.8 works through the NAT. free -m sees the 512 MB cap. You have built a container.

6. Clean up

None of this is automatic — another thing Docker quietly handles. Tear it all down in reverse:

sudo cgdelete -g cpu,memory:/$CONTAINER_ID
sudo ip netns delete netns_$CONTAINER_ID
sudo ip link delete veth_host
sudo ip link set bridge0 down && sudo ip link delete bridge0
sudo btrfs subvolume delete ./btrfs-mount/$CONTAINER_ID
sudo umount ./btrfs-mount
rm -rf ./btrfs-mount && rm -f ./btrfs-disk.img alpine.tar.gz

Why this matters

Docker, containerd, and Kubernetes are enormous engineering achievements, but the core abstraction is genuinely this small: namespaces for isolation, cgroups for limits, and a copy-on-write filesystem for cheap, layered images. Everything else is ergonomics, packaging, and orchestration on top.

Once you’ve seen the primitives directly, the whole stack stops being magic. A crash loop, a noisy-neighbour CPU problem, a container that can’t resolve DNS, a volume mount that behaves strangely — they all map back to one of these five layers. That mental model is what lets you debug production infrastructure instead of guessing at it.

This is the kind of from-first-principles understanding we bring to the AI infrastructure and platform work we do at NH66. If you’re building systems where the abstractions need to hold up under real load, we’d love to talk.