📒 problems 📖 glossary
Mock interview
46:30
ready
of 46:30
Lesson 91 · Engineering craft

Containers — kill "works on my machine"

The oldest excuse in software is the sentence "but it runs on my machine." An engineer writes code, it works perfectly on their laptop, they hand it off, and it immediately breaks on a teammate's computer or on the production server. Nobody changed the code — so what went wrong? The answer is almost always the environment: everything around the code that the code quietly depends on. This lesson explains the problem precisely, then shows how a container ends the argument by packaging your application together with its dependencies and runtime into one portable bundle that behaves identically everywhere — on your laptop, in CI/CD, and in production.

The "works on my machine" problem, in detail

A program is never just the source file you wrote. To actually run, it leans on a stack of things that live on the machine around it. When even one of those differs between your laptop and the server, the program can behave differently or refuse to start. The usual culprits are:

  • Operating system. You build on macOS or Windows; the server runs Linux. File paths, line endings, and available system tools differ. Code that shells out to a command present on your Mac may find nothing on the server.
  • Runtime version. Your laptop has Node 20; the server has Node 16. A language feature or API you used does not exist on the older runtime, so the program crashes the moment it hits that line.
  • System libraries. Many programs link against shared C libraries installed on the OS (for image processing, encryption, database drivers). If the server is missing one, or has a different version, you get a cryptic "library not found" error at startup.
  • Environment variables. An environment variable is a named value the OS hands to a program when it starts (for example DATABASE_URL or PORT). If your laptop has it set and the server does not, your code reads an empty value and fails — often far from the real cause.
  • Dependency versions. Your project pulls in libraries; if the server resolves slightly different versions, behavior drifts in ways that are maddening to track down.

The root cause is the same every time: the program assumes a specific environment, and the environment it actually lands in is not identical. A container solves this by refusing to assume. Instead of shipping only your code and hoping the destination already has the right OS bits, runtime, libraries, and settings, a container packages all of it together into one unit. Wherever that unit runs, it carries its own copy of the dependencies and runtime, so there is nothing left for the host to get wrong. "Works on my machine" becomes "works the same on every machine," because every machine is now running the exact same packaged environment.

Image vs container: blueprint vs running instance

Image vs container. An image is the frozen, read-only blueprint — your code plus its dependencies, runtime, and settings, built once and never changed afterward. A container is a running instance of that image: the image brought to life as a live process. The relationship is exactly like a class and an object in programming — the class is the definition you write once, and you create many objects from it. One image, many identical containers. You build the image one time, then run as many copies as you need, and every copy starts from the same blueprint, so every copy behaves the same.

The full lifecycle, in order, is: build the image from a recipe, push it to a registry, pull it down wherever you want to run it, and run a container from it. A registry is a server that stores images and hands them out — a kind of library or app store for images. Public examples include Docker Hub; every cloud provider also runs its own. The point of the registry is that the artifact built once (in CI) is the same byte-for-byte artifact that production later pulls and runs. Nothing gets rebuilt in between, so nothing can drift. This is the link to CI/CD: your CI pipeline builds the image and pushes it to the registry, and deployment is just "pull this exact image and run it."

The Dockerfile: the recipe for an image

You describe how to build an image in a Dockerfile — a plain text file of instructions read top to bottom, like a recipe. Each line is an instruction (the capitalized word) followed by its arguments. Here is a complete, typical example:

Dockerfile
FROM node:20-slim          # start from a base image (runtime + OS bits)
WORKDIR /app

COPY package*.json ./       # copy deps manifest FIRST...
RUN npm ci                  # ...so this layer caches until deps change

COPY . .                    # then copy the rest of the source
CMD ["node", "server.js"]   # the command that runs when started

Read line by line, here is what each instruction means:

  • FROM node:20-slimFROM picks the starting point: a pre-built base image that already contains a minimal Linux plus Node 20. You almost never start from nothing; you stand on a base image that supplies the OS bits and runtime so you don't assemble them yourself. The -slim tag means a trimmed-down variant with fewer extras, for a smaller result.
  • WORKDIR /appWORKDIR sets the working directory inside the image. Every instruction after it runs relative to /app, and the container starts there. It is like running cd /app once for everything that follows.
  • COPY package*.json ./COPY copies files from your project into the image. Here it copies only the dependency manifests (package.json and package-lock.json) into the working directory. The reason it copies only those, and copies them first, is the caching trick explained in the next section.
  • RUN npm ciRUN executes a command while the image is being built and bakes the result into the image. npm ci installs the exact dependencies listed in the lockfile. After this line, the image contains a fully installed dependency tree.
  • COPY . . — now copy the rest of the source code (everything in your project directory) into the working directory. This comes after the install on purpose.
  • CMD ["node", "server.js"]CMD declares the default command to run when a container starts from this image. Unlike RUN (which runs at build time), CMD runs at launch time. This is the process that is your running app.

Layers and caching — the heart of fast builds

Every instruction in the Dockerfile creates a layer. A layer is the filesystem change that instruction produces, stacked on top of the layers before it — the final image is just these layers piled up. Crucially, layers are cached. When you rebuild, Docker walks the instructions top to bottom and, for each one, asks: "Have I built this exact instruction with these exact inputs before?" If yes, it reuses the cached layer instantly instead of doing the work again.

