Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

Handbooks  /  Docker
Engineering~12 min readBeginner-friendly
Deep Dive

Docker: a container is a lie told by the kernel.

"Works on my machine" died because we learned to ship the machine. But the machine we ship isn't a virtual computer — it's an ordinary process wearing an elaborate disguise, and the disguise is maintained by the host kernel. Understand that one trick and images, layers, volumes and networking all fall into place.

01

Not a VM: namespaces + cgroups

A virtual machine virtualizes hardware and boots its own kernel — heavyweight by construction. A container virtualizes nothing. It's a normal Linux process (or process tree) that the kernel systematically deceives via two mechanisms:

The two kernel features that ARE containers
NamespacesLimit what the process can SEE: its own process table (it thinks it's PID 1), network stack, mount table, hostname, IPC and, optionally, user IDs. One lie per namespace type.
cgroupsLimit what it can USE: CPU shares, memory ceilings, IO bandwidth. The reason one greedy container can't starve the host.

That's why containers start in milliseconds (nothing boots — a process forks), share the host's memory efficiently, and can be packed by the hundred onto one box. It's also why Linux containers need a VM shim on Mac/Windows — the lies are Linux-kernel lies. Docker's real invention wasn't the container; the kernel had the pieces. It was the packaging — which brings us to images.

02

Images: a filesystem in layers

An image is the filesystem the lie will present — built as a stack of read-only layers, one per Dockerfile instruction, each recording only the changes it made. Layers are content-addressed (Git vibes, deliberately): ten images built on the same Ubuntu base share those bytes once, on disk and in the registry. When a container runs, a thin writable layer goes on top; the image below stays immutable, which is why a container is disposable by design — kill it and the writable layer is all that dies.

→ The rule that halves your build times

The build cache reuses layers top-down until the first instruction that changed — everything after rebuilds. So order Dockerfiles by volatility: dependencies first (change rarely), source code last (changes constantly). COPY package.json → RUN npm install → COPY . . exists precisely so editing your code doesn't reinstall your dependencies.

03

Dockerfiles that ship small

The classic sins: bloated images carrying compilers into production, root processes, latest-tag roulette. The fixes are few and mechanical:

PracticeWhy
Multi-stage buildsbuild in a fat stage, COPY --from=build only the artifact into a slim final stage — GBs → tens of MBs
Small bases-slim / distroless / alpine (mind musl quirks) — less surface, faster pulls
Pin versionsFROM node:22.4, never :latest — builds you can reproduce next year
Non-root USERcontainer escape from root is host trouble; from uid 1000 it's a shrug
.dockerignorekeep node_modules, .git and secrets out of the build context entirely
One process per containerthe unit of scaling, logging and restart — compose processes, don't bundle them

The one that saves the most time is layer order. The build cache reuses a layer only if it and everything above it are unchanged, so the first changed instruction busts the cache for itself and all layers below:

cached ⟺ this line and every line above it unchanged  →  first change rebuilds itself + everything after

Put COPY . . before RUN npm install and a one-line code edit re-runs the whole install (~60s); put it after and the same edit costs ~2s. The runnable version below builds both orders and prices the rebuild.

04

A production Dockerfile, line by line

Every line below earns its place: cache order, a slim final stage, a non-root user and a start command that receives signals.

# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build

FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json /app/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
USER node
EXPOSE 3000
ENTRYPOINT ["node"]
CMD ["dist/server.js"]

The manifests are copied and installed before the source, so a code edit reuses the install layer. The build stage keeps the compiler and dev dependencies and never ships; the final stage gets only the built output plus production dependencies. USER node drops root (the official Node image creates that user). EXPOSE only documents the port; publishing is still -p.

BuildKit cache mounts. RUN --mount=type=cache gives that one command a persistent directory that is never written into the image. The package manager's download cache survives between builds, so when a manifest changes and the install layer must rebuild, npm still reuses downloads from earlier builds. A type=secret mount does the same for credentials: the file exists during one RUN and appears in no layer or image history. BuildKit is the default builder in current Docker releases.

ENTRYPOINT vs CMD, and why the form matters

InstructionRoleOverridden by
ENTRYPOINTthe executable the container runsdocker run --entrypoint
CMDdefault arguments, or the whole command if there is no ENTRYPOINTanything after the image name in docker run

With the file above, docker run app runs node dist/server.js and docker run app dist/worker.js runs node dist/worker.js. Write both in exec form, a JSON array. Shell form (CMD npm start) wraps your command in /bin/sh -c, so the shell becomes the container's main process and your app is its child.

That matters because of PID 1. The first process in a container's PID namespace is PID 1, and the kernel does not apply default signal actions to it: a SIGTERM with no handler installed is ignored. docker stop sends SIGTERM, waits 10 seconds by default, then sends SIGKILL, so a program that never handles SIGTERM takes ten seconds to stop and exits with code 137. PID 1 must also reap orphaned child processes, or zombies pile up. Fix it by handling SIGTERM in your code, or run a tiny init that forwards signals and reaps children: docker run --init, or tini as the ENTRYPOINT.

05

What actually runs your container

docker run is the front of a chain. The CLI calls the Docker daemon, dockerd, which hands container lifecycle to containerd. containerd pulls and unpacks images and starts each container through a small shim process, which in turn invokes runc. runc creates the namespaces and cgroups, sets up the root filesystem, then executes your process and exits; the shim stays behind to hold the container's stdio and report its exit. Kubernetes skips the Docker daemon and talks to containerd (or CRI-O) directly.

The seams between those pieces are open standards from the Open Container Initiative: the image spec (layers plus a config), the runtime spec (a root filesystem plus a config.json that runc consumes) and the distribution spec (how registries serve images). That is why an image built with Docker runs under Podman or containerd unchanged.

Layers on disk. The common storage driver, overlay2, uses Linux overlayfs. The image layers are stacked read-only lower directories, the container's writable layer is the upper directory, and the container sees the merged view. The first time a container writes to a file that lives in a lower layer, the whole file is copied up into the writable layer; this copy-on-write makes a one-byte edit to a large file expensive. Deleting a lower-layer file writes a marker called a whiteout, which hides the file but does not shrink the image. For the same reason, RUN rm in a later layer never reclaims space; clean up in the same RUN that created the files. Write-heavy data belongs on a volume, which bypasses overlayfs.

Memory limits and OOM kills. docker run --memory 512m sets a cgroup memory limit. When the container's processes need more than the limit and nothing can be reclaimed, the kernel's OOM killer kills one of them with SIGKILL. The container exits with code 137 (128 + 9), and docker inspect shows State.OOMKilled: true, with no stack trace and no application log line, because the process never got a chance to write one. Runtimes with their own heaps need the limit told to them: set Node's --max-old-space-size below the container limit, and use a JVM version that reads cgroup limits (all current releases do). CPU limits behave differently: a container over its CPU quota is throttled, not killed.

06

State: volumes, and the amnesia feature

Containers forget by design — the writable layer dies with them. That amnesia is a feature (identical fresh state every run) until you're running a database. The escape hatches: volumes (Docker-managed directories mounted into the container — survive restarts, the production answer) and bind mounts (a host directory mapped straight in — the dev-loop answer, your editor writing into the running container). The discipline is knowing which data is cattle (rebuildable — leave it in the container) and which is sacred (the database files — volume, backed up).

07

Networking: who can talk to whom

Each container gets its own network namespace — its own localhost, which is the #1 beginner trap: localhost inside a container is the container, not your machine. Containers on the same user-defined bridge network reach each other by name (Docker runs the DNS: db:5432 just works); the outside world reaches a container only through explicitly published ports (-p 8080:80 — host:container). That's 90% of Docker networking: name-based reach within a network, port-mapping at the edge, and nothing visible unless you published it.

08

Compose, and the handoff to Kubernetes

Real apps are several containers — API, database, cache, worker. Compose is the one-machine orchestrator: a YAML file declaring services, networks and volumes, brought up with one command, networked by name automatically. It is the standard dev environment and a perfectly honest small-deployment tool.

services:
  api:
    build: .
    ports:
      - "8080:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10
volumes:
  pgdata:

One docker compose up builds the API image, starts Postgres with its data on a named volume, and puts both on a default network where the API reaches the database at db:5432. Only port 8080 is published to the host. depends_on alone orders container start, not readiness; the service_healthy condition makes the API wait until the health check passes. The inline password is for local development only; use an env file or secrets anywhere else.

What Compose doesn't do is fleet: many machines, self-healing, rolling deploys, autoscaling. That's Kubernetes — same containers, a much bigger brain around them. The pipeline in practice: Dockerfile → image → registry → Compose (dev) → Kubernetes (prod), with the image as the invariant artifact moving through it. Master the image and the rest is configuration.

RUN IT YOURSELF

Price a rebuild: layer order is everything

The Docker build cache reuses a layer only if it and every layer above it are unchanged — so the first changed instruction rebuilds itself and everything after it. This builds the same app two ways after a one-line code edit. In the bad order, COPY . . sits above RUN npm install, so the edit re-runs the whole 60-second install. In the good order, the install is cached and only the copy rebuilds — the same edit for ~2 seconds. Change which layers are marked changed, or their costs.

CPython · WebAssembly
Frequently asked

Quick answers

Container vs VM?

A VM boots its own kernel on virtual hardware. A container is host-kernel processes inside namespaces (what it sees) + cgroups (what it uses). Milliseconds vs minutes.

How do layers work?

One read-only layer per Dockerfile instruction, content-addressed and shared across images; a writable layer on top at runtime. Cache reuses layers until the first changed instruction.

What is a multi-stage build?

Build with the fat toolchain in stage one, copy only the artifact into a minimal final stage — small, clean production images.

Compose vs Kubernetes?

Compose orchestrates one machine (dev, small deploys). Kubernetes orchestrates fleets: self-healing, rolling deploys, autoscaling.

What is the difference between ENTRYPOINT and CMD?

ENTRYPOINT sets the executable a container runs; CMD sets its default arguments, or the whole default command when there is no ENTRYPOINT. Arguments after the image name in docker run replace CMD, and --entrypoint replaces ENTRYPOINT. Write both in the JSON exec form so your process receives signals directly.

What does exit code 137 mean in Docker?

137 is 128 plus 9: the process was killed with SIGKILL. The usual causes are the kernel's OOM killer, after the container exceeded its memory limit, or docker stop giving up when its grace period ran out. docker inspect shows OOMKilled: true in the first case, which tells the two apart.

▶  Watch it explained

What is a container? It's not a VM — it's a fenced-off process

Docker · Engineering · Vibe Engines · 2026
Finished this one? 0 / 208 Handbooks done
NextThe Kubernetes Handbook Next on the “Ship it, then keep it up” ladder · rung 2 of 4

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Handbooks