A new engineer's first two days were spent installing things. The README had nineteen steps, four of which were wrong, and the failure at step twelve depended on whether they had previously worked on a project that pinned a different runtime version. Multiply by every hire, plus every existing engineer who reformats a laptop or switches between projects, and the cost is a substantial fraction of a headcount.
The environment is part of the build, and it should be defined in the repository with the same rigour as the dependency lockfile. There are two credible approaches, and they solve overlapping but distinct problems.
Containerised environments
A development container defines the toolchain in a Dockerfile plus a manifest, and the editor attaches to it. The win is that the environment is the same one CI uses, isolation from the host is complete, and support across editors is good enough that it is not a fight. The cost is filesystem performance on some hosts and a degree of indirection that frustrates people who like their local tools.
// .devcontainer/devcontainer.json — checked in, versioned, reviewed.
{
"name": "platform-api",
"dockerComposeFile": "../docker-compose.dev.yml",
"service": "app",
"workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
// The whole onboarding script, executed once, by the machine.
"postCreateCommand": "make bootstrap",
"forwardPorts": [8000, 5432, 6379],
"customizations": {
"vscode": {
"extensions": ["ms-python.python", "charliermarsh.ruff"],
"settings": { "python.defaultInterpreterPath": "/workspace/.venv/bin/python" }
}
}
}Declarative environments
The declarative approach — Nix being the mature example — pins the entire dependency graph down to the compiler used to build each tool, so two machines resolve to bit-identical toolchains. It is stronger than containers at guaranteeing reproducibility and runs natively, so there is no filesystem penalty. The cost is a genuinely steep learning curve and a smaller pool of people on your team who can debug it when something is missing.
My pragmatic position: use containers when the team is mixed and the priority is that everyone can start on day one. Use the declarative route when reproducibility is a hard requirement — regulated builds, long-lived maintenance branches, or a platform team that will own it properly.
Keep the inner loop fast
- One command to bootstrap and one to run. If the README has more than two steps, the environment is not solved.
- Cache aggressively: prebuilt images or a binary cache, so nobody waits ten minutes on first open.
- Watch filesystem performance for large dependency trees; keep those inside the container volume rather than on a bind mount.
- Seed realistic data automatically. An environment with an empty database is half an environment.
- Run the same definition in CI. If development and CI diverge, you have two environments and one of them is undocumented.
Onboarding time is a measurement of your build system, not of the new hire.
The honest test is simple and worth running this quarter: hand a laptop with nothing installed to someone who has never seen the project, and time how long until they have the test suite passing. Whatever that number is, it is the number every new engineer pays, every laptop refresh pays, and every engineer switching between projects pays. It is almost always worth a week of someone's time to cut it to an hour.