Module 5 · System architecture

5.5 Containers and Docker: solving "it works on my machine"

Lesson 5.5 · 10 min read

There is a sentence that has been said, with rising frustration, in every software team that has ever existed. Something works perfectly on the machine of the person who built it, and fails on the server, or on a colleague's laptop, and nobody can immediately say why.

You met a version of this in the deployment post, when the build failed on a filename's capitalization that your own machine happily ignored. That was one instance of a much larger problem, and this post is about the problem and its solution.

You will probably not write the configuration files this post describes. AI does that well. But you will absolutely encounter containers, because essentially all serious software now runs inside them, and when something goes wrong at that layer you need to know what layer you're looking at. More importantly, understanding containers explains how the modern deployment and scaling you've been learning about is even possible. The stateless, disposable, interchangeable servers from the scalability post exist because of this.


Why the same code behaves differently in different places

Your program does not run alone. It runs on top of a large, invisible pile of things it depends on and did not bring with it.

It needs a specific version of a language runtime. It needs the packages it imports, each at a particular version, and each of those has its own dependencies. It needs system libraries, tools that happen to be installed, environment variables, files in expected places, a particular operating system behaving in a particular way.

Your machine has accumulated all of this over years, through a thousand installations you don't remember. The server has accumulated a different pile. They differ in ways nobody has written down and nobody can fully enumerate, and your program runs correctly in one pile and not the other.

This is the actual disease. The code is not the whole program. The program is the code plus everything underneath it, and only the code was ever put in the repository.

So the cure has to be: ship the environment along with the code. Not instructions describing the environment, which people follow incorrectly and which drift out of date. The environment itself, packaged, complete, identical everywhere.


What a container is

A container is exactly that package: your code, plus the runtime, plus the libraries, plus the system dependencies, plus the configuration, sealed together into a single unit that runs the same way anywhere.

The unit is created from a recipe, a plain text file that says: start from this base (a minimal operating system with a language runtime), copy in these files, install these dependencies, run this command to start. That recipe is versioned in your repository alongside your code, which means the environment is now part of the thing you review, change, and roll back.

Build that recipe and you get an image, a frozen, complete filesystem, immutable, identified by a hash. Run an image and you get a container, a live process running from that image, isolated from everything else on the machine.

The distinction is worth holding, because it mirrors something you already know. An image is to a container as a class is to an object, or, more usefully for you, as a component definition is to an instance of that component. One image, many identical containers running from it. Change the image, and you must create new containers; you don't edit a running one.

The shipping container is where the name comes from, and unusually for a tech metaphor it's precise rather than decorative. Before standardized shipping containers, cargo was loaded piece by piece, and every ship, port, crane, and truck had to handle every kind of goods differently. The container standardized the interface, not the contents. Now a crane doesn't need to know whether it's lifting bananas or televisions. Software containers do the same: the platform running them doesn't need to know or care whether the container holds a Python service or a Node service. It knows how to run a container. What's inside is the container's business.

Docker is the tool that most people use to build and run containers, and its name has become shorthand for the whole concept. It isn't the only one, and the underlying standard is what matters. But when someone hands you a Dockerfile, they are handing you the recipe described above.


What containers are not, and why the difference matters

People conflate containers with virtual machines, and understanding the difference explains why containers changed everything.

A virtual machine simulates an entire computer. It has its own complete operating system running inside your operating system. It's genuinely isolated, and it's heavy: gigabytes of size, a full operating system to boot, tens of seconds to start, a meaningful slice of the machine's resources burned on running a second operating system for no benefit.

A container does not simulate a computer. It uses the operating system that's already there, and asks it to run your process in a walled-off area, with its own view of the filesystem, its own network interface, its own visible processes. From inside, it looks like you have your own machine. Underneath, you're a normal process on the host, just fenced.

The consequences are what matter. A container is small, often tens of megabytes. It starts in milliseconds, because there's no operating system to boot, there's just a process to launch. And because it's cheap, you can run dozens on one machine, and you can throw them away and start new ones constantly, without ceremony.

That last property is the whole game. Containers made servers disposable. When starting a fresh, identical copy of your application takes a fraction of a second, an entire architecture becomes possible: kill a sick server and start another, add ten servers when traffic rises and remove them when it falls, replace every running instance one at a time to deploy a new version with no downtime.

Go back to the scalability post. Stateless, interchangeable, disposable servers behind a load balancer. That model is easy to describe and was historically hard to do, because "start another identical server" used to mean minutes and careful configuration. Containers made it instant and exact. The architecture you learned depends on the technology in this post, and now you can see the dependency.

