LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Local Development Environments: Dev Containers, Nix Flakes, and Reproducible Setups

devcontainersnixdockerdevelopmentreproducibilityvscodedevextoolingdevops
Contents

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:

1
2
3
4
5
6
7
8
git clone git@github.com:yourorg/yourproject.git
cd yourproject
# then either:
code .   # opens VS Code, prompts to reopen in container → done
# or:
nix develop   # drops into a shell with everything installed → done
# or:
mise install && make dev   # installs tool versions, starts services → done

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "name": "My Project",
  "image": "mcr.microsoft.com/devcontainers/python:3.12",
  "features": {
    "ghcr.io/devcontainers/features/docker-in-docker:2": {},
    "ghcr.io/devcontainers/features/git:1": {}
  },
  "postCreateCommand": "pip install -e '.[dev]'",
  "customizations": {
    "vscode": {
      "extensions": ["ms-python.python", "charliermarsh.ruff"],
      "settings": {
        "python.defaultInterpreterPath": "/usr/local/bin/python"
      }
    }
  },
  "forwardPorts": [8000, 5432],
  "remoteUser": "vscode"
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  "features": {
    "ghcr.io/devcontainers/features/node:1": {
      "version": "20"
    },
    "ghcr.io/devcontainers/features/rust:1": {
      "version": "1.76"
    },
    "ghcr.io/devcontainers/features/go:1": {
      "version": "1.22"
    },
    "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {
      "version": "1.29",
      "helm": "3.14"
    },
    "ghcr.io/devcontainers/features/terraform:1": {
      "version": "1.7.4"
    },
    "ghcr.io/devcontainers/features/aws-cli:1": {},
    "ghcr.io/devcontainers/features/docker-in-docker:2": {
      "moby": true
    }
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04

