devenv

Fast, declarative, reproducible, and composable developer environments powered by Nix. Define languages, packages, services, scripts, tasks, git hooks, containers, and more in devenv.nix.

Installation

# macOS
curl -sSfL https://artifacts.nixos.org/nix-installer | sh -s -- install
 
# Linux
sh <(curl -L https://nixos.org/nix/install) --daemon
 
# Windows WSL2
sh <(curl -L https://nixos.org/nix/install) --no-daemon
 
# Install devenv via nix profile
nix profile add nixpkgs#devenv
 
# Or via nix-env
nix-env --install --attr devenv -f https://github.com/NixOS/nixpkgs/tarball/nixpkgs-unstable
 
# NixOS (configuration.nix)
environment.systemPackages = [ pkgs.devenv ];
 
# home-manager
home.packages = [ pkgs.devenv ];

Initial Setup

devenv init    # creates devenv.nix, devenv.yaml, .gitignore
devenv shell   # enter the dev shell

Core Commands

devenv init                       # scaffold new project files
devenv shell                      # activate developer environment
devenv update                     # update and pin inputs in devenv.lock
devenv search <NAME>              # search Nixpkgs for packages
devenv up                         # start all processes
devenv processes down             # stop background processes
devenv tasks lists                # list all tasks
devenv tasks run <task>           # run a specific task
devenv test                       # build environment and run checks
devenv gc                         # delete unused environments (garbage collect)
devenv info                       # print environment information
devenv build <attr>               # build attributes
devenv eval <attr>                # evaluate attributes as JSON
devenv repl                       # launch interactive Nix REPL
devenv container build <name>     # build OCI container
devenv container copy <name>      # copy container to registry
devenv container run <name>       # run container with Docker
devenv inputs add <name> <url>    # add an input

devenv.nix Structure

The devenv.nix file is a Nix function receiving inputs (pkgs, lib, config, and any custom inputs):

{ pkgs, lib, config, inputs, ... }:
{
  # Packages available in the shell
  packages = [ pkgs.git pkgs.jq pkgs.httpie ];
 
  # Environment variables
  env.DATABASE_URL = "postgres://localhost/mydb";
  env.API_KEY = "dev-key";
 
  # Shell initialization script (consider using tasks instead for complex setups)
  enterShell = ''
    echo "Dev environment ready!"
    echo "Run 'devenv up' to start services."
  '';
}

Features

Packages

Add executables and libraries/headers to the environment:

packages = [
  pkgs.git
  pkgs.jq
  pkgs.libffi
  pkgs.zlib
];

Search for packages: devenv search <NAME>. Find which package owns a file with nix run github:nix-community/nix-index-database. Packages are pinned to the Nixpkgs version in devenv.lock.


Languages

Over 50 languages supported with toolchain, LSP, formatters, and version management:

languages.python = {
  enable = true;
  version = "3.11";
  venv.enable = true;
  venv.requirements = ./requirements.txt;
};
 
languages.rust.enable = true;
 
languages.nodejs = {
  enable = true;
  package = pkgs.nodejs_20;
};
 
languages.go.enable = true;
languages.ruby.enable = true;
languages.php.enable = true;

Environment Variables

env.GREET = "hello";
env.DATABASE_URL = "postgres://localhost/mydb";

Variables set here are available in the shell, scripts, and processes.


Scripts

Define reusable shell commands available as executables in the shell:

# Simple script
scripts.silly-example.exec = ''
  curl "https://httpbin.org/get?$1" | jq '.args'
'';
 
# Script with its own packages (doesn't pollute global env)
scripts.analyze-json = {
  exec = ''
    curl "https://httpbin.org/get?$1" | jq '.args'
  '';
  packages = [ pkgs.curl pkgs.jq ];
  description = "Fetch and analyze JSON";
};
 
# Pass arguments
scripts.foo.exec = ''
  npx @foo/cli "$@"
'';
 
# Use direct package paths
scripts.silly-example.exec = ''
  ${pkgs.curl}/bin/curl "https://httpbin.org/get?$1" | ${pkgs.jq}/bin/jq '.args'
'';
 
# Script in another language
scripts.python-hello = {
  exec = ''
    print("Hello, world!")
  '';
  package = config.languages.python.package;
  description = "hello world in Python";
};

Tasks

Tasks form dependencies between code, executed in parallel. Preferred over enterShell for complex operations.

# Basic task
tasks."myapp:hello" = {
  exec = ''echo "Hello, world!"'';
};
 
# Task that hooks into shell entry
tasks."myapp:migrate" = {
  exec = ''echo "Running migrations..."'';
  before = [ "devenv:enterShell" ];
};
 
# Task with status check (skip if status command returns 0 exit code)
tasks."myapp:install" = {
  exec = ''npm install'';
  status = ''test -d node_modules'';
  before = [ "devenv:enterShell" ];
};
 
# Task only runs when files change
tasks."myapp:build" = {
  exec = ''npm run build'';
  execIfModified = [ "src/**/*.ts" "package.json" ];
};

Built-in lifecycle events:

  • devenv:enterShell — runs before shell entry and process startup
  • devenv:enterTest — runs before tests, depends on devenv:enterShell

Task Attributes

AttributeDescription
execShell command to run (required)
packageAlternative runtime (e.g. config.languages.python.package)
statusCommand to check if exec should be skipped (exit 0 = skip)
execIfModifiedArray of glob patterns; only run when matched files change
cwdWorking directory for the task
inputStatic JSON input passed via $DEVENV_TASK_INPUT
beforeList of task/event names this task must run before
afterList of task/event names this task must run after
# Task with status check — skips if node_modules already exists
tasks."myapp:install" = {
  exec = "npm install";
  status = "test -d node_modules";
  before = [ "devenv:enterShell" ];
};
 
# Task that only reruns when sources change
tasks."myapp:build" = {
  exec = "npm run build";
  execIfModified = [ "src/**/*.ts" "package.json" ];
};
 
# Task in a monorepo subdirectory
tasks."myapp:migrate" = {
  exec = "alembic upgrade head";
  cwd = "${config.git.root}/backend";
  before = [ "devenv:enterShell" ];
};
 
# Task with static input
tasks."myapp:seed" = {
  exec = ''
    echo "$DEVENV_TASK_INPUT" | jq '.count'
  '';
  input = { count = 100; env = "dev"; };
};

Task I/O

VariableDescription
$DEVENV_TASK_INPUTJSON of the task’s input attribute
$DEVENV_TASKS_OUTPUTSJSON map of outputs from all dependent tasks
$DEVENV_TASK_OUTPUT_FILEPath to write this task’s JSON output
$DEVENV_TASK_EXPORTS_FILEPath to export env vars to dependents (name\0base64(value)\0)

Shell messages (v2.1+): Write to $DEVENV_TASK_OUTPUT_FILE with a devenv.messages key to display messages after shell load:

tasks."myapp:info" = {
  exec = ''
    echo '{"devenv":{"messages":["Dashboard: http://localhost:3000"]}}' > "$DEVENV_TASK_OUTPUT_FILE"
  '';
  before = [ "devenv:enterShell" ];
};

CLI Input Override

devenv tasks run myapp:mytask --input value=42 --input name=hello
devenv tasks run myapp:mytask --input-json '{"value": 42}'

CLI inputs merge with Nix-defined inputs; CLI takes precedence on conflicts.

Process Integration

All processes are automatically available as tasks under the devenv:processes: prefix:

tasks."app:seed" = {
  exec = "python seed.py";
  before = [ "devenv:processes:api" ];
};

Run a single process as a task: devenv tasks run devenv:processes:web-server

Run all tasks in a namespace: devenv tasks run myapp


Processes

Define long-running processes supervised by the devenv process manager:

processes.web.exec = "python -m http.server 8080";
 
processes.server = {
  exec = "myserver";
  cwd = "${config.git.root}/backend";
};
devenv up                          # start all processes
devenv processes down              # stop all processes
devenv processes wait --timeout 120  # wait for readiness (useful in CI)

Process Dependencies

processes = {
  database.exec = "postgres";
  api = {
    exec = "myapi";
    after = [ "devenv:processes:database" ];
  };
};

Dependency suffixes for processes:

SuffixMeaning
@startedWait for process to start executing
@readyWait for readiness probe (default)
@completedWait for process to terminate (soft)

Dependency suffixes for tasks:

SuffixMeaning
@startedWait for task to start
@succeededWait for zero exit code (default)
@completedWait for any termination (soft)

Restart Policies

processes.worker = {
  exec = "worker --queue jobs";
  restart = {
    on = "always";   # on_failure | always | never  (default: on_failure)
    max = 10;        # null = unlimited  (default: 5)
  };
};

Ready Probes

Probes let the manager detect when a process is operational so dependents can start.

# Exec probe
processes.database = {
  exec = "postgres -D $PGDATA";
  ready.exec = "pg_isready -d template1";
};
 
# HTTP probe
processes.api = {
  exec = "myserver";
  ready.http.get = {
    port = 8080;
    path = "/health";
    # host = "127.0.0.1";  (default)
    # scheme = "http";     (default)
  };
};
 
# Notify probe (systemd-style: process sends READY=1 to $NOTIFY_SOCKET)
processes.daemon = {
  exec = "mydaemon";
  ready.notify = true;
};

Probe timing options (apply to all probe types):

ready = {
  http.get = { port = 8080; path = "/health"; };
  initial_delay = 2;      # seconds before first probe (default: 0)
  period = 10;            # seconds between probes (default: 10)
  timeout = 1;            # probe timeout in seconds (default: 1)
  success_threshold = 1;  # consecutive successes needed (default: 1)
  failure_threshold = 3;  # failures before unhealthy (default: 3)
};

When ports are allocated without explicit probes, TCP connectivity is checked automatically.

File Watching

processes.backend = {
  exec = "cargo run";
  watch = {
    paths = [ ./src ];
    extensions = [ "rs" "toml" ];
    ignore = [ "target" "*.log" ];
  };
};

Socket Activation

The manager binds sockets before starting the process, enabling zero-downtime restarts and lazy startup:

processes.api = {
  exec = "myserver";
  listen = [
    { name = "http";  kind = "tcp";         address = "127.0.0.1:8080"; }
    { name = "admin"; kind = "unix_stream"; path = "$DEVENV_STATE/admin.sock"; }
  ];
};

Provided env vars: LISTEN_FDS, LISTEN_PID, LISTEN_FDNAMES. File descriptors start at 3 (systemd convention).

Watchdog Monitoring

processes.api = {
  exec = "myserver";
  ready.notify = true;
  watchdog = {
    usec = 30000000;      # 30 seconds in microseconds
    require_ready = true; # only enforce after READY=1 (default)
  };
};

Processes must periodically send WATCHDOG=1 to $NOTIFY_SOCKET or face termination.

Port Allocation

{ config, ... }: {
  processes.server = {
    ports.http.allocate = 8080;    # preferred port; auto-increments if taken
    ports.admin.allocate = 9000;
    exec = ''
      python -m http.server ${toString config.processes.server.ports.http.value}
    '';
  };
}

Access the resolved port via config.processes.<name>.ports.<port>.value.

Set strictPorts: true in devenv.yaml (or pass --strict-ports) to fail instead of auto-incrementing.


Services

Pre-configured integrations for databases, caches, message queues, and more:

services.postgres = {
  enable = true;
  listen_addresses = "127.0.0.1";
  initialDatabases = [{ name = "mydb"; }];
};
 
services.redis.enable = true;
services.mysql.enable = true;
services.mongodb.enable = true;
services.elasticsearch.enable = true;
services.rabbitmq.enable = true;
services.kafka.enable = true;
services.minio.enable = true;
services.caddy.enable = true;
services.nginx = {
  enable = true;
  httpConfig = ''
    server {
      listen 8080;
      location / { return 200 "Hello"; }
    }
  '';
};
services.vault.enable = true;

Services start via devenv up and are stopped with devenv processes down.


Git Hooks (pre-commit)

First-class integration with git-hooks.nix. Hooks auto-install at .git/hooks/pre-commit when entering the shell. Run devenv test to verify in CI.

git-hooks.hooks = {
  shellcheck.enable = true;
  black.enable = true;          # Python formatter
  rustfmt.enable = true;
  clippy = {
    enable = true;
    settings.allFeatures = true;
  };
  nixfmt.enable = true;
  ormolu.enable = true;         # Haskell formatter
  mdsh.enable = true;           # Markdown shell examples
};
 
# Custom hook
git-hooks.hooks.my-hook = {
  name = "My Custom Hook";
  entry = "./scripts/check.sh";
  files = "\\.(ts|tsx)$";
  types = [ "text" ];
  language = "system";
  pass_filenames = true;
};

The .pre-commit-config.yaml file is auto-generated as a symlink; add it to .gitignore.


Containers

Generate OCI containers from your development environment:

# Default containers: "shell" and "processes" are predefined
 
# Custom container
containers.myapp = {
  startupCommand = config.processes.web.exec;
};
 
# Container from build artifacts only
containers.prod = {
  copyToRoot = config.outputs.myapp;
};
 
# Conditional packages for containers
packages = if config.container.isBuilding
  then [ pkgs.minimal-tool ]
  else [ pkgs.full-dev-tool ];

Commands:

devenv container build shell        # build shell container
devenv container build processes    # build processes container
devenv container run processes      # run with Docker
devenv container --registry docker://ghcr.io/ copy myapp  # push to registry

macOS requires a remote Linux builder.


Tests

{ pkgs, lib, config, ... }: {
  packages = [ pkgs.ncdu ];
 
  enterTest = ''
    ncdu --version | grep "ncdu 2.2"
  '';
}

With processes:

services.nginx.enable = true;
 
enterTest = ''
  wait_for_port 8080
  curl -s localhost:8080 | grep "Hello"
'';

Conditional configuration during tests:

processes = {
  backend.exec = "cargo watch";
} // lib.optionalAttrs (!config.devenv.isTesting) {
  frontend.exec = "parcel serve";
};

Automatically detects and runs .test.sh if it exists. Helper: wait_for_port <port> <timeout>.

Run: devenv test — builds environment and runs all checks.


Files

Declaratively create configuration files symlinked into the project:

# JSON config
files."config.json".json = {
  database = { host = "localhost"; port = 5432; };
  features = [ "auth" "api" "ui" ];
};
 
# YAML
files."config.yaml".yaml = { foo = "bar"; };
 
# TOML
files."config.toml".toml = { key = "value"; };
 
# INI
files."config.ini".ini = { section.key = "value"; };
 
# Plain text
files."config.txt".text = "hello world";
 
# Executable script
files."scripts/build.sh" = {
  text = ''#!/bin/bash\nnpm run build'';
  executable = true;
};

Files are auto-generated when entering the shell, ensuring consistent configs across the team.


Binary Caching (Cachix)

# Pull from a cache
cachix.pull = [ "mycache" ];
 
# Push to a cache (typically in devenv.local.nix for CI)
cachix.push = "mycache";
 
# Disable Cachix integration
cachix.enable = false;

Set CACHIX_AUTH_TOKEN environment variable for push access. devenv.cachix.org is included by default.


Profiles

Organize variations of your environment with selective activation:

profiles.backend = {
  services.postgres.enable = true;
  languages.go.enable = true;
};
 
profiles.frontend = {
  languages.nodejs.enable = true;
};
 
# Auto-activate by hostname
profiles.hostname.my-macbook = {
  packages = [ pkgs.darwin-tools ];
};
 
# Auto-activate by username
profiles.user.alice = {
  env.EDITOR = "nvim";
};
 
# Profile extension
profiles.full = {
  extends = [ "backend" "frontend" ];
};

Activation:

devenv --profile backend shell
devenv --profile backend --profile testing shell

Priority (lowest to highest): base config → hostname profiles → user profiles → manual --profile flags.


Inputs

Reference external Nix code while maintaining reproducibility. Managed in devenv.yaml:

inputs:
  nixpkgs:
    url: github:cachix/devenv-nixpkgs/rolling
  nixpkgs-unstable:
    url: github:NixOS/nixpkgs/nixpkgs-unstable
  mylib:
    url: github:myorg/mylib

Use in devenv.nix:

{ pkgs, inputs, ... }:
{
  packages = [ inputs.mylib.packages.${pkgs.system}.mytool ];
}
devenv inputs add <name> <url>               # add input
devenv inputs add <name> <url> --follows nixpkgs  # follow another input
devenv update                                 # update devenv.lock

Supported URI formats: github:, gitlab:, git+ssh://, git+https://, git+file://, hg+*, sourcehut:, tarball+https://, path:, file:///.

Special inputs always available in devenv.nix: pkgs, lib, config.


Outputs

Define Nix derivations for distribution:

outputs = {
  rust-app = config.languages.rust.import ./rust-app {};
  python-app = config.languages.python.import ./python-app {};
};
devenv build                        # build all outputs
devenv build outputs.my-rust-app    # build specific output

Languages use ecosystem-appropriate tools: Rust uses crate2nix, Python uses uv2nix.


Overlays

Customize the pkgs package set:

overlays = [
  # Patch an existing package
  (final: prev: {
    mypackage = prev.mypackage.overrideAttrs (old: {
      patches = old.patches ++ [ ./my-patch.patch ];
    });
  })
 
  # Use a specific Node.js version
  (final: prev: {
    nodejs = prev.nodejs-18_x;
  })
 
  # Add a custom package from a local derivation
  (final: prev: {
    mytool = final.callPackage ./nix/mytool.nix {};
  })
 
  # Import from nixpkgs-unstable (add as input first)
  (final: prev: {
    somepackage = (import inputs.nixpkgs-unstable { system = final.system; }).somepackage;
  })
];

REPL

devenv repl

Provides access to config (fully resolved devenv config), pkgs (nixpkgs package set), and all inputs:

nix-repl> config.languages.python.enable
true
nix-repl> pkgs.hello.version
"2.12.1"

Garbage Collection

devenv gc

Each shell activation creates a timestamped symlink in $DEVENV_HOME/gc/. GC cleans dangling symlinks, resolves remaining ones, and passes them to Nix GC. Only removes store paths unreferenced by any active environment.


Files Reference

FilePurpose
devenv.nixMain environment configuration
devenv.local.nixLocal overrides, excluded from git
devenv.yamlInputs, imports, and global settings
devenv.local.yamlLocal overrides for devenv.yaml (v1.10+)
devenv.lockPinned input versions (commit this)
.envrcdirenv integration (auto-activation)
.devcontainer.jsonAuto-generated for Codespaces

Environment Variables

VariableValue
$DEVENV_ROOTProject root containing devenv.nix
$DEVENV_DOTFILE$DEVENV_ROOT/.devenv
$DEVENV_STATE$DEVENV_DOTFILE/state
$DEVENV_RUNTIMETemp dir for sockets and runtime files
$DEVENV_PROFILENix store path of the environment profile
$DEVENV_HOME~/.local/share/devenv

devenv.yaml Options Reference

# Environment cleanup on shell entry
clean:
  enabled: false
  keep: [] # env vars to preserve when cleaning
 
# Import other devenv.nix / devenv.yaml files
imports:
  - ./frontend
  - ./backend
 
# Relax hermeticity
impure: false
 
# Auto-reload shell when files change
reload: true
 
# Error on port conflicts instead of auto-allocating
strictPorts: false
 
# Default profile to activate
profile: ""
 
# Inputs
inputs:
  nixpkgs:
    url: github:cachix/devenv-nixpkgs/rolling
    flake: true
    follows: ""
    overlays: []
 
# Nixpkgs settings
nixpkgs:
  allowUnfree: false
  allowBroken: false
  permittedUnfreePackages: []
  cudaSupport: false
  rocmSupport: false
 
# Secrets
secretspec:
  enable: false

Flake Integration

# flake.nix
{
  inputs.devenv.url = "github:cachix/devenv";
 
  outputs = { devenv, nixpkgs, ... }@inputs: {
    devShells.x86_64-linux.default = devenv.lib.mkShell {
      inherit inputs;
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
      modules = [ ./devenv.nix ];
    };
  };
}