Interactive: container vs vm

A hands-on visual for this idea is in the works. The text around it carries everything you need for now.


Orchestration, and why you'll hear the word Kubernetes

Once you have many containers across many machines, something must manage them. Which machine does each container run on? What happens when a container crashes, or a whole machine dies? How do containers find each other? How do you roll out a new version without dropping requests?

Answering those questions by hand is untenable past a small number of containers. Orchestration is the automation of it, and Kubernetes is the system most large organizations use.

You do not need to learn Kubernetes. You should understand what it does, in one paragraph, because you'll hear it constantly and the concept is simple even though the tool is not.

You tell an orchestrator the state you want: "run five copies of this image, make sure they're reachable at this address, keep them healthy." It then works continuously to make reality match that description. A container dies, it starts another. A machine fails, it moves that machine's containers elsewhere. You change the desired version, it replaces containers gradually, checking health as it goes, and stops if things look wrong. Traffic rises, and if configured to, it starts more.

This is called declarative management, and you've met the idea before, in the SQL post, where you describe the result you want rather than the steps to get there. Same principle. You describe the desired state; the system figures out how to reach and maintain it. That's the entire concept, and it's why the tools feel like they're doing something magical when they're really just repeatedly comparing what is to what should be, and closing the gap.


What containers ask of you

Containers impose a discipline, and it's the same discipline that made your servers scalable. This is not a coincidence; it's the same idea twice.

A container's filesystem is temporary. Write a file inside a container and it dies with the container. This is not a limitation to work around, it's a property to depend on. It's precisely why files belong in object storage and data belongs in a database, both of which live outside the container's lifetime. If a container can be destroyed at any moment, then anything you need to keep must live somewhere else. Statelessness, which sounded like an architectural preference in the scalability post, is enforced by the shape of the thing.

Configuration comes from outside. The same image runs in staging and production, and behaves differently because it's given different environment variables at the moment it starts. Same image, different configuration. This is exactly the deployment post's separation of code from configuration, now with a hard reason behind it: the image is immutable and identical everywhere, so anything that differs has to be injected at runtime.

One process, one job. A container should run your application, not also a database and a cron job and a log shipper. Each of those is its own container. This is the microservices instinct from post 0.4 appearing at the packaging layer: small, focused, independently replaceable units.

You'll notice something. Nearly every rule containers impose is a rule the rest of this series already told you to follow. Containers didn't invent these principles. They made them mandatory, by making the alternative impossible, which is often how good practice actually spreads.


What you actually need to know

Here is the honest floor. When you encounter containers in real work, and you will:

You should recognize a Dockerfile as a recipe for building an image, readable top to bottom: start from this base, copy these files, install these things, run this command.

You should understand that if a container works locally and fails in production, the container is identical, so the difference is almost certainly configuration injected from outside, or a dependency reachable from one place and not the other. The container itself has been eliminated as a variable, which is precisely what it was for. That's a genuinely powerful debugging position, and it's a gift.

You should know that changing a running container is meaningless, because it will be replaced. You change the image and roll out new containers. When someone says "I fixed it on the server," in a containerized world, they fixed nothing; the next deploy erases it.

You should recognize that a container image is a build artifact, like the built frontend bundle from the deployment post, produced from source, versioned, deployed, and rolled back as a unit.

And you should be able to look at an architecture diagram with a load balancer, five identical application containers, a database, a cache, and a queue with worker containers, and understand every box, why it's there, why the application containers are identical and disposable, why the database is not, and what happens when any one of them dies.

That last one is the whole module, and you're now most of the way through it.


Do this before the next post

Find a real Dockerfile, which takes thirty seconds. Almost any serious open source project has one at the root of its repository. Open it and read it as prose.

The first line names the base image, which tells you what language and version this project runs on. Then you'll see files copied in, dependencies installed, and finally a command that starts the application. That's the entire environment of that project, written down in a page. Consider that this file is the reason that project runs identically on a maintainer's laptop, on a contributor's machine, and on a production server, and that before files like this existed, that was not true and could not be made true.

Then, on something you've built, ask the question that reveals whether it could survive being containerized. If your server were destroyed and recreated from scratch right now, with an empty filesystem and only the environment variables you've configured, would it work?

If you store uploads on the server's disk, no. If you keep sessions in memory, no. If someone once ran a command on the server to fix something and never wrote it down, no.

Each "no" is a place where your application is quietly holding state it shouldn't, and each one is a wall between you and the disposable, scalable, recoverable architecture the last few posts have been describing. You don't need to fix them all today. You need to be able to see them, and now you can.