Episode 01 · 11 min · 5 August 2026

It works on my machine: the half of your application you never shipped

A deploy fails on a missing module. The same commit ran fine on your laptop ten minutes earlier. The code is not what differs between those two machines, and the error message points at the symptom rather than the cause.

Chapters

A deploy fails, and the log has one line in it:

ModuleNotFoundError: No module named 'psycopg2'

The error names a missing package, so the obvious reading is that someone forgot to install a dependency. That reading is what makes this happen again next month, because the missing package is a symptom. Your code is identical on both machines. Something else is not.

This explains what your application is actually made of, why two machines running the same commit behave differently, and what a container freezes that a requirements.txt cannot.

The short version

  • Your application is five layers deep. You wrote the top one and deliberately chose none of the other four.
  • Those four arrived over months, one command at a time, from you, from other people, and from the operating system updating itself. Nothing in your repository describes them.
  • A pinned requirements.txt specifies exactly one of the five.
  • A Dockerfile declares all five, which turns the environment from the accumulated state of one machine into a file in version control.
  • A container is not a virtual machine. It shares the host kernel and packages only the layers above it, which is why it is measured in megabytes and starts in milliseconds.

What your application looks like from the outside

A REST API is a request and response machine. A client sends a request over the network, your server answers it, and the network only ever carries text.

A four part diagram: client, request, server, response
Client, request, server, response. The request carries a verb, a path that names the resource, and headers. The answer comes back as a status code and a JSON body.

Inside the server box, three things happen in order.

A server box opened into three stacked layers, router, handler and data layer, with a database cylinder connected below
The router matches the path to a function, the handler runs your logic, the data layer queries the database. Then the whole thing unwinds and the result travels back out.

Hold on to this picture. It is the part everyone means when they say “my application”, and it is also the part that is not the problem.

And what it actually sits on

That server box is not just your code. Your code is a thin layer resting on a large pile of things you did not write and probably cannot name.

Five stacked layers: your code, libraries, python runtime, system libraries, operating system
Your 400 lines sit on libraries. Those sit on a specific Python runtime. That runtime is compiled against system libraries. All of it sits on a particular operating system, with a particular kernel.

When you say “my application” you mean the top layer. When the computer runs your application, it means all five.

The uncomfortable part is not that the other four exist. It is how they got there. You chose the top layer deliberately: you reviewed it, tested it, and put it in version control. The other four arrived over months, one command at a time, from you, from colleagues, and from the operating system updating itself in the background.

No file in your repository describes them. No review approved them. They are the accumulated sediment of every terminal session that machine has ever had, and your application depends on all of it.

What actually differs between two machines

Take the same commit and put it on a laptop and on a production server. The top layer is identical, byte for byte. Then look underneath.

Two five layer stacks side by side, the production one showing psycopg2 not installed, Python 3.9.18, libssl1.1 and Ubuntu 20.04 highlighted in amber
The database driver was installed on the laptop eight months ago and forgotten. The server never got it. The laptop runs Python 3.12; the server was provisioned in 2020 and runs 3.9. One machine is a Mac, the other is Linux.

That is the whole failure. The deploy blames a missing module because a missing module is the first thing to break, but the bug is that your application’s environment was never something you built. It was something that happened to you.

Why pinning your dependencies does not fix it

The usual objection at this point is that a requirements.txt already solves this, because every package is pinned to an exact version.

It does not, and the reason is worth being precise about.

A requirements.txt file next to the five layer stack, with red crosses against interpreter, SSL library and operating system
The file describes exactly one of the five layers. It says which Python packages you want. It says nothing about which interpreter runs them, which SSL library they compile against, or which operating system any of it sits on.

Pinning your libraries while leaving the three layers beneath them undefined is like sending someone a precise recipe and letting them guess the oven. Same instructions, different kitchen, different result.

You shipped your code. You never shipped your environment.

What a Dockerfile actually declares

The answer is almost aggressively simple. If the environment is the problem, stop treating the environment as an accident. Write it down, build it, and ship it as one unit with the code inside it.

A Dockerfile beside the five layer stack, with every layer now lit in cyan
Line one pins the operating system and the Python version. Line four installs dependencies from a file that is in version control, so the list is explicit and reviewable. Line five copies your code in last, because it changes most often.

Read it top to bottom and every layer that was invisible is now a line of text in your repository. The environment stopped being tribal knowledge and became source code.

Images and containers

Building that file produces an image, a frozen and versioned snapshot of the entire stack. Running the image produces a container.

An image cube connected to three identical container cubes
Any container started from that image has the same runtime, the same libraries and the same system packages, in the same versions, every time.

Run the two machine experiment again and neither machine needs to agree with the other about anything, because neither one is providing the environment any more. The container brings its own. “It works on my machine” stops being a defence, because your machine is no longer part of the equation.

A container is not a virtual machine

This is the point that trips up most people meeting containers for the first time, so it is worth stating exactly.

A virtual machine carries an entire second operating system, kernel included, and boots it. A container shares the kernel of the machine it runs on and packages only the layers above it.

A virtual machine stack with a guest OS, guest kernel and hypervisor, next to a container stack sharing the host kernel
That is why an image is measured in megabytes rather than gigabytes, and starts in milliseconds rather than half a minute. You get the isolation you wanted without paying for a whole second computer.

The layer I did not know I had

One story, because it added a sixth layer to that stack that I did not know was there.

I was building a payments integration. Stripe ships a command line tool for testing webhooks locally, which is ordinary software from a company everyone has heard of. I ran it, and Windows Defender told me it had quarantined stripe.exe.

It was a false positive, and the reason developer tools get flagged so often is not random. The Stripe CLI is a Go binary: a single self-contained executable that unpacks and runs its own code at startup. To a machine learning heuristic that looks almost exactly like malware unpacking itself. Add that the file is new and almost nobody on the antivirus vendor’s network has run it before, and you have a tool that is suspicious purely for being a tool.

I added an exclusion for the file. Still blocked. I excluded the whole folder. Still blocked. I spent longer than I want to admit fighting my own machine to run a tool that was doing nothing wrong.

What worked was running it in a container, and the reason is not that Docker overrules your antivirus. On Windows, Docker runs containers inside a lightweight Linux virtual machine, so the thing I was running was no longer a Windows executable at all. It was a Linux process in a Linux environment, and the Windows security stack was simply not a layer it had to pass through.

The five layer stack with a sixth amber layer added underneath: host security policy, Defender, SmartScreen, ASR
A sixth layer that had never been in the diagram: a policy layer my application depended on, that I never chose, that was written down nowhere, and that I could not fully control even as administrator of my own machine.

The replacement command was three times longer than the one I was used to typing. That is not the container being awkward, it is the container being honest. Every one of those flags was something the short command had been quietly inheriting from my machine.

Same lesson as the missing driver, one level deeper. The container did not fight that layer. It stopped needing it.

What actually changed

Go back to the first diagram. The client still sends a GET request. The router still matches the path. The handler still runs. The database still returns the row. The JSON still comes back with a 200. Not one line of business logic moved.

All a container did was draw a hard border around the server and make that border reproducible.

That is the whole idea, and it is worth being clear about what it does not do. It does not make your API faster, and it does not make your code better. It makes your application portable, which means the machine it lands on stops being a variable.

Next: what happens to that same request path when one server stops being enough, and you put three of them behind a load balancer.