How to run the same checks locally and in CI.

•

•

It Passed Locally Is Not an Excuse

I once spent a full Tuesday chasing a phantom bug that only appeared in our Jenkins pipeline, despite my local test suite giving me a clean green checkmark every single time. It’s a specific kind of exhaustion—the kind that hits when you realize your machine is essentially lying to you. Most teams try to fix this by adding more complex orchestration layers or expensive cloud-based dev environments, but that’s usually just throwing money at a symptom. The real problem isn’t your hardware; it’s the subtle drift between your laptop and the runner. If you’re tired of wondering how to run the same checks locally and in ci without losing your mind, you’re likely dealing with a configuration gap that no amount of “magic” tooling is going to fix.

I’m not going to sell you on some shiny new container orchestration platform or a proprietary SaaS tool. Instead, I want to talk about the boring, reliable stuff: parity through containerization, shared configuration files, and why your shell environment is probably your worst enemy. I’ll show you the exact patterns I use to ensure that if a build passes on my machine, it actually passes in the pipeline. No hype, just the practical trade-offs of making your local environment a mirror of reality.

Table of Contents

Eliminating It Works on My Machine Syndrome Once and for All

Eliminating It Works on My Machine Syndrome Once and for All

The root of the problem is almost always a silent drift between your laptop and the runner. You’ve got a specific version of Node or Python tucked away in a corner of your OS, while the CI is pulling the latest stable image from a registry. It feels fine until a dependency update breaks the build in the cloud, leaving you staring at a green checkmark on your terminal and a red cross in GitHub. To stop this, I’ve moved toward dockerizing test suites as a non-negotiable part of my workflow. If the tests don’t run inside a container on my machine, they don’t exist.

This isn’t about being a Docker zealot; it’s about eliminating it works on my machine syndrome by ensuring the execution context is identical. I stop trying to manage local runtimes and instead treat my local machine as nothing more than a way to trigger a containerized environment. When you use the exact same image for your local pre-commit hooks as you do for your production deployment, you aren’t just chasing consistency—you’re actually buying back your sleep. It turns the “but it worked for me” excuse into a relic of the past.

The High Cost of Broken Automated Testing Workflow Parity

The High Cost of Broken Automated Testing Workflow Parity.

The real cost isn’t just the time wasted staring at a terminal; it’s the slow erosion of trust in your own tooling. When your local machine says “green” but the CI pipeline screams “red” because of a minor version mismatch in a system library, you stop trusting your tests. You start treating CI failures as nuisances to be bypassed rather than actual signals of regression. I’ve seen entire sprint cycles slowed down because a team spent more time fighting their deployment scripts than writing actual business logic.

It also creates a specific kind of mental friction. If you can’t rely on reproducible build pipelines, you end up performing a strange ritual every time you push code: running the tests locally, then immediately running them again in the cloud just to be sure. This lack of automated testing workflow parity turns the development loop into a guessing game. Instead of focusing on the logic of a new payment gateway or a tricky database migration, you’re stuck wondering if the failure is a legitimate bug or just another ghost in the machine caused by a missing environment variable. It’s exhausting, and it’s exactly how technical debt starts to pile up.

Five ways to stop lying to yourself about your local environment

  • Stop using “shortcuts” for local testing. If your CI runs a heavy-duty linting suite or a specific version of Node, don’t skip it locally because “it takes too long.” If you aren’t running the same checks, you aren’t actually testing; you’re just hoping.
  • Containerize your toolchain. I learned this the hard way at the insurance firm: if your local machine has a slightly different version of a CLI tool than the runner in GitHub Actions, you’re going to have a bad time. Use Docker to wrap your linting and testing commands so the environment is identical regardless of where it’s running.
  • Treat your CI configuration as the single source of truth. Don’t maintain a separate `Makefile` for local use and a `.yaml` for CI that do slightly different things. If you can, call the exact same scripts from both places. The goal is to make the CI just a remote execution of your local workflow.
  • Use “Pre-commit” hooks, but don’t overdo it. Running every single check on every single commit can kill your flow, but having a lightweight version of your CI suite running locally via a tool like `pre-commit` ensures that the “obvious” stuff is caught before you even push.
  • Mirror your data constraints. There is nothing more frustrating than a test passing locally against a clean SQLite database only to fail in CI because the integration environment is hitting a real, messy PostgreSQL instance. If your CI uses a specific database engine, use that same engine locally—even if it’s heavier.

The Bottom Line

The Bottom Line: Reducing cognitive load.

If your local setup is a “special snowflake” compared to your CI pipeline, you aren’t actually testing your code; you’re just testing your ability to mimic a specific environment.

Parity isn’t about making everything identical—it’s about making the failure modes identical so you aren’t blindsided by a red build after a green local run.

Invest the time in containerization or unified task runners now, or prepare to spend that time debugging environment drift at an hour when you’d much rather be sleeping.

The Bottom Line

At the end of the day, achieving parity between your laptop and the CI runner isn’t about chasing some theoretical ideal of perfection. It’s about reducing cognitive load. When you spend your time fighting environment discrepancies—different Node versions, missing environment variables, or subtle differences in how Docker handles volumes—you aren’t actually engineering; you’re just troubleshooting your tools. By standardizing on containers or shared task runners, you ensure that when a test fails, it’s because your logic is actually broken, not because your local machine is playing tricks on you.

I know it feels like extra work upfront. It’s much faster to just `npm test` and move on to the next ticket. But I’ve learned the hard way that the time you “save” today is usually paid back with interest at 2 a.m. when a deployment fails for a reason you can’t replicate. Build these guardrails now. Your future self—the one who is tired, caffeinated, and trying to fix a production incident—will thank you for making the environment a constant rather than a variable.

Frequently Asked Questions

If I'm using Docker to mirror the CI environment, won't that just make my local feedback loop painfully slow?

It can, if you do it wrong. If you’re spinning up a full, heavy-duty orchestration stack every time you change a single line of Go, you’re going to hate your life. I don’t run the whole world locally. I use Docker for the “heavy” stuff—the database, the message broker, the messy dependencies—but I keep the actual application running natively. You get the environment parity for the infrastructure without the agonizingly slow container rebuilds for every minor tweak.

How do I handle secrets or environment variables that exist in the CI pipeline but shouldn't be sitting on my laptop?

The temptation is to just copy the `.env` file from the CI runner to your laptop, but that’s a one-way ticket to a security incident. Instead, I use a “template and shim” approach. I keep a `.env.example` in the repo with dummy values for everything. Locally, I use a tool like `direnv` or a simple shell script to load local-only overrides. If a service requires a real secret to even boot, I use a local secret manager or a dedicated tool like Doppler to inject them into my session without ever writing them to a plain-text file on my disk.

Is it worth the effort to sync linting and formatting rules if the CI is already catching them eventually?

If you’re waiting for CI to catch linting errors, you’re just moving the friction downstream. It’s a waste of time. Every minute I spend waiting for a CI pipeline to spin up just to tell me I missed a semicolon is a minute I’m not actually coding. Syncing them locally isn’t about perfection; it’s about the feedback loop. Keep the loop tight so you can fix mistakes while the context is still in your head.

At what point does trying to make local and CI identical become a case of diminishing returns?

You hit diminishing returns the moment you start fighting the laws of physics. If you’re spending three days trying to replicate a massive, proprietary cloud-native orchestration layer on a MacBook just to run a lint check, you’ve lost. Stop trying to mirror the entire infrastructure. Aim for parity in the logic and the tooling versions, not the hardware. If the environment is “close enough” to catch 95% of the silly mistakes, move on.

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.