How to debug a build that works locally.

•

•

Green on My Machine, Red in the Pipeline

I was sitting in my kitchen at 3:14 AM, staring at a terminal window that felt like it was mocking me. My local tests were green, my container ran perfectly on my MacBook, and yet the CI pipeline was throwing a cryptic error that looked like it had been written in ancient Aramaic. There is a specific, hollow kind of frustration that comes when you realize you don’t actually know how to debug a build that works locally. You start questioning your entire career path, wondering if you should just give up the backend life and go into something stable, like insurance or goat farming.

I’m not here to sell you on some expensive, shiny observability platform or a complex new way to architect your microservices. Instead, I want to walk through the actual, messy steps I take when the environments stop behaving. I’ve spent too many years rediscovering the same mistakes to let them go unrecorded. We are going to look at the specific, boring things—the environment variables, the subtle differences in filesystem case sensitivity, and the hidden dependencies—that actually cause these breaks. My goal is to give you a practical checklist so that the next time your build fails in production, you spend twenty minutes fixing it rather than two hours questioning your sanity.

Table of Contents

Chasing the Ghost of an Environment Variable Mismatch

Chasing the Ghost of an Environment Variable Mismatch.

The most common culprit is almost always an environment variable mismatch. I’ve lost count of how many times I’ve sat there, certain my logic was sound, only to realize that my local `.env` file had a dummy API key that the staging environment actually needed to be real. It’s a special kind of frustration because the code is technically correct, but the context it lives in is lying to you. I used to think I was being thorough, but I wasn’t accounting for the silent assumptions we make about what “available” means in a local shell versus a remote runner.

This usually leads into a deeper rabbit hole of dependency version drift. You think you’re running the same stack, but your local machine has a slightly newer version of a shared library cached in its global path, while the CI/CD pipeline is pulling a slightly older, more restrictive version from the registry. I once spent an entire afternoon chasing a phantom bug that turned out to be a minor patch difference in a sub-dependency that my local package manager had resolved differently than the build server. If you aren’t enforcing strict version pinning, you aren’t actually running the same application in both places; you’re just running two very similar cousins.

Why Dependency Version Drift Always Wins in Production

Why Dependency Version Drift Always Wins in Production

If environment variables are the ghosts in the machine, then dependency version drift is the slow-motion train wreck that most of us ignore until it’s too late. I learned this the hard way at the insurance firm. We had a service that passed every local test with flying colors, but the moment it hit the staging environment, it would throw cryptic linking errors. It turned out that while our `package-lock.json` or `go.sum` files were technically present, our CI/CD pipeline was pulling a slightly different minor version of a transitive dependency because of a poorly configured registry mirror.

The real killer isn’t usually the library you explicitly installed; it’s the library that your library depends on. You think you’re in control, but you’re actually at the mercy of a deep, nested tree of versioning. I’ve spent far too many late nights realizing that my local machine was using a cached version of a binary, while the build server was performing a clean build vs cached build comparison that favored a newer, broken release. To stop this from happening, I’ve become a bit of a fanatic about build artifact consistency. If you aren’t using something like a specific container digest to ensure docker container parity, you aren’t actually testing what you’re deploying; you’re just testing a very convincing simulation.

My Checklist for When the Build Fails in the Wild

  • Stop assuming your local shell is “clean.” I’ve lost hours to a forgotten `.env` file or a shell alias that was masking a configuration error. If it isn’t explicitly defined in your deployment manifest, treat it as if it doesn’t exist.
  • Compare the actual runtime artifacts, not just the source code. Sometimes the build pipeline is pulling a different base image or a slightly different version of a system library than what you have sitting on your MacBook. If you aren’t checking the checksums or the exact image tags, you’re just guessing.
  • Log everything—even the stuff that seems trivial. When a build fails in a remote CI/CD runner, you don’t have the luxury of an interactive terminal to poke around. I’ve learned to add extra “sanity check” print statements that output the environment state right before the critical failure point.
  • Audit your “invisible” dependencies. It’s rarely the library you added yesterday; it’s usually a transitive dependency that updated itself in the background. If your lockfile isn’t strictly enforced in the build pipeline, you’re essentially playing Russian roulette with your production stability.
  • Test the deployment, not just the build. A successful build is a hollow victory if the container can’t actually talk to the database in the staging VPC. I’ve started running “smoke tests” that check basic connectivity immediately after the build completes, rather than waiting for a user to report a 500 error.