There is one rule that governs everything: when a layer is invalidated, every layer below it must rebuild too. Layers are stacked, so if a lower layer changes, nothing built on top of it can be trusted anymore. This is exactly why the order of instructions matters so much. You want the things that rarely change near the top (so their expensive layers stay cached) and the things that change constantly near the bottom (so only cheap layers rebuild). Your dependencies rarely change; your source code changes on every commit. So you copy the dependency manifest and install dependencies before copying the source:

Good order

COPY package*.json ./
RUN npm ci
COPY . .

Edit server.js and only the final COPY . . layer is invalid. The expensive npm ci layer above it is untouched, so its cache is reused. Rebuild takes seconds.

Bad order

COPY . .
RUN npm ci

Now COPY . . sits above the install. Any source edit invalidates that copy layer, which forces the npm ci layer below it to rebuild too — reinstalling every dependency from scratch on every code change. Rebuild takes minutes.

Concretely: a one-character change to a comment in your source costs you nothing in the good order and a full dependency reinstall in the bad order. Same code, same image, wildly different build time — purely because of instruction ordering and how the layer cache propagates downward.

Containers vs virtual machines

A common question is how containers differ from virtual machines (VMs), since both "isolate" software. A VM emulates an entire computer: it runs a complete guest operating system — its own kernel, its own system services, its own everything — on top of your real machine. That full OS is gigabytes in size and takes seconds to minutes to boot, exactly like a physical computer powering on.

A container does far less. The kernel is the core of an operating system — the part that actually manages memory, processes, and hardware. Containers share the host's kernel instead of shipping their own. A container isolates only the application and its files (the dependencies, libraries, and runtime you packaged), and lets the host kernel do the low-level work underneath. Because there is no guest OS to carry or boot, an image is typically megabytes rather than gigabytes, and a container starts in milliseconds rather than seconds.

Why the difference matters. Lightness and speed enable two things directly. First, density: because each container is small, you can pack dozens of them onto a single host that could only hold a few heavyweight VMs. Second, fast autoscaling: because a container starts in milliseconds, the system can spin up extra copies the instant traffic spikes and discard them when it drops — responding to load in near real time instead of waiting on slow VM boots.

Orchestration: running hundreds of containers

Running one container by hand is easy: pull the image, start it, done. Running a hundred containers across many machines is a different problem entirely, and doing it by hand is hopeless. You need an orchestrator — a system that manages the fleet for you. An orchestrator has four core jobs, and it helps to see each as a concrete question it answers automatically:

  • Scheduling — which machine should each container run on? Example: you have 10 servers and need to start 30 containers; the orchestrator decides which servers have spare CPU and memory and places each container accordingly, instead of you picking by hand.
  • Self-healing — what happens when a container or a whole machine crashes? Example: a container dies at 3am; the orchestrator notices it is gone and immediately starts a replacement somewhere healthy, with no human paged.
  • Scaling — how do you run more copies under load? Example: traffic triples on Black Friday; the orchestrator launches more copies to absorb it, then removes them when the rush ends, keeping capacity matched to demand.
  • Rollouts — how do you ship a new version without downtime? Example: you release v2; the orchestrator starts v2 containers, waits until they are healthy, shifts traffic over, and only then retires the v1 containers — so users never hit a dead service.

Kubernetes (often written k8s) is the dominant orchestrator. You don't need its internals yet — just its core vocabulary, each defined in plain English and related to the others:

Pod

The smallest unit you deploy — one (or a few tightly-coupled) running containers treated as a single thing. Pods are disposable: the orchestrator kills and recreates them freely, and each new one gets a fresh IP address.

Replica

One of N identical copies of a pod. "Run 5 replicas" means keep 5 identical copies alive at all times — for capacity (more copies handle more traffic) and redundancy (if one dies, four still serve).

Deployment

Your declared desired state: "I want 5 replicas of this image." You state the goal, not the steps. The Deployment creates the pods, replaces them when you ship a new version, and recreates any that die — this is where self-healing and rollouts live.

Service

A stable address in front of the churning pods. Because pods come and go and change IPs, other code can't talk to a pod directly. It talks to the Service — one fixed name — which load-balances requests across whichever replicas are currently alive.

Here is how those pieces tie together as a text diagram:

# Deployment says: "keep 3 replicas of my-app:v2 running"
Deployment (desired: 3)
     │  creates & maintains
     ▼
  Pod  Pod  Pod        # 3 replicas, each its own container + IP
   ▲    ▲    ▲          # IPs change as pods are recreated
   └────┼────┘
        │  routes & load-balances
Service (stable address)   # one fixed front door
        ▲
        │
   other code / users      # always talk to the Service, never a pod IP
The whole mental model in one breath: a Dockerfile builds an image; a registry stores it; CI/CD is what builds and pushes that image; a Deployment runs N replica pods of it; and a Service gives those pods one stable front door. You declare the state you want, and the orchestrator's only job is to keep reality matching it. The cluster of machines this all runs on usually lives in a managed cloud — see Cloud fundamentals for where clusters actually run.
Go deeper (optional): do the official "Docker get started" tutorial end to end, then the interactive "Kubernetes Basics" walkthrough on kubernetes.io. Build and run one image locally before you touch any cluster — feeling the layer cache hit on a rebuild teaches the ordering lesson better than any explanation.