On a Linux host, start an nginx container, then run ps aux. nginx is right there in the list, with an ordinary host PID. No guest kernel booted and no hypervisor started. That one observation is the key to how Docker works under the hood: a container is a normal Linux process that the kernel agrees to lie to (namespaces) and to ration (cgroups). The rest of docker run is plumbing that sets those two things up.
This post walks the plumbing with two browser simulators, then shows the same kernel features on a real machine, no Docker required. Disclosure: I help build the simulators; they are free, run in the browser, and need no signup.
The path of one docker run
The How Docker Works Under the Hood simulator takes one command, docker run -p 8080:80 nginx, and steps down the stack in eight stages: the Docker CLI, the Docker daemon, the registry pull, containerd, the OCI bundle, runc, the running container, and the Linux kernel. Press Play, or use Prev and Next to go at your own pace. Tick "nginx already pulled" and the registry stage drops out (the counter goes from Step 1 / 8 to Step 1 / 7), the first lesson: a pull only happens on a cache miss, and only missing layers come down.
The stages worth pausing on:
-
The CLI is a REST client. It sends an HTTP request to dockerd over
/var/run/docker.sock. The stage shows acurl --unix-socketrequest to the daemon's/infoendpoint: it starts nothing, but it uses the same transport the CLI does. - dockerd does not start your process. It hands off to containerd, which unpacks the image layers into a snapshot and tracks the container's lifecycle.
-
The OCI bundle is the whole recipe. It is a
config.jsonplus a root filesystem. The excerpt in the simulator lists the five namespaces to create, with the type names from the OCI runtime spec:pid,network,mount,uts,ipc. The kernel shortens two of them:/proc/<pid>/nsandlsnssaynetandmnt, as you will see below. The command for this stage isrunc spec, which writes a sampleconfig.jsonso you can read the real shape. - runc does the kernel work and leaves. It creates the namespaces and the cgroup, pivots into the rootfs, drops capabilities, and execs nginx as PID 1 inside the container. Then runc exits and a shim keeps the container attached to containerd.
Every stage has a copy button next to its real command. The last stage ends on four labels: namespaces, cgroups, networking, mounts. Those are the container.
Namespaces: what the process can see
You can look at your own namespaces right now on any Linux box. Every process has a set, and each one is a link in /proc:
$ ls -l /proc/self/ns # trimmed: link targets only, 6 of 10 entries
cgroup -> cgroup:[4026531835]
ipc -> ipc:[4026531839]
mnt -> mnt:[4026531841]
net -> net:[4026531840]
pid -> pid:[4026531836]
uts -> uts:[4026531838]
Two processes share a namespace when the numbers match; a container's process shows different ones. The unshare tool from util-linux makes new ones by hand. On Debian 12, as a normal user:
$ unshare --user --map-root-user --pid --fork --mount-proc ps -o pid,user,cmd
PID USER CMD
1 root ps -o pid,user,cmd
ps believes it is PID 1 and that it runs as root. Both are namespace tricks: a new PID namespace starts counting at 1, and the user namespace maps your normal UID to root inside it only. The UTS namespace does the same for the hostname:
$ unshare --user --map-root-user --uts sh -c 'hostname demo-box; hostname'
demo-box
$ hostname
raspberrypi
If your distro restricts unprivileged user namespaces, run them with sudo and drop --user --map-root-user. The namespaces(7) man page lists all eight types.
cgroups: what the process can use
Namespaces hide things. They do not limit anything. A process in its own PID namespace can still create processes until the host runs out. The classic proof is the fork bomb: a tiny shell function named : that calls itself twice, one copy piped into another in the background. Every copy makes two more, the process table fills, and the machine stops responding.
Do not run it. Use the Fork Bomb Simulator instead, which draws the process tree without forking anything real. Hover the code explainer to see what each of its ten pieces does, then press Start. The process count climbs until it hits the PID limit (a slider from 64 to 1024, 512 by default), the log prints FATAL: fork() failed - Resource temporarily unavailable, and the screen reads SYSTEM CRASHED.
Now pick one of the three Protection cards and start again: ulimit -u (a per-user process cap), cgroups (pids.max), or systemd (TasksMax in a unit file). In the simulation, each one caps the run at 100 processes, and it ends with SYSTEM PROTECTED instead. That only works while the PID limit slider is above 100: set it to 64 and the system runs out first, protection or not. Same bomb, same growth, stopped at a line you drew in advance. On a real system they count different things. ulimit -u counts every process and thread of your user ID across the whole machine, and the kernel does not enforce it for root. When I ran ulimit -u 50 in a shell on the Pi, the very first fork() failed, because my user already owned 191 tasks elsewhere. pids.max and TasksMax count tasks inside one cgroup, whoever owns them. (The cgroups card shows the cgroup v2 form: echo 100 > /sys/fs/cgroup/user.slice/user-1000.slice/pids.max.)
Try the limit for real
On a systemd machine with cgroup v2, where the user manager has the pids controller delegated to it, you can put a command in a throwaway cgroup with a task limit, no root needed. To check, cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers must list pids (on the Pi it prints cpu pids). This is not a fork bomb. It is a loop that stops after 50 children, run inside a scope capped at 10 tasks:
$ systemd-run --user --scope -p TasksMax=10 python3 -c '
import os, time
n = 0
try:
for _ in range(50):
if os.fork() == 0:
time.sleep(2)
os._exit(0)
n += 1
except OSError as e:
print("started", n, "children, then:", e)
'
Running scope as unit: run-r01155c5e085f497f94c998714d8323f5.scope
started 9 children, then: [Errno 11] Resource temporarily unavailable
Nine children plus the Python parent is ten tasks, the value systemd wrote to the scope's pids.max. The tenth fork() failed with EAGAIN, which is what the kernel's cgroup v2 docs promise. In the simulator, the same refusal shows as cgroup: fork rejected, pids.max (100) reached on the cgroups card. Nothing else on the machine noticed. A version of the script that also reads its own cgroup files printed pids.events: max 1 at the end: that file counts refused forks, so it is the one to alert on.
Docker exposes the same controller per container: docker run --pids-limit 100 nginx sets that ceiling on the container's cgroup (docs). Without that flag, a runaway container is bounded only by the limits the host already has. On the Pi, systemd's DefaultTasksMax is 3914 (15% of kernel.threads-max), but whether a container's unit inherits a default like that depends on the runtime. Check instead of guessing: on a cgroup v2 host, docker run --rm alpine cat /sys/fs/cgroup/pids.max prints the container's limit, and max means none.
The whole trick
Side by side, the two simulators put how Docker works under the hood in one sentence: runc asks the kernel for new namespaces so the process sees its own small world, and puts it in a cgroup where the limits you set cap what it can use. A cgroup with no limits caps nothing, and a PID cap does not cap CPU or memory. A limit also needs its controller: the Pi boots with cgroup_disable=memory, and a scope with MemoryMax=50M let a process allocate 100 MiB without an error. dockerd, containerd and the registry are how the recipe gets built and delivered.
For the longer tour, with commands to run at each layer of a real Docker host, read How Docker Really Works, From docker run to the Kernel. For the controllers themselves, see cgroups(7).
What surprised you most the first time you looked under the hood of a container? Tell us in the comments.

Top comments (2)
The namespace demo is the part I wish more Compose posts started from. One VPS gotcha: if you
docker run --network hostto simplify debugging, you leave the network namespace, so port binds stop matching what production Compose does on a bridge. I keepls -l /proc/$$/ns/netinside the container and on the host; different inode means you are still isolated. Same idea as your cgroup fork-bomb stop: prove the boundary with a file you canstat, not with a green healthcheck. Do you show the ns inode in the article, or only the unshare commands?"A normal Linux process that the kernel agrees to lie to" is the best one-line description of a container I've read, and ps aux showing nginx with an ordinary host PID is the proof that lands.
It's also exactly where the microVM model diverges, which makes it a useful contrast for readers. A Cube at Krova Cloud is a Firecracker microVM , it does boot its own kernel, so there's no host PID to find and no shared syscall surface. You pay a real boot and some memory overhead for that.
Worth adding to the piece: the tradeoff isn't "containers are insecure." It's that namespaces isolate the view and a hypervisor isolates the kernel, and most workloads only need the first.