6 min read
Most UK teams I have worked with in the last four years treat Docker for local dev as a default, not a choice. It is cargo-culted from a Medium post in 2019 and nobody has questioned it since. The thesis of this piece is blunt: if your service is a single binary or a single language runtime, you are almost always better off running it natively on your laptop and leaving Docker for production-shaped problems. Native installs are faster to boot, faster to attach debuggers to, and they do not eat 4-8GB of RAM before you have written a line of code.
Run docker stats on your laptop right now. On a typical MacBook Pro with two or three Compose services up, you will see 6-12GB of RSS attributed to the daemon, the buildkit workers, and idle containers. That is memory the host OS would otherwise use for file cache and your IDE. On Apple Silicon the gap is smaller than it used to be thanks to the new virtiofs mount, but it is still real and measurable. I have watched a Monzo-style service-mesh sidecar stack go from a 12-second cold start in Compose to 1.4 seconds natively on the same M2 Pro machine.
Disk is the other hidden tax. A Postgres 16 image, a Redis 7 image, a Node 20 image and a small Python 3.12 image will easily consume 6GB of Docker.raw on macOS. Native installs via Homebrew or apt share the system page cache and deduplicate libraries. CI is the only place where image reproducibility is genuinely worth paying for. Your laptop is not CI.
If your service is a single Go binary, a single Rust binary, or a Node/Python app with one database, you do not need a container on your laptop. You need the binary on $PATH, the database running as a normal service, and a Procfile or a tmux session to keep them together. The mental model collapses nicely and so does the resource bill.
There is also the debugging tax. Attaching dlv to a Go process running natively is one command. Attaching it through a Compose network, with a remote-attach configuration and source paths mapped through a bind mount, is a small but constant tax on every breakpoint. Same story for Python with debugpy and Node with --inspect. You are paying for the privilege of debugging across a virtualised filesystem boundary that exists only because you chose Docker.
Docker Compose earns its keep when you genuinely have more than one long-running service that needs to talk to each other and you want one command to bring the whole graph up. A small API plus Postgres plus Redis plus a worker is a reasonable Compose use case. A single FastAPI app plus Postgres is not. The line I use on my teams is: if removing the container would force you to rewrite more than about ten lines of config, native is fine. If it would force you to hand-roll a service discovery file and three startup scripts, Compose is the right call.
Here is a realistic Procfile for a small UK fintech-style stack. It runs natively with Foreman or overmind on macOS and Linux:
web: uvicorn app.main:app --reload --port 8000
worker: rq worker --url redis://localhost:6379
redis: redis-server --save "" --appendonly no
postgres: postgres -D /usr/local/var/postgres
No Dockerfile, no docker-compose.yml, no volume mounts, no network_mode: host workarounds for the Mac. Cold start for the whole stack: about three seconds, dominated by Postgres. Hot reload of the web process: instant, because uvicorn --reload watches the filesystem directly without going through a 9P shim.
The trick is to keep production on containers and only change the developer experience. Production is where image reproducibility, distroless base images, and pinning actually matter. Laptops are where iteration speed matters. These are different optimisation targets and pretending they are the same is the original sin of "but it works in production."
Concretely, here is the migration I have run three times in the last two years, most recently at a small edtech in Manchester:
Makefile target bootstrap that installs Postgres, Redis and Node via Homebrew on macOS and via apt on Ubuntu. CI already runs Linux so the parity story is easy.docker-compose up with a Procfile run via foreman start or overmind start -f Procfile. Both are one-binary installs.smoke target that runs the container once in CI to prove it still boots. That is the only place Docker needs to be exercised day to day.The HMRC-style services my friends work on at GOV.UK have gone further: developers run Python natively, the integration tests run in containers, and the deploy is a container. Three different environments, three different optimisation targets. That is the right shape.
I ran a tiny benchmark on an M2 Pro, 16GB. Same FastAPI service, same Postgres 16, same query workload via wrk -t4 -c50 -d30s against /healthz and a 200-row SELECT. Results averaged over three runs:
That is a 54 percent throughput improvement, a 56 percent p99 improvement, and a 60 percent drop in RAM, by deleting Docker from the dev loop. The throughput gap is mostly filesystem: Postgres inside Docker on macOS is doing IO through a virtiofs mount, and Postgres is famously sensitive to fsync latency. Native Postgres hits the APFS directly. Your mileage will vary on Linux where Docker uses overlayfs over tmpfs, but the RAM and CPU wins still hold.
"But new joiners get a working laptop in 20 minutes." So do they with a Makefile and Homebrew. The setup script is shorter and the failure modes are easier to Google. "But my production runs on Alpine." Your production should not be on Alpine, and that is a separate fight, but even if it is, your dev box matching the libc of your laptop matters more than matching the libc of production for the 99 percent of bugs that are not glibc-related. "But secrets." Use a .env file and direnv. The NHS Digital pattern for this is well documented and does not need a sidecar.
There is one case where I will still reach for Docker on a laptop: reproducing a specific customer's environment because they are stuck on a weird old OpenSSL and the bug only reproduces there. That is a debugging tool, not a development platform. Treat it like a debugger, not like a runtime.
Mostly. Windows Subsystem for Linux 2 gives you a real Linux kernel and a real filesystem, so running Postgres, Redis and your app natively inside WSL2 is roughly as fast as native on Linux and noticeably faster than Docker Desktop's Hyper-V backend. The same Procfile works. Docker Desktop's WSL2 engine has improved but it still adds a layer you do not need.
Lock your language version with .python-version, .node-version, or go.mod. Lock your database version with a Homebrew formula pin or an apt hold. Reproducibility is a language tool problem, not a container problem. CI still runs in a clean container so you catch drift there, which is where drift actually matters.
Yes: when your dev environment is genuinely a graph of six or more services, when one of them is something painful to install natively like Kafka or Elasticsearch, or when you are on Windows and need to test Linux-specific behaviour. Past two or three services, or past one painful dependency, Compose starts to pay for itself. Below that line it is a tax.