...
Back

It Builds on My Laptop and Nowhere Else: A Dockerfile Post-Mortem

We spent a day failing to containerize a monorepo. Every failure was the same failure wearing a different error message: the build context did not contain what we thought it contained.

It Builds on My Laptop and Nowhere Else: A Dockerfile Post-Mortem

It Builds on My Laptop and Nowhere Else 🐳

The commit log from that day is not flattering. Sixteen commits in a row, every one of them a variation on fix: make the Dockerfile find the thing it could not find. Reorder the COPY lines. Combine the COPY lines. Remove an entry from .dockerignore. Give up and COPY . ..

It is worth writing down, because the flailing had exactly one cause, and the cause is structural rather than clever.


A container build is not a build in a directory

When you run bun run build locally, your process can see the whole filesystem. It can walk up out of the package it is in. It can resolve a symlink into a sibling workspace. It can read a config file two levels above the one you told it about.

When you run docker build, none of that is true. The daemon gets a build context — a tarball assembled from your directory minus whatever .dockerignore excludes — and the build can only see what you explicitly copy out of that tarball, in the order you copy it.

Every one of our sixteen failures was the same bug: something the build needed was either not in the context at all, or not yet copied when it was needed. The error messages differed wildly. turbo.json not found. Module resolution failures. A workspace package resolving to nothing. They read like four separate problems. They were one problem.


The monorepo makes it worse, specifically

In a single-package repo the naive Dockerfile works, so nobody learns this lesson. In a monorepo it breaks immediately, because the thing that makes a monorepo useful — packages referring to each other through the workspace root — is exactly the thing the build context is happy to cut in half.

Three concrete traps we hit:

The root config is not optional. turbo.json, the root package.json, the lockfile, and the workspace globs live at the top. If your .dockerignore is aggressive — ours ignored a build-output pattern that also matched turbo.json's directory — the orchestrator disappears and the error blames something three steps downstream.

Copy order decides cache behavior and correctness. The standard move is to copy manifests first, install, then copy source, so a source edit doesn't re-run the install. That is right, but in a monorepo "manifests" means every workspace's package.json, at its correct nested path, plus the root ones. Miss one and the install silently produces a different dependency tree than your laptop has.

COPY . . makes it work and makes it slow. It is a legitimate debugging step — it proves the problem is the context and not your code. It is not a destination. We shipped it briefly, then walked it back, because it puts the entire repo into a layer that invalidates on every edit.


What we changed

The fix was to stop guessing what the context contained and start looking.

# What is actually being sent to the daemon?
docker build --no-cache --progress=plain -t probe . 2>&1 | head -40
 
# Is the file I think I copied actually in the image?
docker build -t probe . && docker run --rm probe ls -la /app
 
# Which .dockerignore rule is eating my file?
#   The fastest answer is to comment out the file entirely and rebuild.

That last one solved it in about two minutes after a day of guessing. The rule that was eating turbo.json was not written to eat turbo.json; it was written years earlier for something else and happened to match.


The runtime half of the same mistake

After the build works, the same class of error moves to the run stage, so the two are worth reading together.

Next.js standalone output exists precisely to solve this — it traces exactly which files the server needs and emits a minimal tree. But it emits it at a specific path, and the server inside it is a plain Node entrypoint, not your package script. We lost time running bun run start against a standalone build, which does not do what it looks like it does, and then more time pointing CMD at the wrong nesting level of the output directory.

Two rules that would have saved all of it:

  1. Start the standalone server directly, at the path the build actually produced. Don't route through a package script that resolves differently in a trimmed tree.
  2. Build-time and run-time environment variables are different things. Anything inlined into the client bundle must be present during the build. Setting it in the deployment is too late — the value was baked in, or wasn't, an hour earlier.

The general shape

If a build works locally and fails in a container, the hypothesis to test first is not "the container is misconfigured." It is:

The build context does not contain what I think it contains, or does not contain it yet.

That covers nearly all of them. Testing it costs one docker run ... ls. We did it on attempt seventeen. It would have worked on attempt one.