Local Development Environments: Dev Containers, Nix Flakes, and Reproducible Setups
It is 9 AM on a new developer’s first day. They clone the repo, follow the README, and immediately hit a wall: Python version mismatch, missing system library, postgres running on the wrong port, a native extension that won’t compile on their Apple Silicon Mac. By noon they have opened six Stack Overflow tabs and are quietly questioning their career choices. By end of day they have a working environment — sort of — with a collection of workarounds that only they understand, and that will cause mysterious failures for the next six months.
This is not a hiring problem. It is a tooling problem. The development environment is infrastructure, and like all infrastructure it needs to be treated as code: versioned, reproducible, and shareable.
This post is a deep technical dive into solving that problem completely. We will cover Dev Containers (the VS Code and GitHub Codespaces standard), Nix flakes (the nuclear option for perfect reproducibility), mise (the pragmatic polyglot version manager), and how to combine them into a layered setup that gets a new developer from git clone to a fully working environment in minutes, not days.
Part 1: The “Works on My Machine” Problem
Before reaching for tools, it helps to understand exactly what goes wrong and why.
The Classic Failure Modes
Tool version drift. Your pyproject.toml requires Python 3.12, but the developer has 3.9 installed from a tutorial three years ago. Your Makefile calls terraform and expects 1.7 but they have 0.14. Node 18 vs Node 20 has subtle breaking changes in the crypto module that surface as a cryptic runtime error, not a clean dependency error.
System dependency hell. Python’s cryptography package needs OpenSSL headers. Your image processing library needs libvips. Your PDF generator needs wkhtmltopdf. These live outside your package manager’s jurisdiction — they are OS-level concerns — and there is no requirements.txt for apt. The README says “install system dependencies” and lists four packages, but it was written on Ubuntu 22.04 and the developer is on Arch Linux.
Global state pollution. Developer A installs a globally-patched version of a tool to fix a different project. Developer B has a conda environment that overrides their PATH in unexpected ways. Developer C ran npm install -g on a package that monkey-patches a shared binary. These global mutations compound over months and machines until no two developer machines are in the same state.
The environment variable problem. DATABASE_URL is set in someone’s .bashrc. It was never added to .env.example. New developer gets ConnectionRefusedError and assumes the database is not running, when the actual problem is a missing environment variable. Thirty minutes lost.
The Hidden Costs
Beyond the obvious onboarding friction, environment inconsistency creates ongoing costs:
- Bugs that only reproduce on one machine are expensive to diagnose and frequently dismissed as “local issue, can’t reproduce”
- CI failures that pass locally (or vice versa) because CI runs a slightly different OS or tool version
- Security patches that developers skip because “upgrading will break my local setup”
- Senior engineers spending a disproportionate chunk of their time helping juniors debug environment issues
The Goal
A developer should be able to run:
|
|
And have a fully working development environment. No README archaeology. No system-level package hunting. No “ask Dave, he knows how to set it up.”
The Landscape
Several tools attack this problem from different angles:
| Tool | Approach | Best For |
|---|---|---|
| Dev Containers | Docker container as IDE | VS Code/Cursor users, teams that need service deps |
| Nix / Nix Flakes | Functional package manager | Maximum reproducibility, cross-platform, power users |
| mise | Polyglot version manager | Simple tool version pinning, fast adoption |
| Vagrant | VM provisioner | Scenarios requiring full OS isolation |
| Homebrew Bundlefile | macOS package pinning | Mac-only teams with simple needs |
| asdf | Predecessor to mise | Legacy; mise is the modern replacement |
These tools are not mutually exclusive. The most practical setups layer them.
Part 2: Dev Containers — Docker as Your IDE
What Dev Containers Are
A Dev Container is a Docker container that your IDE treats as a first-class development environment. VS Code (and Cursor, which shares the same extension system) mounts your source code into the container, installs your extensions inside the container’s filesystem, and runs your terminal inside the container. From your perspective you are using your normal editor. From the computer’s perspective everything is executing in an isolated, reproducible Docker container.
The .devcontainer/devcontainer.json file is an open specification — the same config works in VS Code locally, GitHub Codespaces, JetBrains IDEs (improving support), and Gitpod. You define it once and it works everywhere.
The Minimal devcontainer.json
The simplest possible setup uses a pre-built image from Microsoft’s container registry:
|
|
When a developer opens this project in VS Code and accepts the “Reopen in Container” prompt, VS Code pulls the image, creates a container, installs the two extensions inside it, runs pip install -e '.[dev]', and opens an editor session inside the running container. Ports 8000 and 5432 are forwarded transparently to the host.
The remoteUser: "vscode" is important — it ensures you are not running as root inside the container, which matters for file permission consistency and security.
Dev Container Features
Features are pre-built, composable add-ons that install tools into your dev container without requiring a custom Dockerfile. They handle the messy installation details (architecture detection, PATH setup, etc.) for you.
Commonly useful features:
|
|
The feature registry at containers.dev/features has hundreds of community-maintained features. For most tool installs you should reach for a feature before writing custom Dockerfile commands — they are better maintained and handle edge cases you will not think of.
Custom Dockerfile-Based Dev Containers
When you need full control — custom base image, specific system packages, build steps that features do not cover — use a Dockerfile:
.devcontainer/
devcontainer.json
Dockerfile
.devcontainer/Dockerfile:
|
|
.devcontainer/devcontainer.json referencing the Dockerfile:
|
|
Using "context": ".." allows the Dockerfile to COPY files from the project root if needed (e.g., copying a requirements.txt to pre-warm the pip cache).
Docker Compose-Based Dev Containers
Real applications depend on services. A Python web app needs Postgres. A Node app needs Redis. A microservice needs a message broker. The dockerComposeFile option wires a full compose stack into your dev container:
.devcontainer/docker-compose.yml:
|
|
.devcontainer/devcontainer.json:
|
|
The "service": "app" key tells VS Code which container to attach the IDE session to. The other services (db, cache) run alongside and are accessible by their service names within the Docker network.
The volumes: ../..:/workspaces:cached mount uses :cached for performance on macOS. The double ../.. goes up two levels (from .devcontainer/) to mount the full repository root.
Lifecycle Hooks
Dev containers have a rich lifecycle you can hook into:
|
|
The execution order:
initializeCommand— runs on the host before the container starts; useful for pulling images or checking host prerequisitesonCreateCommand— runs once when the container is first created; for one-time setup stepsupdateContentCommand— runs when the repository content changes (e.g., after agit pull); ideal forpip install/npm cito keep deps in syncpostCreateCommand— runs after creation; for database migrations, seeding, etc.postStartCommand— runs every time the container starts; for lightweight tasks like fetching git remotes
For long-running commands, pass an object to show progress in the VS Code terminal:
|
|
GitHub Codespaces
The same devcontainer.json works in GitHub Codespaces without modification. Push the .devcontainer/ directory to your repository and any developer can click “Code → Codespaces → Create codespace” to get a fully configured browser-based (or VS Code-attached) environment in the cloud. Free tier is available for public repositories.
For team projects, this is a powerful fallback: developers who have not set up local Docker can still get a fully working environment in under two minutes, with no local installation required.
Dev Container Limitations
Dev Containers are excellent but not perfect:
- Requires Docker. On macOS, Docker Desktop has significant overhead. OrbStack is a popular alternative that is faster and lighter.
- VS Code or Cursor required for the full experience. JetBrains support exists but is less mature.
- Cold start time. Building a custom Dockerfile the first time can take minutes. Layer your Dockerfile well to maximize cache hits on rebuilds.
- Bind mount performance on macOS. Docker Desktop’s macOS bind mounts are notoriously slow. Use the
VirtioFSfile sharing implementation in Docker Desktop settings, or switch to OrbStack.
Part 3: Nix and Nix Flakes — The Nuclear Option
What Nix Is
Nix is a purely functional package manager with a key property that no other mainstream package manager shares: builds are hermetic and reproducible. Given the same inputs, Nix always produces the same output. Every package is installed in its own isolated store path (/nix/store/hash-name-version/). Packages never share or conflict over a global mutable state like /usr/local. Different projects can depend on different versions of the same library without conflict.
Nix works on Linux and macOS. A single flake.nix file describes an environment that works identically on both, across machines, across time.
Why Nix Is Worth Learning
Nix has a steep learning curve — the Nix expression language is unusual, the documentation is scattered, and the error messages can be inscrutable. So when is it worth it?
Nix is worth it when:
- You have polyglot projects with complex dependency graphs
- You need the same environment on Linux and macOS without platform-specific workarounds
- You want to pin not just language runtimes but system libraries, compilers, and build tools
- You are building reproducible CI environments where hermetic builds matter
- You are serious about long-term reproducibility (a
flake.lockfile means the environment works the same way in five years)
Nix is probably overkill when:
- Your team is small, uses one language, and mise handles your needs
- No one on your team has any Nix experience and the learning curve is not justified
- You are on Windows (Nix on WSL works but adds complexity)
Nix Flakes
Flakes are the modern Nix interface, stabilizing what was previously a collection of ad-hoc conventions. A flake.nix defines:
- inputs: external dependencies (other flakes, nixpkgs channel), each pinned to a specific revision in
flake.lock - outputs: what the flake provides (dev shells, packages, NixOS configurations, etc.)
A flake.lock is like a package-lock.json for your entire toolchain — it pins every transitive dependency to a specific git commit, ensuring that nix develop produces byte-for-byte the same environment on every machine, forever.
A Practical flake.nix
|
|
Enter the environment with:
|
|
To use a specific output (useful for per-environment shells):
|
|
direnv + Nix: Automatic Activation
Manually running nix develop every time you enter a project directory is tedious. direnv solves this. Install direnv and hook it into your shell:
|
|
Create .envrc in your project root:
|
|
Run direnv allow once. From that point forward, entering the project directory automatically activates the Nix dev shell — all tools appear in your PATH, all environment variables are set. Leaving the directory deactivates it. This is seamless and fast because direnv caches the shell environment.
For performance, use nix-direnv instead of the built-in flake support:
|
|
nix-direnv caches the dev shell activation aggressively, making directory switching nearly instant even for complex flakes.
Language-Specific Nix Patterns
Python with packages baked in:
|
|
This embeds the Python packages into the Nix environment rather than relying on a virtualenv. For projects with many dependencies, using pip inside the shell (with a virtualenv) and letting Nix manage only the system-level Python and native deps is usually more practical.
Go with tools:
|
|
Rust (using the fenix overlay for latest toolchains):
|
|
devenv.sh: Nix Without the Pain
devenv.sh is a layer on top of Nix that provides a much friendlier interface, including built-in process management. It is the recommended entry point for teams new to Nix.
devenv.nix:
|
|
With devenv up, Postgres and Redis start locally (no Docker required). With devenv shell, you drop into the configured shell. The devenv.lock file pins everything reproducibly.
Part 4: mise — The Pragmatic Version Manager
What mise Is
mise (pronounced “meez”, formerly rtx) is a polyglot version manager that replaces asdf, nvm, pyenv, rbenv, goenv, and their friends with a single, fast, Rust-based tool. It pins tool versions per project via a .mise.toml file committed to the repository.
Installation and Setup
|
|
.mise.toml
This is the file you commit to your repository. Every developer who runs mise install gets exactly these versions:
|
|
The [env] section injects environment variables automatically when you are inside the project directory (no direnv required, though they work together).
Key Commands
|
|
mise Tasks
mise supports a [tasks] section that replaces Makefile patterns:
|
|
Run tasks with mise run dev, mise run test, etc.
mise vs Nix
| mise | Nix | |
|---|---|---|
| Learning curve | Very low | High |
| Setup time | Minutes | Hours |
| Reproducibility | Tool versions pinned | Entire dependency graph pinned |
| System libraries | Not managed | Fully managed |
| macOS + Linux parity | Good | Excellent |
| Binary cache | Uses tool’s own releases | Cachix for pre-built binaries |
| Hermetic builds | No | Yes |
mise and Nix complement each other well: mise for simple projects where you just need Python 3.12 and Node 20; Nix when you need the entire dependency graph to be hermetic and reproducible down to the C library.
Part 5: A Complete Developer Workflow — Combining the Tools
The Layered Approach (Dev Container + mise + docker-compose)
This is the recommended setup for most teams: pragmatic, VS Code-friendly, and covers the full stack.
Layer 1 — mise for language runtimes. Even inside a dev container, having a .mise.toml is useful because the same file drives CI and any developer who does not use VS Code.
Layer 2 — Dev Container for system deps and IDE integration. The container provides system libraries, dev tools, and the VS Code extension configuration.
Layer 3 — docker-compose for services. Postgres, Redis, and other services run in separate containers alongside the app container.
Here is the full directory structure for a FastAPI + PostgreSQL project:
myproject/
.devcontainer/
devcontainer.json
docker-compose.yml
Dockerfile
.mise.toml
pyproject.toml
alembic.ini
.env.example
.editorconfig
.devcontainer/Dockerfile:
|
|
.devcontainer/docker-compose.yml:
|
|
Note the vscode-extensions volume — this persists VS Code extension installations across container rebuilds, dramatically speeding up the “rebuild container” workflow.
.devcontainer/devcontainer.json:
|
|
.mise.toml (committed to repo):
|
|
The Nix-First Approach
For teams already invested in Nix, the entire stack lives in flake.nix. No Docker required for local development:
|
|
Part 6: CI/CD Parity
The biggest win from a reproducible dev environment is that it eliminates the class of bugs where tests pass locally and fail in CI (or vice versa).
Using Your Dev Container Dockerfile in CI
|
|
The devcontainers/ci action builds your dev container (using the same .devcontainer/ config) and runs commands inside it. CI now uses the same Docker image as every developer’s local environment.
Alternatively, reference the Dockerfile directly in your CI workflow:
|
|
Pre-build and push the dev container image to your container registry periodically (e.g., when .devcontainer/Dockerfile changes). This eliminates build time in CI entirely.
Nix in CI
|
|
Cachix provides a binary cache so Nix packages are not rebuilt from source on every CI run — they are downloaded from the cache just like a package manager.
mise in CI
|
|
The jdx/mise-action installs mise and activates it. mise install reads .mise.toml and installs the pinned versions. Everything downstream uses those exact versions.
The Multi-Stage Dockerfile Pattern
The cleanest setup has a single Dockerfile that serves three purposes:
|
|
Reference the dev stage in .devcontainer/devcontainer.json:
|
|
Reference the production stage in your deployment pipeline. One Dockerfile, three consumers, zero duplication.
Part 7: Dotfiles and Shell Configuration
The Problem
Even with a perfectly reproducible tool environment, your shell is personal. Your aliases, your prompt, your git config, your tmux layout — these live in dotfiles scattered across $HOME. A new machine means hours of manual reconstruction.
The Dotfiles Repository Pattern
Store all your dotfiles in a git repository and use a tool to manage the symlinks:
~/dotfiles/
.zshrc
.gitconfig
.tmux.conf
.config/
starship.toml
nvim/
install.sh
GNU Stow creates symlinks from ~/dotfiles/ to ~/:
|
|
chezmoi is more powerful: it supports templates (machine-specific config), encrypted secrets, and a proper apply/diff workflow:
|
|
Project-Level Configuration
Commit these files to every repository:
.editorconfig — enforces consistent indentation and line endings across editors:
|
|
.envrc — environment variables loaded by direnv, never .env in git:
|
|
.env.example — documents what secrets are needed, without values:
|
|
Part 8: Real-World Examples by Stack
Python FastAPI + PostgreSQL
Already covered in the combined workflow above. Key points:
- Use the
mcr.microsoft.com/devcontainers/python:3.12base image - docker-compose for Postgres with a health check before the app depends on it
postCreateCommand:pip install -e '.[dev]' && alembic upgrade head- Extensions:
ms-python.python,charliermarsh.ruff,mtxr.sqltools
Node.js / Next.js
|
|
.mise.toml:
|
|
Go Project
|
|
Flake alternative for Go, if your team uses Nix:
|
|
Infrastructure / DevOps Repo (Terraform + kubectl + Ansible)
|
|
The mounts array binds your local AWS credentials, kubeconfig, and SSH keys into the container. They are never copied into the image — they are mounted read-only at runtime.
Part 9: Tips and Common Pitfalls
File Permission Issues
The most common dev container headache: files created inside the container are owned by root or a different UID than your host user. Symptoms include permission denied errors when trying to edit files from the host.
Solutions:
- Always set
"remoteUser": "vscode"and use thevscodeuser in your Dockerfile - Set your Dockerfile’s user to match the expected UID:
RUN usermod -u 1000 vscode - Use
:cachedon volume mounts to reduce macOS overhead and let Docker handle caching
Performance on macOS
Docker Desktop’s macOS bind mounts route through a Linux VM, causing significant overhead for I/O-heavy operations (npm install, large test suites, webpack builds).
Mitigations:
- Switch to OrbStack. OrbStack uses a different virtualization approach and is 2-5x faster than Docker Desktop for bind mounts.
- Use VirtioFS in Docker Desktop settings (Preferences → Resources → File sharing).
- Move node_modules out of the bind mount: use a named volume for
node_modulesso it lives inside the VM rather than being synced.
|
|
Git Credentials in Dev Containers
VS Code automatically forwards your SSH agent and git credential helper into the dev container. To ensure SSH agent forwarding works:
|
|
For HTTPS credentials, the VS Code dev containers extension handles forwarding your git credential manager automatically.
Secrets Management
Never put secrets in devcontainer.json. Use one of:
.env file (gitignored):
|
|
Reference it in docker-compose:
|
|
1Password CLI:
|
|
VS Code secrets: For Codespaces, use repository secrets in GitHub Settings → Secrets.
When to Rebuild vs Reopen
- Reopen in Container: use when you have changed workspace settings or environment variables that do not require rebuilding the image
- Rebuild Container: required when you change
devcontainer.json, theDockerfile, or installed features - Full rebuild (no cache): use when a Dockerfile layer has gone stale or you suspect a corrupted layer cache
Dockerfile Layer Ordering for Fast Rebuilds
Order Dockerfile layers from least-to-most frequently changing:
|
|
Never COPY . . in a dev container Dockerfile — your source code is mounted via volume, so copying it into the image is wasteful and causes unnecessary cache invalidation.
The “Works in Dev Container but Not CI” Problem
If you use the devcontainers/ci GitHub Action or build your CI container from the same Dockerfile, this class of problem disappears by definition. If you do not, the most common culprits are:
- CI using a different base image version (pin your base image tag:
python:3.12.3-slim-bookworm, notpython:3.12orpython:latest) - CI missing a secret or environment variable that you have locally
- File permissions differ between host bind-mount and CI’s fresh checkout
Putting It All Together
The journey from “works on my machine” to “works everywhere” has a clear path:
-
Start with mise. Add a
.mise.tomlto your project, pin your language versions. This takes five minutes and immediately solves the most common class of environment mismatch. Every developer runsmise installafter cloning. -
Add a devcontainer. Create
.devcontainer/devcontainer.json. Start with a pre-built image and add features as needed. This gets you IDE integration, system dependency isolation, and GitHub Codespaces compatibility. Most teams should stop here. -
Add docker-compose for services. If your project needs Postgres, Redis, or other services, wire them into the devcontainer via
dockerComposeFile. This eliminates the “install and configure Postgres locally” step entirely. -
Align CI with dev. Use
devcontainers/ciin GitHub Actions, or build your CI container from the same Dockerfile. Now a test failure in CI is a real failure, not a CI misconfiguration. -
Consider Nix when you need more. If you have a polyglot monorepo, need hermetic builds, or have developers on both Linux and macOS with complex native dependencies, invest in a
flake.nix. Usedevenv.shto lower the learning curve. The upfront investment pays off in environments that work identically for years without maintenance.
The goal is that a new developer’s first command after git clone is their last setup step. Everything else should be automatic. That is achievable today, with tools that are mature, well-documented, and increasingly the industry default. There is no excuse for a four-hour onboarding process in 2026.
Comments