All Guides

15 / 25

Local-First Dev Environments

Core Questions

  • How do you set up a local environment that agents can also use?
  • What does the inner loop look like when agents are involved?
  • How do you keep local parity with CI and remote runtimes?

Remote-first sounds elegant until you hit your 50th round-trip waiting for a container to spin up. The inner loop — write, run, verify — needs to stay fast. Local-first development with remote-available fallback gives you the speed of your laptop and the reproducibility of containerized runtimes. The key is making local environments identical to what agents use, so work transfers seamlessly.

Why local still matters

The remote-everything approach has a hidden cost: latency. Every file save, every test run, every syntax check — all of it adds up. When your inner loop slows from 2 seconds to 20, you don't just lose time. You lose flow state.

Inner Loop Latency Comparison

Local native

~50-200ms round trip. Edit, save, see results almost instantly. Maximum flow state.

Local container

~200-500ms with volume mounts. Small overhead but still feels instant. Good tradeoff for reproducibility.

Remote dev server

~500ms-2s depending on network. Workable for longer tasks, frustrating for rapid iteration.

Remote ephemeral runtime

~5-30s cold start, then remote latency. Great for agents, painful for human inner loops.

The goal isn't to eliminate remote environments — it's to use them strategically. Humans need fast loops for exploration. Agents need reproducible environments for execution. A good setup supports both.

Making local environments agent-compatible

The trap is building a local environment that works perfectly for you but breaks when an agent tries to use it. If your setup depends on your shell aliases, your PATH modifications, or software you installed six months ago, it's not agent-compatible.

Principle

If it's not in the repo, it doesn't exist

Every dependency, every tool, every configuration should be declaratively specified in the repository. The repo is the source of truth, not your laptop.

Three patterns dominate here: devcontainers, Nix flakes, and Dockerfiles. Each has tradeoffs, but all share the same core property — they declare the environment in code.

Environment Declaration Options

devcontainer.json

VS Code native, good tooling support. Runs in Docker locally or in Codespaces remotely. Easy adoption but less hermetic than Nix.

Nix flakes

Maximum reproducibility. Same binaries everywhere. Steeper learning curve but no "works on my machine" problems. Runs natively, no container overhead.

Dockerfile

Most portable, widest adoption. Works everywhere Docker runs. May need careful layer caching to keep build times reasonable.

Devcontainer patterns that work

Devcontainers are the most approachable option for teams already using VS Code or GitHub Codespaces. A well-configured devcontainer gives you local container development with minimal friction.

// .devcontainer/devcontainer.json
{
  "name": "project-dev",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  
  // Features add tooling declaratively
  "features": {
    "ghcr.io/devcontainers/features/node:1": {
      "version": "20"
    },
    "ghcr.io/devcontainers/features/docker-in-docker:2": {},
    "ghcr.io/devcontainers/features/github-cli:1": {}
  },
  
  // Run on container create, not every start
  "postCreateCommand": "npm ci",
  
  // Mount local files for fast inner loop
  "mounts": [
    "source=${localWorkspaceFolder},target=/workspace,type=bind"
  ],
  
  // VS Code settings that should be consistent
  "customizations": {
    "vscode": {
      "settings": {
        "editor.formatOnSave": true,
        "typescript.preferences.importModuleSpecifier": "relative"
      }
    }
  }
}

Key Devcontainer Principles

Use features over shell scripts

Devcontainer features are versioned, tested, and cached. Shell scripts in postCreateCommand are fragile and slow. Prefer features when available.

Separate create vs start

postCreateCommand runs once when the container is built. postStartCommand runs every time. Put slow setup (npm ci, build) in create; put state refresh in start.

Include Docker-in-Docker

If your project runs containers (tests, services, databases), you need Docker inside the devcontainer. The docker-in-docker feature handles this cleanly.

Nix flakes for hermetic environments

Nix takes a different approach: instead of running inside a container, it manages your environment natively. A Nix flake declares exactly which binaries you need, and Nix ensures you get identical versions everywhere — your laptop, your coworker's laptop, CI, production.

# flake.nix
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
    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; [
            nodejs_20
            nodePackages.pnpm
            docker
            gh
            jq
          ];
          
          shellHook = ''
            echo "Dev environment ready"
            export NODE_ENV=development
          '';
        };
      });
}

Enter the environment with nix develop. Every tool is now available, at exactly the versions specified. No container overhead, no volume mount performance issues, no Docker daemon required.

Nix learning curve is real

Nix offers the strongest reproducibility guarantees, but the language and ecosystem take time to learn. Start with devcontainers if you need quick adoption. Migrate to Nix when reproducibility problems become painful enough to justify the investment.

Filesystem semantics across environments

One of the trickiest aspects of local-remote parity is filesystem behavior. Your local filesystem might be case-insensitive (macOS), have different symlink handling, or allow file paths that break on Linux. These differences cause subtle bugs that only appear when agents run your code in containers.

Common Filesystem Gotchas

Case sensitivity

macOS is case-insensitive by default. File.tsx and file.tsx are the same file locally but different files in Linux containers. Enforce lowercase conventions or use a case-sensitive volume.

Path lengths

