Learning how to containerise a local environment.

•

•

One Command to a Working Environment

I once spent three days trying to debug a race condition in a payment gateway, only to realize the issue wasn’t my code—it was the fact that my local Mac was running a slightly different version of OpenSSL than our staging servers. That realization, usually accompanied by a cold cup of coffee and a heavy sense of dread, is exactly why I stopped treating my machine like a sacred temple and started treating it like a disposable component. Most people think learning how to containerise a local environment is about following a trendy DevOps roadmap, but in reality, it’s about preventing the “it works on my machine” lie from becoming a production incident.

I’m not here to sell you on the magic of microservices or some bloated orchestration layer you don’t actually need. My goal is to show you a practical, stripped-back approach to setting up your workspace so that your environment is a predictable, boring constant rather than a variable that breaks at 2 AM. I’ll walk you through the trade-offs of different setups, focusing on what actually saves you time when you’re trying to ship a fix instead of fighting your own operating system.

Table of Contents

Eliminating Dependency Hell Before It Hits Production

Eliminating Dependency Hell Before It Hits Production

I used to think “dependency hell” was just something people complained about in forums until I spent an entire Tuesday trying to figure out why a specific version of a C library was behaving differently on my MacBook than it was on our Debian staging servers. It’s a special kind of exhaustion. You spend four hours debugging your application logic only to realize the issue was a minor version mismatch in a background worker’s runtime. By eliminating dependency hell at the source, you stop treating your local machine like a sacred, snowflake environment and start treating it like a disposable testing ground.

The most effective way I’ve found to handle this is by using docker compose for local setup. Instead of a README file that lists twelve different versions of PostgreSQL, Redis, and RabbitMQ that you have to manually install and hope they don’t conflict with your other projects, you define them in a single YAML file. This ensures environment parity between dev and prod because the container running on your laptop is the exact same image that will eventually hit the cluster. You aren’t just running code; you’re running the entire ecosystem. It takes a little more upfront effort to write the configuration, but it beats the alternative of debugging your OS at midnight.

Achieving True Environment Parity Between Dev and Prod

Achieving True Environment Parity Between Dev and Prod

The problem with most “works on my machine” bugs isn’t that the code is wrong; it’s that the ground beneath the code is different. I once spent an entire afternoon debugging a connection timeout, only to realize my local machine was running Postgres 15 while production was still stuck on 12. That’s not a code issue; that’s a failure of environment parity between dev and prod. When you rely on whatever version of a library happens to be installed on your MacBook, you aren’t actually testing your application—you’re testing your assumptions.

To fix this, I’ve moved toward using docker compose for local setup as a non-negotiable standard. Instead of a README file that lists five different installation steps for Redis, RabbitMQ, and a specific version of Python, I want a single command that spins up the entire stack. By treating your local dependencies as immutable artifacts rather than manual installs, you stop playing “version whack-a-mole.” It’s about creating a predictable sandbox where the only variable left to worry about is the logic you actually wrote. If it runs in the container on my laptop, and the container is identical to the one in the registry, the chances of a deployment-day meltdown drop significantly.

Five things I learned by breaking my own machines

  • Stop using ‘latest’ tags in your Dockerfiles. I once spent three hours debugging a build that worked on Monday but died on Tuesday because a base image updated overnight. Pin your versions to a specific SHA or at least a minor version; it’s the only way to ensure your environment doesn’t mutate behind your back.
  • Use Docker Compose to orchestrate the “invisible” dependencies. If your app needs Redis or a specific version of Postgres, don’t install them directly on your macOS or Linux host. Spin them up as sidecar containers in a compose file so everyone on the team is running the exact same database engine.
  • Don’t bake your source code into the image during development. Use bind mounts. If you have to rebuild a container every time you change a single line of code, you’ll stop testing frequently, and that’s exactly how bugs slip into the next commit.
  • Keep your development containers lightweight. I used to try and cram every single CLI tool and library into one giant image, but it made startup times sluggish and debugging a nightmare. Use multi-stage builds to keep the production image lean, but keep your dev environment focused on what actually runs the code.
  • Use a `.env` file for local configuration, but never, ever commit it. I’ve seen enough “accidental credential leaks” in pull requests to last a lifetime. Define your defaults in a `.env.example` so the next person knows what they need to provide, but keep the actual secrets out of the repository.

The Lessons I Learned While Fixing Broken Builds

If your local environment relies on a specific version of a library installed directly on your OS, you haven’t built a dev environment; you’ve built a time bomb that will inevitably go off during a deployment.

Parity isn’t about making your laptop identical to a massive cloud cluster; it’s about ensuring the fundamental runtime and its dependencies are the exact same bits in both places.

Documentation is useless if it’s a manual list of `brew install` commands; a Dockerfile is living documentation that actually works when you need it to.

The Long View

The Long View of containerized development.

At the end of the day, containerising your local environment isn’t about following a trend or being a purist about Dockerfiles. It’s about reducing the cognitive load required to actually write code. By forcing your local machine to mirror the production runtime, you stop wasting hours debugging why a specific library version is missing or why a database connection behaves differently on your Mac than it does in the cloud. You’ve moved the “works on my machine” conversation from a frantic Slack thread during a deployment window to a controlled, predictable process that happens while you’re still drinking your morning coffee.

I’ve spent enough time in the trenches to know that no amount of automation can prevent every single bug, but it can certainly prevent the stupid ones. Containerisation is a hedge against the chaos of a growing system. It might feel like extra overhead when you’re starting a small side project, but treat it as a mandatory piece of infrastructure from day one. You aren’t just setting up a dev environment; you’re building a safety net that ensures when things eventually do break, you’re debugging the logic of your application rather than the quirks of your operating system.

Frequently Asked Questions

How do I stop my laptop fans from sounding like a jet engine every time I run the full stack?

If your fans are screaming, you’re likely running too many heavy containers or unoptimized resource limits. I used to do this constantly. The fix is simple: set explicit CPU and memory limits in your `docker-compose.yml`. Without them, one runaway process will cannibalize your entire laptop. Also, check your build context; if you’re accidentally sending massive, unnecessary folders into the Docker daemon every time you save, you’re just burning cycles for no reason.

Is it actually worth the overhead of containerizing a simple script, or am I just over-engineering?

If it’s a script you’ll run once and forget, don’t bother. But if that script is going to live in a cron job, a CI pipeline, or on a teammate’s machine, containerize it. I’ve spent too many hours debugging why a Python script worked on my MacBook but died on a Debian server because of a minor `libssl` mismatch. The overhead is small; the “why is this failing now?” headache is massive.

How do I handle local data persistence so my database doesn't wipe itself every time I restart a container?

If you don’t use volumes, you’re essentially playing a game of Russian roulette with your test data. I used to lose half a day of work every time a container crashed because I hadn’t mapped a local directory to the container’s data path. Use Docker volumes. Map your database’s data directory to a persistent volume on your host machine. It’s a small amount of configuration overhead that prevents the “where did my schema go?” panic.

When does the complexity of managing a local Docker Compose file outweigh the benefits of just installing dependencies directly on my machine?

It’s a trade-off. If you’re working on a single script or a tiny CLI tool, Docker is overkill; just use a virtual environment and move on. But the moment you need a specific version of Postgres, a Redis cache, and a message queue just to run your tests, stop installing things directly. If you find yourself manually tweaking your system PATH or fighting version conflicts every Monday morning, the complexity of Compose has already paid for itself.

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.