How Docker works under the hood
Type this and hit enter:
docker run -p 8080:80 nginx
A few hundred milliseconds later there’s a web server answering on port 8080. It feels like a tiny virtual machine booted up. It didn’t. Nothing booted. No guest kernel, no BIOS, no hypervisor. What actually happened is that a normal Linux process started — the same way every process on the box starts — and the kernel was handed a list of instructions about what that process is allowed to see and use. That’s the whole trick, and everything below is just the machinery that arranges it.
Here’s the one sentence to keep in your head the entire way down:
A container is a process. Isolation isn’t a box the process lives in — it’s a set of restrictions the kernel applies to a process that’s already running on the host.
Get that and the rest is plumbing. Let’s follow the command down through every hop.
The one command, five hops down
The docker you type is a thin client. It doesn’t create containers, doesn’t touch namespaces, doesn’t pull images. It serialises your command into an HTTP request and sends it to a daemon. From there the work is handed down a chain of increasingly specialised programs, each doing one job and delegating the rest — until, at the bottom, a single execve turns a pile of files into a running process.
docker run. Each program does one job and delegates the rest: the CLI only talks HTTP; dockerd owns images and networks; containerd unpacks layers and supervises lifecycle; a per-container shim plus runc does the actual kernel setup and then gets out of the way. What's left is a process the host kernel fences in with namespaces and cgroups.1 — docker (the CLI) just makes a phone call
The CLI’s entire job is to translate docker run … into an HTTP request against the Docker Engine API and print back what it hears. By default it dials the daemon over a Unix domain socket at /var/run/docker.sock (it can also speak TCP to a remote host — that’s all DOCKER_HOST changes). Nothing about isolation happens here. You could curl --unix-socket /var/run/docker.sock http:/v1.45/containers/json and get the same answers; the CLI is a convenience over a REST API.
2 — dockerd (the daemon) owns the “big” objects
dockerd is the long-running background service, and it’s where all the Docker-flavoured concepts live: images, the local image cache, networks, volumes, builds, and registry authentication. When your nginx image isn’t already on disk, it’s dockerd that reaches out to the registry (Docker Hub by default, or ECR/GHCR/etc.), authenticates, and pulls the layers. But dockerd does not create the container itself. It resolves everything the container needs and then hands the actual create-and-run job down to containerd over a gRPC API. This split is deliberate: you can restart dockerd and your running containers survive, because the thing supervising them lives one level lower.
3 — containerd (the supervisor) turns an image into a bundle
containerd is a full container runtime in its own right — it’s what Kubernetes talks to directly, no Docker involved. Its job here is to take the pulled image and get it ready to run: it unpacks the layers, uses a snapshotter to stack them into a root filesystem (overlayfs, usually), and assembles an OCI bundle — a directory containing a config.json (the spec: which binary to run, env vars, mounts, the namespaces and cgroup limits to apply) and a rootfs/ (that stacked filesystem). Then containerd manages the container’s whole life: start, stop, pause, delete, and reporting status back up. For each container it starts, it spawns a small shim process and asks the shim to invoke the low-level runtime.
4 — runc (the runtime) does the one privileged thing
runc is the reference implementation of the OCI runtime spec — a small C-and-Go program whose only job is: given an OCI bundle, create the container process exactly as config.json describes. This is the moment all the isolation is actually built:
- namespaces —
runcusesclone()/unshareso the new process gets its own PID, network, mount, UTS (hostname), IPC, and (optionally) user namespaces. This is what the process can see. - cgroups — it places the process into a control group that caps CPU, memory, and I/O. This is what the process can use.
- capabilities, seccomp, AppArmor/SELinux — it drops most of root’s powers, filters the allowed syscalls, and applies the security profile. This is what the process is allowed to do.
pivot_root— it swaps the process’s root directory to the container’srootfs/, so/inside the container is the image, not the host.
Then it execves your program (nginx) as PID 1 of that namespace — and runc exits. It’s a setup tool, not a supervisor. The containerd-shim stays behind as the container’s parent: it keeps stdio and the exit code, and — crucially — it means containerd (and dockerd above it) can be restarted or upgraded without killing your running containers.
5 — what’s left is just… a process
There is no “container object” running anywhere. Run ps aux on the host and you’ll see the nginx process sitting right there next to everything else, same as any daemon. docker exec doesn’t “enter a box” — it starts a new process and joins it to the same namespaces (via setns). The isolation is entirely a property the kernel enforces per-process; take the restrictions away and it’s an ordinary program.
The other half of the trick: the filesystem
Isolation explains why the process can’t see the host. It doesn’t explain why the container has nginx and a Debian userland inside it while the host might be running something else entirely. That’s the image, and it’s built from stacked, read-only layers plus one thin writable layer on top — a union filesystem, almost always overlayfs.
Two consequences fall straight out of this. Startup is cheap because nothing is copied — starting a container is a mount plus a process, not a boot. And containers are ephemeral by default: everything written at runtime lives in that top writable layer, which is deleted with the container. Data you want to keep goes in a volume — a host directory (or managed mount) bind-mounted into the container, deliberately outside the layer stack so it survives docker rm.
So why isn’t it a VM?
Because there’s no second kernel. A virtual machine virtualises hardware: the hypervisor gives each guest fake CPUs, fake memory, fake devices, and the guest boots its own full kernel on top. That’s strong isolation, but it costs a boot, a gigabyte of guest OS, and a constant virtualization tax. A container virtualises the operating system’s view instead: every container’s process calls the one host kernel directly, and the kernel just answers each call according to that process’s namespaces and cgroups.
That’s the trade in one line. A VM is a computer running on your computer; a container is a process pretending it’s the only one on yours. The VM gives you a real kernel boundary — run untrusted or multi-tenant workloads, mix operating systems. The container gives you density and speed — hundreds to a host, up in milliseconds — at the price of a shared kernel (which is also why “runs on Linux” is load-bearing: Docker on macOS or Windows quietly runs a Linux VM, and the containers live inside that).
The port, briefly
The -p 8080:80 in the command is a small world of its own. The container gets its own network namespace with a private IP, connected to a host bridge (docker0) by a veth pair — a virtual cable with one end in the container and one on the host. Publishing a port installs a NAT rule (via iptables, plus a small userland docker-proxy helper) that forwards host :8080 to the container’s :80. From inside, the process just thinks it’s listening on port 80 on its own interface; the kernel’s networking stack does the rest.
TL;DR:
docker rundoesn’t boot anything — it starts a Linux process and tells the kernel to fence it in. ThedockerCLI is a thin client that sends an HTTP request to dockerd, which owns images/networks/volumes and pulls from a registry if needed, then delegates to containerd, which unpacks the image into an OCI bundle and supervises lifecycle, then calls runc, which creates the process with namespaces (what it sees), cgroups (what it uses), and capabilities/seccomp (what it may do) — and then exits, leaving a shim as the parent so the daemons can restart without killing containers. The filesystem is stacked read-only image layers + one thin writable copy-on-write layer (overlayfs), so images are shared and containers are ephemeral. No guest kernel, no hypervisor: every syscall hits the one shared host kernel — which is exactly why containers are dense and fast, and why the isolation is only as strong as that kernel boundary.
Sources
- Docker architecture — Docker docs overview
- Docker Engine API reference
- containerd — what it is and where it sits
- runc — the OCI reference runtime (GitHub)
- OCI Runtime Specification
- About storage drivers / union filesystems (overlayfs) — Docker docs
- Linux namespaces — man7 namespaces(7)
- Control groups (cgroups) — man7 cgroups(7)