# Install system dependencies not available as features
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev \
    libvips-dev \
    wkhtmltopdf \
    redis-tools \
    && rm -rf /var/lib/apt/lists/*

# Install a specific version of a tool via its own installer
RUN curl -fsSL https://install.python-poetry.org | python3 - --version 1.8.2

# Configure the non-root user's environment
USER vscode
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/vscode/.bashrc

.devcontainer/devcontainer.json referencing the Dockerfile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "name": "My Project",
  "build": {
    "dockerfile": "Dockerfile",
    "context": ".."
  },
  "features": {
    "ghcr.io/devcontainers/features/python:1": {
      "version": "3.12"
    }
  },
  "postCreateCommand": "poetry install",
  "remoteUser": "vscode"
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
version: "3.8"

services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile
    volumes:
      - ../..:/workspaces:cached
    command: sleep infinity
    environment:
      DATABASE_URL: postgresql://postgres:devpassword@db:5432/myapp_dev
      REDIS_URL: redis://cache:6379/0
    depends_on:
      - db
      - cache

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: myapp_dev
    volumes:
      - postgres-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    restart: unless-stopped
    ports:
      - "6379:6379"

volumes:
  postgres-data:

.devcontainer/devcontainer.json:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
  "name": "My Project",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
  "features": {},
  "postCreateCommand": "pip install -e '.[dev]' && alembic upgrade head",
  "customizations": {
    "vscode": {
      "extensions": [
        "ms-python.python",
        "charliermarsh.ruff",
        "ms-azuretools.vscode-docker",
        "mtxr.sqltools",
        "mtxr.sqltools-driver-pg"
      ],
      "settings": {
        "python.defaultInterpreterPath": "/usr/local/bin/python",
        "sqltools.connections": [
          {
            "name": "Dev DB",
            "driver": "PostgreSQL",
            "server": "db",
            "port": 5432,
            "database": "myapp_dev",
            "username": "postgres",
            "password": "devpassword"
          }
        ]
      }
    }
  },
  "forwardPorts": [8000, 5432, 6379],
  "remoteUser": "vscode"
}

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:

1
2
3
4
5
6
7
{
  "initializeCommand": "docker pull mcr.microsoft.com/devcontainers/python:3.12",
  "onCreateCommand": "git lfs pull",
  "updateContentCommand": "pip install -e '.[dev]'",
  "postCreateCommand": "alembic upgrade head && python manage.py seed_dev_data",
  "postStartCommand": "git fetch --all"
}

The execution order:

  1. initializeCommand — runs on the host before the container starts; useful for pulling images or checking host prerequisites
  2. onCreateCommand — runs once when the container is first created; for one-time setup steps
  3. updateContentCommand — runs when the repository content changes (e.g., after a git pull); ideal for pip install / npm ci to keep deps in sync
  4. postCreateCommand — runs after creation; for database migrations, seeding, etc.
  5. 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:

1
2
3
4
5
6
{
  "postCreateCommand": {
    "install-deps": "pip install -e '.[dev]'",
    "run-migrations": "alembic upgrade head"
  }
}

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 VirtioFS file 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.lock file 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
  description = "My project dev environment";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in {
        devShells.default = pkgs.mkShell {
          buildInputs = with pkgs; [
            # Language runtimes
            python312
            nodejs_20
            go_1_22

            # Infrastructure tools
            terraform
            kubectl
            kubernetes-helm
            ansible

            # Databases (CLI clients)
            postgresql_16
            redis

            # Development tools
            git
            ripgrep
            jq
            yq-go
            just
            pre-commit
          ];

          shellHook = ''
            echo "Dev environment ready — $(python --version), $(node --version), $(go version)"
            export DATABASE_URL="postgresql://localhost/myapp_dev"
            export REDIS_URL="redis://localhost:6379/0"
            export PATH="$PWD/.venv/bin:$PATH"
          '';
        };
      });
}

Enter the environment with:

1
2
3
nix develop                     # opens a bash subshell with all tools
nix develop --command zsh       # uses zsh instead of bash
nix develop --command $SHELL    # uses your current shell

To use a specific output (useful for per-environment shells):

1
2
nix develop .#ci          # uses devShells.ci output
nix develop .#docs        # uses devShells.docs output

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:

1
2
3
4
5
6
7
# Install (on macOS via Homebrew, or via Nix itself)
brew install direnv
# or
nix-env -iA nixpkgs.direnv

# Add to ~/.zshrc
eval "$(direnv hook zsh)"

Create .envrc in your project root:

1
use flake

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:

1
2
3
4
5
# In your flake.nix inputs:
nix-direnv.url = "github:nix-community/nix-direnv";

# .envrc:
# use flake

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
devShells.default = pkgs.mkShell {
  buildInputs = [
    (pkgs.python312.withPackages (ps: with ps; [
      requests
      pytest
      black
      mypy
    ]))
  ];
};

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:

1
2
3
4
5
6
7
buildInputs = with pkgs; [
  go_1_22
  gopls
  golangci-lint
  delve        # debugger
  air          # live reload
];

Rust (using the fenix overlay for latest toolchains):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
inputs.fenix.url = "github:nix-community/fenix";

# In outputs:
let
  fenix = inputs.fenix.packages.${system};
in {
  devShells.default = pkgs.mkShell {
    buildInputs = [
      fenix.stable.toolchain
      pkgs.cargo-watch
      pkgs.rust-analyzer
    ];
  };
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
{ pkgs, ... }: {
  packages = with pkgs; [
    git
    ripgrep
    jq
    just
  ];

  languages.python = {
    enable = true;
    version = "3.12";
  };

  languages.javascript = {
    enable = true;
    package = pkgs.nodejs_20;
  };

  services.postgres = {
    enable = true;
    package = pkgs.postgresql_16;
    initialScript = ''
      CREATE DATABASE myapp_dev;
      CREATE USER myapp WITH PASSWORD 'devpassword';
      GRANT ALL PRIVILEGES ON DATABASE myapp_dev TO myapp;
    '';
  };

  services.redis = {
    enable = true;
  };

  env.DATABASE_URL = "postgresql://myapp:devpassword@localhost/myapp_dev";
  env.REDIS_URL = "redis://localhost:6379/0";

  scripts.dev.exec = "uvicorn app.main:app --reload --port 8000";

  enterShell = ''
    echo "Dev environment ready"
    echo "Run 'devenv up' to start services"
  '';
}

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

1
2
3
4
5
6
7
8
curl https://mise.run | sh
# or via Homebrew
brew install mise

# Add to ~/.zshrc
eval "$(mise activate zsh)"
# or for bash, ~/.bashrc
eval "$(mise activate bash)"

.mise.toml

This is the file you commit to your repository. Every developer who runs mise install gets exactly these versions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[tools]
python = "3.12.3"
node = "20.11.0"
go = "1.22.1"
terraform = "1.7.4"
kubectl = "1.29.2"
helm = "3.14.2"
pnpm = "8.15.4"
poetry = "1.8.2"

[env]
DATABASE_URL = "postgresql://localhost/myapp_dev"
REDIS_URL = "redis://localhost:6379/0"
NODE_ENV = "development"

[settings]
experimental = true

The [env] section injects environment variables automatically when you are inside the project directory (no direnv required, though they work together).

Key Commands

1
2
3
4
5
6
7
8
mise install          # install all tools in .mise.toml
mise install python   # install just Python
mise use python@3.12  # add to .mise.toml and install
mise use -g node@20   # set global default version
mise list             # show installed versions
mise current          # show active versions in current directory
mise run dev          # run a task (if [tasks] defined)
mise exec -- pytest   # run a command in the mise environment

mise Tasks

mise supports a [tasks] section that replaces Makefile patterns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[tasks.dev]
run = "uvicorn app.main:app --reload"

[tasks.test]
run = "pytest --cov=app tests/"

[tasks.migrate]
run = "alembic upgrade head"

[tasks.fmt]
run = ["black .", "ruff check --fix ."]

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
FROM mcr.microsoft.com/devcontainers/python:3.12-bookworm

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev \
    postgresql-client \
    redis-tools \
    && rm -rf /var/lib/apt/lists/*

# Install mise inside the container for consistency
RUN curl https://mise.run | sh
USER vscode
RUN echo 'eval "$(~/.local/bin/mise activate bash)"' >> /home/vscode/.bashrc

.devcontainer/docker-compose.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
version: "3.8"
services:
  app:
    build:
      context: ../
      dockerfile: .devcontainer/Dockerfile
    volumes:
      - ../:/workspace:cached
      - vscode-extensions:/home/vscode/.vscode-server/extensions
    command: sleep infinity
    environment:
      DATABASE_URL: postgresql://postgres:devpassword@db:5432/myapp_dev
      REDIS_URL: redis://cache:6379/0
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: myapp_dev
    volumes:
      - postgres-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 10

  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres-data:
  vscode-extensions:

Note the vscode-extensions volume — this persists VS Code extension installations across container rebuilds, dramatically speeding up the “rebuild container” workflow.

.devcontainer/devcontainer.json:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
  "name": "MyProject",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "postCreateCommand": "pip install -e '.[dev]' && alembic upgrade head",
  "postStartCommand": "git fetch --all --quiet",
  "customizations": {
    "vscode": {
      "extensions": [
        "ms-python.python",
        "ms-python.debugpy",
        "charliermarsh.ruff",
        "ms-python.mypy-type-checker",
        "mtxr.sqltools",
        "mtxr.sqltools-driver-pg",
        "ms-azuretools.vscode-docker",
        "eamodio.gitlens",
        "EditorConfig.EditorConfig"
      ],
      "settings": {
        "python.defaultInterpreterPath": "/usr/local/bin/python",
        "editor.formatOnSave": true,
        "[python]": {
          "editor.defaultFormatter": "charliermarsh.ruff"
        }
      }
    }
  },
  "forwardPorts": [8000, 5432, 6379],
  "portsAttributes": {
    "8000": {"label": "App", "onAutoForward": "notify"},
    "5432": {"label": "PostgreSQL", "onAutoForward": "silent"},
    "6379": {"label": "Redis", "onAutoForward": "silent"}
  },
  "remoteUser": "vscode"
}

.mise.toml (committed to repo):

1
2
3
4
5
6
[tools]
python = "3.12.3"

[env]
DATABASE_URL = "postgresql://postgres:devpassword@localhost/myapp_dev"
REDIS_URL = "redis://localhost:6379/0"

The Nix-First Approach

For teams already invested in Nix, the entire stack lives in flake.nix. No Docker required for local development:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
  description = "MyProject dev environment";
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
    devenv.url = "github:cachix/devenv";
  };

  outputs = { self, nixpkgs, devenv, ... } @ inputs:
    let
      systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
      forAllSystems = nixpkgs.lib.genAttrs systems;
    in {
      devShells = forAllSystems (system:
        let pkgs = nixpkgs.legacyPackages.${system};
        in {
          default = devenv.lib.mkShell {
            inherit inputs pkgs;
            modules = [{
              packages = with pkgs; [ git ripgrep jq just pre-commit ];
              languages.python = { enable = true; version = "3.12"; };
              services.postgres = {
                enable = true;
                package = pkgs.postgresql_16;
                initialDatabases = [{ name = "myapp_dev"; }];
              };
              services.redis.enable = true;
              env.DATABASE_URL = "postgresql://localhost/myapp_dev";
              enterShell = "echo 'Run: devenv up'";
            }];
          };
        });
    };
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tests in dev container
        uses: devcontainers/ci@v0.3
        with:
          runCmd: |
            pip install -e '.[dev]'
            alembic upgrade head
            pytest --cov=app tests/

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:

1
2
3
4
5
6
7
8
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: myregistry/myproject-dev:latest
    steps:
      - uses: actions/checkout@v4
      - run: pytest tests/

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: cachix/install-nix-action@v26
        with:
          nix_path: nixpkgs=channel:nixos-24.11
      - uses: cachix/cachix-action@v14
        with:
          name: myproject
          authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
      - run: nix develop --command make test

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

1
2
3
4
5
6
7
8
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: jdx/mise-action@v2
      - run: mise install
      - run: mise exec -- pytest tests/

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Stage 1: base — system deps shared by all stages
FROM python:3.12-slim-bookworm AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Stage 2: dev — additional dev tooling on top of base
FROM base AS dev
RUN apt-get update && apt-get install -y --no-install-recommends \
    git \
    curl \
    postgresql-client \
    && rm -rf /var/lib/apt/lists/*
RUN pip install debugpy pytest pytest-cov ruff mypy

# Stage 3: builder — builds the application
FROM base AS builder
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir build && python -m build

# Stage 4: production — minimal final image
FROM base AS production
WORKDIR /app
COPY --from=builder /app/dist/*.whl .
RUN pip install --no-cache-dir *.whl && rm *.whl
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Reference the dev stage in .devcontainer/devcontainer.json:

1
2
3
4
5
6
{
  "build": {
    "dockerfile": "../Dockerfile",
    "target": "dev"
  }
}

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 ~/:

1
2
cd ~/dotfiles
stow .   # symlinks everything into $HOME

chezmoi is more powerful: it supports templates (machine-specific config), encrypted secrets, and a proper apply/diff workflow:

1
2
chezmoi init --apply https://github.com/yourname/dotfiles
chezmoi update   # pull changes and apply

Project-Level Configuration

Commit these files to every repository:

.editorconfig — enforces consistent indentation and line endings across editors:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.py]
indent_size = 4

[Makefile]
indent_style = tab

.envrc — environment variables loaded by direnv, never .env in git:

1
2
3
4
5
6
# .envrc — safe to commit, contains no secrets
export APP_ENV=development
export LOG_LEVEL=debug
use flake   # if using Nix
# or
mise exec -- true   # activate mise environment

.env.example — documents what secrets are needed, without values:

1
2
3
4
5
# Copy to .env and fill in values. Never commit .env.
DATABASE_URL=postgresql://user:password@localhost/dbname
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
STRIPE_SECRET_KEY=sk_test_...

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.12 base 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "name": "Next.js App",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:20",
  "postCreateCommand": "npm ci",
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "bradlc.vscode-tailwindcss",
        "ms-vscode.vscode-typescript-next"
      ],
      "settings": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      }
    }
  },
  "forwardPorts": [3000],
  "portsAttributes": {
    "3000": {"label": "Next.js", "onAutoForward": "openBrowser"}
  }
}

.mise.toml:

1
2
3
[tools]
node = "20.11.0"
pnpm = "8.15.4"

Go Project

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "name": "Go Service",
  "image": "mcr.microsoft.com/devcontainers/go:1.22",
  "features": {
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },
  "postCreateCommand": "go mod download && go install github.com/air-verse/air@latest",
  "customizations": {
    "vscode": {
      "extensions": [
        "golang.go",
        "ms-vscode.makefile-tools"
      ],
      "settings": {
        "go.useLanguageServer": true,
        "go.lintTool": "golangci-lint",
        "[go]": {"editor.formatOnSave": true}
      }
    }
  },
  "forwardPorts": [8080]
}

Flake alternative for Go, if your team uses Nix:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
devShells.default = pkgs.mkShell {
  buildInputs = with pkgs; [
    go_1_22
    gopls
    golangci-lint
    delve
    air
    buf          # protobuf
    grpcurl
  ];
  shellHook = ''
    export GOPATH="$PWD/.gopath"
    export PATH="$GOPATH/bin:$PATH"
  '';
};

Infrastructure / DevOps Repo (Terraform + kubectl + Ansible)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
  "name": "Infrastructure",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu-22.04",
  "features": {
    "ghcr.io/devcontainers/features/terraform:1": {"version": "1.7.4"},
    "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {
      "version": "1.29.2",
      "helm": "3.14.2",
      "minikube": "none"
    },
    "ghcr.io/devcontainers/features/aws-cli:1": {},
    "ghcr.io/devcontainers/features/azure-cli:1": {},
    "ghcr.io/devcontainers/features/python:1": {"version": "3.12"}
  },
  "postCreateCommand": "pip install ansible ansible-lint && pre-commit install",
  "customizations": {
    "vscode": {
      "extensions": [
        "hashicorp.terraform",
        "redhat.ansible",
        "redhat.vscode-yaml",
        "ms-kubernetes-tools.vscode-kubernetes-tools"
      ]
    }
  },
  "mounts": [
    "source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached",
    "source=${localEnv:HOME}/.kube,target=/home/vscode/.kube,type=bind,consistency=cached",
    "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached,readonly"
  ],
  "remoteUser": "vscode"
}

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 the vscode user in your Dockerfile
  • Set your Dockerfile’s user to match the expected UID: RUN usermod -u 1000 vscode
  • Use :cached on 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_modules so it lives inside the VM rather than being synced.
1
2
3
4
5
6
# In docker-compose.yml
volumes:
  - ..:/workspace:cached
  - node_modules:/workspace/node_modules   # stays in VM, fast
volumes:
  node_modules:

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:

1
2
3
4
5
# On macOS, add to ~/.ssh/config
Host *
  AddKeysToAgent yes
  UseKeychain yes
  IdentityFile ~/.ssh/id_ed25519

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):

1
2
# .gitignore
.env

Reference it in docker-compose:

1
2
3
services:
  app:
    env_file: ../.env

1Password CLI:

1
2
# .envrc
export DATABASE_PASSWORD="$(op read op://Personal/myapp-dev/password)"

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, the Dockerfile, 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:

1
2
3
4
5
6
7
8
# Changes rarely — system packages
RUN apt-get install -y libpq-dev libvips-dev

# Changes occasionally — dependency manifest
COPY pyproject.toml .
RUN pip install --no-cache-dir .[dev]

# Changes frequently — application code (handled by volume mount, not COPY)

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, not python:3.12 or python: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:

  1. Start with mise. Add a .mise.toml to your project, pin your language versions. This takes five minutes and immediately solves the most common class of environment mismatch. Every developer runs mise install after cloning.

  2. 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.

  3. 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.

  4. Align CI with dev. Use devcontainers/ci in 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.

  5. 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. Use devenv.sh to 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