Windows has path length limits. node_modules nesting can exceed them. Use pnpm or set Windows long path support if you need Windows compatibility.

Line endings

Git can auto-convert line endings. If not configured consistently, the same file looks different in different environments. Set core.autocrlf in your repo's .gitattributes.

Volume mount performance

Docker volume mounts on macOS can be slow for large directories (node_modules). Use named volumes for dependencies or enable VirtioFS in Docker Desktop.

# .gitattributes - Enforce consistent behavior
* text=auto eol=lf
*.sh text eol=lf
*.bat text eol=crlf
*.png binary
*.jpg binary

# Prevent case sensitivity issues
*.tsx linguist-detectable
*.ts linguist-detectable

Integrating agents into your inner loop

The local environment isn't just for you — it's where agents run when you're pairing with them. An agent-compatible local setup means the agent can execute the same commands you do, with the same results.

Agent-Compatible Local Environment Checklist

All commands in package.json scripts

Don't rely on global installs or shell functions. Everything an agent needs should be runnable via npm run <script>.

Documented bootstrap command

One command to go from clone to running. Usually npm ci && npm run dev. Put it in the README, put it in AGENTS.md.

No GUI dependencies for core tasks

Agents can't click buttons. Tests, builds, and dev servers should all work from the command line.

Secrets via environment variables

Don't read from files agents can't access. Use .env.local (gitignored) for local development, injected vars for CI and agent runs.

Health check endpoints

Agents need to verify services are running. A /health endpoint that returns 200 when ready saves polling and guessing.

Docker-in-Docker done right

Many projects need to run containers as part of development — databases, test fixtures, dependent services. If your dev environment is already containerized (devcontainer), you need Docker-in-Docker. But there are multiple ways to do this, with different security and performance tradeoffs.

Docker-in-Docker Options

Docker socket mount

Mount /var/run/docker.sock into the container. Fast and simple but gives the container full control over the host's Docker. Security risk if you don't trust what runs inside.

DinD (Docker-in-Docker)

Run a separate Docker daemon inside the container. Better isolation but requires privileged mode. The devcontainer docker-in-docker feature uses this.

Sysbox runtime

A specialized container runtime that enables Docker-in-Docker without privileged mode. Best security but requires Sysbox on the host.

# docker-compose.yml for local services
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: app_dev
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dev"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

Keep service definitions in the repo. docker compose up -d should start everything an agent needs. Healthchecks ensure services are actually ready before tests run.

Maintaining parity with CI

The classic failure mode: it works locally, fails in CI. Or worse, it works in CI but fails when an agent runs it. Parity isn't automatic — it requires intentional design.

Parity Strategies

Same base image everywhere

Your devcontainer, CI runner, and agent runtime should use the same base image. Pin the version explicitly — node:20.10.0 not node:20.

Lockfiles committed

package-lock.json, pnpm-lock.yaml, flake.lock — these ensure identical dependencies. CI should fail if lockfiles are out of sync with package.json.

CI runs in container mode

GitHub Actions can run jobs in containers. Use the same image as local development. This eliminates "works on GitHub"s runners" drift.

Test CI locally

Tools like act run GitHub Actions locally. When you can reproduce CI locally, you can debug CI failures without push-and-pray cycles.

# .github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      # Same image as devcontainer
      image: mcr.microsoft.com/devcontainers/base:ubuntu
      
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          cache: 'npm'
          
      - run: npm ci
      - run: npm test
      - run: npm run build

What goes wrong

Snowflake local environments

Everyone's laptop has different tools installed. Some have Node 18, some have 20. Some have an old version of Docker. Code that works for one developer breaks for another.

Fix: Enforce environment through devcontainers or Nix. If it's not in the environment definition, it shouldn't be required. Run CI in the same container to catch drift early.

Agent can't run the dev server

The developer runs npm run dev and opens localhost:3000 in a browser. The agent runs the same command but has no way to verify the server started or access it.

Fix: Add a health check endpoint. Have the dev server script wait for health before exiting. Give agents access to localhost via tunnels or network bridging.

Volume mount performance kills iteration

Running in a container on macOS, every file operation is slow. File watching triggers late, builds take forever, the inner loop is painful.

Fix: Use named volumes for heavy directories like node_modules. Enable VirtioFS in Docker Desktop. Or use Nix for native performance without container overhead.

Implicit dependencies on host services

The app connects to a PostgreSQL database the developer installed globally. Works locally, fails everywhere else.

Fix: All services in docker-compose.yml. No dependencies on host-installed software except what's in the environment definition.

Summary

  • Local-first development keeps your inner loop fast — optimize for human iteration speed while maintaining agent compatibility
  • Declare your environment in code (devcontainer, Nix flake, or Dockerfile) so it's reproducible everywhere
  • Watch for filesystem differences between macOS, Linux, and containers — case sensitivity and line endings cause subtle bugs
  • Make all commands runnable via npm scripts — agents can't use your shell aliases or global installs
  • Use the same container image locally and in CI to eliminate environment drift

Stay updated

Get notified when we publish new guides or make major updates.
(We won't email you for little stuff like typos — only for new content or significant changes.)

Found this useful? Share it with your team.