What I'm actually taking away from this

If it works on your machine but fails in the pipeline, stop looking at your logic and start looking at the environment; the code is rarely the liar, the context is.

Treat your local environment like a lie—it’s a curated, clean version of reality that ignores the messy, drifting dependencies and configuration gaps that actually exist in production.

The goal isn’t just to fix the build; it’s to document the discrepancy so that when the next person hits this exact same wall at 2 AM, they aren’t starting from scratch.

The Long View

Debugging environments through The Long View.

At the end of the day, debugging these discrepancies isn’t about being a genius; it’s about being a detective who refuses to accept “it just works” as an answer. Whether you are hunting down a missing environment variable that only exists in the CI pipeline, or realizing that your local machine is running a slightly different version of a shared library than the production cluster, the solution usually lies in the differences between environments. Stop assuming your local setup is a perfect mirror of reality. Start treating your local environment as a controlled experiment that requires strict, documented parity with the places where your code actually lives and dies.

I’ve spent enough late nights on incident calls to know that these frustrations are just part of the job description. Every time a build fails in production despite passing locally, it is an opportunity to tighten your process—to add a linting rule, to lock a dependency, or to automate a configuration check. Don’t just fix the bug and move on; fix the reason the bug was able to hide from you. If you do that, you aren’t just a developer who writes code; you’re a developer who builds reliable systems that don’t wake you up at 2 AM.

Frequently Asked Questions

How do I actually verify that my local Docker container is running the exact same OS version as the production cluster without manually checking every layer?

I used to waste way too much time manually inspecting layers, which is a recipe for missing something. Now, I just compare the digests. Run `docker inspect` on your local image to grab the `RootFS.Layers` hashes, and then run a quick script against your production registry or cluster to pull the same metadata. If the SHA-256 digests don’t match, you aren’t running the same OS, regardless of what the `FROM` line in your Dockerfile says.

If I'm using a lockfile, why am I still seeing different dependency trees during the build stage in the CI pipeline?

The lockfile is a promise, but it’s only as good as the command you use to honor it. If your CI pipeline is running `npm install` instead of `npm ci` (or `yarn install –frozen-lockfile`), you’re asking for trouble. A standard install can see a mismatch between your `package.json` and the lockfile and decide to “helpfully” update your dependencies on the fly. Suddenly, your CI is building a different tree than the one you tested.

At what point do I stop debugging the code and accept that the underlying infrastructure—like the runner or the network—is the actual culprit?

You stop when your “identical” environment starts feeling like a lie. If you’ve verified the code, the dependencies, and the environment variables, and the failure is still non-deterministic or only happens in the CI pipeline, stop poking the logic. At that point, you aren’t debugging code; you’re debugging the plumbing. I usually make this call once I’ve spent more than two cycles proving the logic is sound. If the runner is the culprit, no amount of refactoring will fix it.

How can I automate the detection of these environment mismatches before they actually trigger a 2 AM alert?

I stopped trying to manually audit everything and started treating environment parity as a testable requirement. Now, I run a “pre-flight” script in my CI pipeline that compares a checksum of the production environment’s keys against a sanitized local manifest. It won’t catch every nuance, but it catches the obvious stuff—like that missing API key—before the deployment even starts. It’s not a silver bullet, but it’s better than a pager at 2 AM.

Tomasz Wierzbicki

About Tomasz Wierzbicki

I am not interested in arguing about frameworks. I am interested in why the thing broke at two in the morning and what would have prevented it. Most of what I know came from being wrong in production and writing it down afterwards.