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 shellCore 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 inputdevenv.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 startupdevenv:enterTest— runs before tests, depends ondevenv:enterShell
Task Attributes
| Attribute | Description |
|---|---|
exec | Shell command to run (required) |
package | Alternative runtime (e.g. config.languages.python.package) |
status | Command to check if exec should be skipped (exit 0 = skip) |
execIfModified | Array of glob patterns; only run when matched files change |
cwd | Working directory for the task |
input | Static JSON input passed via $DEVENV_TASK_INPUT |
before | List of task/event names this task must run before |
after | List 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
| Variable | Description |
|---|---|
$DEVENV_TASK_INPUT | JSON of the task’s input attribute |
$DEVENV_TASKS_OUTPUTS | JSON map of outputs from all dependent tasks |
$DEVENV_TASK_OUTPUT_FILE | Path to write this task’s JSON output |
$DEVENV_TASK_EXPORTS_FILE | Path 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:
| Suffix | Meaning |
|---|---|
@started | Wait for process to start executing |
@ready | Wait for readiness probe (default) |
@completed | Wait for process to terminate (soft) |
Dependency suffixes for tasks:
| Suffix | Meaning |
|---|---|
@started | Wait for task to start |
@succeeded | Wait for zero exit code (default) |
@completed | Wait 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 registrymacOS 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 shellPriority (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/mylibUse 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.lockSupported 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 outputLanguages 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 replProvides 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 gcEach 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
| File | Purpose |
|---|---|
devenv.nix | Main environment configuration |
devenv.local.nix | Local overrides, excluded from git |
devenv.yaml | Inputs, imports, and global settings |
devenv.local.yaml | Local overrides for devenv.yaml (v1.10+) |
devenv.lock | Pinned input versions (commit this) |
.envrc | direnv integration (auto-activation) |
.devcontainer.json | Auto-generated for Codespaces |
Environment Variables
| Variable | Value |
|---|---|
$DEVENV_ROOT | Project root containing devenv.nix |
$DEVENV_DOTFILE | $DEVENV_ROOT/.devenv |
$DEVENV_STATE | $DEVENV_DOTFILE/state |
$DEVENV_RUNTIME | Temp dir for sockets and runtime files |
$DEVENV_PROFILE | Nix 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: falseFlake 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 ];
};
};
}