If you want a new hire productive on day one and "works on my machine" gone for good, three tools dominate the conversation: devcontainers (a container spec your editor reads), Nix (a purely functional package manager that pins every dependency down to the compiler), and a plain Makefile wrapping docker/asdf/scripts. The short version: a Makefile is the cheapest thing that removes 80% of onboarding pain, devcontainers give you a full disposable OS per project with the least new syntax to learn, and Nix is the only one that makes the environment bit-for-bit reproducible — at the cost of a real learning curve. Most teams should start with a Makefile and reach for the heavier tools only when a specific pain shows up.
I've shipped all three on real projects, and the failure modes matter more than the feature lists. Here's how to choose without regretting it in six months.
What problem are you actually solving?
"Dev environment as code" is a bucket that hides three different problems:
- Onboarding friction — a new person spends a day installing Postgres, the right Node version, and some C library nobody remembers.
- Drift — two engineers have subtly different toolchains and a bug only reproduces on one machine.
- CI/local parity — CI passes, local fails (or vice versa), because they're built from different recipes.
You need to know which one hurts most before picking a tool, because each tool targets a different point on that list. A Makefile crushes problem 1 with almost no investment. Devcontainers handle 1 and 2 well. Only Nix genuinely closes problem 3 to the point where "reproducible" means the same hash, not "close enough."
Takeaway: name the specific pain first — the wrong tool for your actual problem is worse than no tool.
When is a Makefile enough?
A surprising number of teams over-engineer this. If your stack is "a language runtime, a database, and a few CLI tools," a documented Makefile plus a version manager gets you most of the value in an afternoon.
.DEFAULT_GOAL := help
help: ## Show available commands
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN{FS=":.*?## "}{printf " %-12s %s\n", $$1, $$2}'
setup: ## Install toolchain + start services
asdf install
docker compose up -d db
npm ci
test: ## Run the test suite
npm test
reset-db: ## Nuke and reseed the local database
docker compose down -v && docker compose up -d db
npm run db:migrate && npm run db:seed
Pair that with an .tool-versions file (asdf/mise) so the language versions are pinned, and docker compose for stateful services, and you've solved onboarding: a new dev runs make setup and reads make help to discover everything else.
The honest limitation: a Makefile documents intent, it doesn't enforce an environment. If someone has a stray global node earlier on their PATH, or a different libssl, the Makefile won't catch it. It reduces drift; it doesn't eliminate it. And Makefile syntax itself is a minor footgun — tabs vs spaces, $$ for shell variables, recursive expansion surprises. But nobody needs to learn a new mental model, and that's the whole point.
Takeaway: if you can't articulate a drift bug that actually bit you, a Makefile is probably where you should stop.
How do devcontainers change the picture?
A devcontainer is a Dockerfile (or image) plus a .devcontainer/devcontainer.json that VS Code, GitHub Codespaces, and the devcontainer CLI understand. Your editor opens inside the container, so the whole toolchain — compilers, linters, the language server — lives in an image everyone shares.
{
"name": "api",
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"features": {
"ghcr.io/devcontainers/features/node:1": { "version": "20" }
},
"forwardPorts": [8000, 5432],
"postCreateCommand": "pip install -r requirements.txt",
"customizations": {
"vscode": { "extensions": ["ms-python.python", "charliermarsh.ruff"] }
}
}
The win is that the environment is disposable and identical: rebuild the container and you're back to a known-good state, and "works on my machine" becomes "works in the image, which is the machine." It also plays well with Codespaces if you want cloud dev boxes with zero local setup.
Where it hurts, from experience: the inner-loop performance on macOS and Windows depends on Docker's filesystem sharing, and large repos with heavy file watching can feel sluggish (bind-mount vs named-volume choices matter a lot here). It's also somewhat coupled to the VS Code ecosystem — the open devcontainer CLI exists and JetBrains support has grown, but the smoothest path is still VS Code. And your image is only as reproducible as your Dockerfile: apt-get install somepkg without a pinned version drifts the moment upstream updates.
If you want a full, disposable OS-level environment per project with the least new syntax to learn, devcontainers are the option that gets a team there fastest.
Takeaway: devcontainers trade a Docker performance tax and some editor coupling for a shared, throwaway environment that kills most drift.
When is Nix worth the learning curve?
Nix is the only tool here that treats the environment as a pure function of its inputs. With flakes and direnv, cd-ing into a project drops you into a shell where every tool is the exact version pinned in a lockfile — same inputs, same output hash, on your laptop and in CI.
{
description = "api dev env";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
outputs = { self, nixpkgs }:
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
devShells.x86_64-linux.default = pkgs.mkShell {
packages = [ pkgs.python312 pkgs.nodejs_20 pkgs.postgresql_16 ];
};
};
}
Commit the flake.lock, add an .envrc with use flake, and every developer gets byte-identical tooling. This is the real thing: it closes the CI/local parity gap in a way the other two only approximate. When a build depends on a precise chain of native libraries, Nix is the tool that makes "reproducible" a technical guarantee rather than a hope.
The cost is real and I won't sugarcoat it. The Nix language is unfamiliar and error messages are famously rough. Getting the whole team past "why won't my editor find the language server" takes a champion who actually knows Nix — without one, it becomes a single-person dependency, which is its own risk. macOS support works but some packages are Linux-first, and binary cache misses mean occasionally waiting on long local builds. The payoff is highest for teams with gnarly native dependencies or a hard reproducibility requirement; for a straightforward web app, it's often more machinery than the problem deserves.
Takeaway: adopt Nix when identical beats similar enough to justify a learning curve — and only if more than one person will maintain it.
Decision table
| Factor | Makefile + version manager | Devcontainers | Nix (flakes + direnv) |
|---|---|---|---|
| Setup effort | Lowest — an afternoon | Medium | Highest |
| New syntax to learn | Minimal | Docker + JSON | Nix language |
| Reproducibility | Documents intent | Shared image | Bit-for-bit guaranteed |
| Editor coupling | None | Smoothest in VS Code | None (works anywhere) |
| CI/local parity | Approximate | Good | Exact |
| Inner-loop speed | Native | Docker FS tax on Mac/Win | Native |
| Biggest risk | Drift still possible | Container performance | Team-wide learning curve |
Nothing stops you from combining them — a Makefile of ergonomic make targets inside a devcontainer or Nix shell is a common, pleasant setup where each tool does the job it's best at.
FAQ
Do devcontainers replace docker-compose?
No. docker-compose runs your stateful services (database, cache, queues); the devcontainer is the box your code and toolchain run in. They're complementary, and devcontainer.json can reference a compose file to bring both up together.
Is Nix overkill for a solo developer?
Usually, unless you're fighting native-dependency hell or want CI and laptop to match exactly. For most solo projects a pinned .tool-versions plus a Makefile removes the same day-to-day pain with far less to learn.
Can I use a Makefile and a devcontainer together?
Yes, and it's a good pattern. Let the devcontainer define what's installed and the Makefile define what you run — make test, make setup, make reset-db — so the commands stay the same whether you're inside the container or not.
Bottom line
Start with a Makefile plus a version manager; it removes most onboarding friction for the least effort, and it's the layer you'll keep no matter what else you add. Move to devcontainers when drift between machines is actually causing bugs and you want a shared, disposable environment — accepting a Docker performance tax on macOS and Windows. Reach for Nix only when byte-for-byte reproducibility is a genuine requirement and you have more than one person willing to learn it, because a single Nix expert is a single point of failure. In every case, name the specific pain first: the fanciest environment-as-code setup is wasted if it's solving a problem you don't have.
Top comments (0)