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_URLorPORT). 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
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:
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-slim— FROM 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-slimtag means a trimmed-down variant with fewer extras, for a smaller result.WORKDIR /app— WORKDIR sets the working directory inside the image. Every instruction after it runs relative to/app, and the container starts there. It is like runningcd /apponce for everything that follows.COPY package*.json ./— COPY copies files from your project into the image. Here it copies only the dependency manifests (package.jsonandpackage-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 ci— RUN executes a command while the image is being built and bakes the result into the image.npm ciinstalls 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. UnlikeRUN(which runs at build time),CMDruns 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:
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.
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.
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:
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.
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).
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.
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