| name | devenv |
| description | Define Nix-based development environments with devenv.sh, including task runners, services, and containers. |
| tags | ["nix","devenv","development-environment","nixos","direnv"] |
devenv.sh - Nix-powered Developer Environments
Use this skill when modifying devenv.nix, devenv.yaml, or working with devenv CLI commands. This covers the full devenv configuration surface.
When to Use
- Creating or modifying a
devenv.nix or devenv.yaml for a project
- Adding packages, language runtimes, services, or scripts to a dev environment
- Integrating devenv with direnv for automatic environment activation
- Pinning or updating devenv inputs via
devenv.lock
- Bootstrapping a web application environment around
Taskfile.yml, docker compose, and git hooks
Project File Structure
| File | Purpose | Commit? |
|---|
devenv.nix | Main configuration (Nix expression) | Yes |
devenv.yaml | Inputs, imports, and settings | Yes |
devenv.lock | Pinned input revisions (auto-generated) | Yes |
.envrc | direnv integration (use devenv) | Yes |
devenv.local.nix | Local overrides (not committed) | No |
devenv.local.yaml | Local input overrides | No |
.devenv/ | Generated state directory | No (gitignored) |
.devenv.flake.nix | Auto-generated flake wrapper | No (gitignored) |
devenv.nix Structure
Every devenv.nix is a Nix function receiving a module argument set:
{ pkgs, lib, config, inputs, ... }:
{
# Configuration goes here
}
Key parameters:
pkgs - Nixpkgs package set
lib - Nixpkgs library functions
config - The resolved devenv configuration (self-referencing)
inputs - Flake inputs defined in devenv.yaml
devenv.yaml Structure
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
Environment Variables
Built-in variables available inside devenv shell:
DEVENV_ROOT - Project root directory
DEVENV_DOTFILE - Path to .devenv/ directory
DEVENV_STATE - Persistent state directory (for services data)
DEVENV_RUNTIME - Runtime directory (temp, cleared on reboot)
DEVENV_PROFILE - Current Nix profile path
Setting custom env vars:
{
env.DATABASE_URL = "postgres://localhost:5432/mydb";
env.API_PORT = "3000";
# Reference other config values
env.GREETING = "Running in ${config.env.DEVENV_ROOT}";
}
Packages
{
# Add packages from nixpkgs
packages = [
pkgs.git
pkgs.jq
pkgs.curl
pkgs.go-task
pkgs.lefthook
pkgs.docker-compose
];
}
Search for packages: devenv search <name>
Languages
Languages are enabled via toggle modules. Each language module provides tooling, compilers, and package managers.
{
# Node.js
languages.javascript = {
enable = true;
package = pkgs.nodejs_22; # Override default version
pnpm.enable = true;
pnpm.install.enable = true; # Auto-install on shell entry
};
# Python
languages.python = {
enable = true;
version = "3.12";
venv.enable = true;
venv.requirements = ./requirements.txt;
};
# Rust
languages.rust = {
enable = true;
channel = "stable"; # "stable", "nightly", or "beta"
};
# Go
languages.go.enable = true;
# Ruby
languages.ruby = {
enable = true;
bundler.enable = true;
};
}
Scripts
Custom commands available in the devenv shell:
{
# Simple script
scripts.dev.exec = ''
pnpm run dev
'';
# Script with extra packages in scope
scripts.fetch-data.exec = ''
curl -s https://api.example.com | jq '.data'
'';
scripts.fetch-data.packages = [ pkgs.curl pkgs.jq ];
# Pin binaries to avoid PATH conflicts
scripts.build.exec = ''
${pkgs.nodejs}/bin/node scripts/build.js
'';
# Use a specific interpreter
scripts.analyze.exec = ''
import sys
print(sys.version)
'';
scripts.analyze.package = config.languages.python.package;
scripts.analyze.binary = "python3";
}
enterShell
Code that runs every time you enter the devenv shell:
{
enterShell = ''
if [ ! -f .env ] && [ -f .env.example ]; then
cp .env.example .env
echo "Created .env from .env.example"
fi
'';
}
Use enterShell only for small, idempotent shell-entry bootstrap such as creating a missing local .env file from .env.example.
Prefer tasks over enterShell for anything non-trivial - tasks support better reuse, clearer entrypoints, and easier composition with project tooling.
Web Application Default Stance
For web applications, prefer this split of responsibilities:
- devenv for toolchains, packages, env wiring, and shell bootstrapping
Taskfile.yml (go-task) for developer entrypoints like task dev, task test, and task lint
docker compose for long-running multi-service application stacks
For typical web applications, prefer Task + Compose over processes.*.
Recommended Defaults for Web Apps
- Put developer commands in
Taskfile.yml
- Use
docker compose up / down / logs for the running stack
- Use
scripts.* in devenv.nix as thin wrappers around task ... when convenient
- Keep
processes.* for small local daemons, single binaries, or non-web workflows that do not need Compose
Example:
{
scripts.dev.exec = "task dev";
scripts.test.exec = "task test";
scripts.logs.exec = "task compose:logs";
}
Processes
Long-running processes managed by devenv (started with devenv up). These are useful for simple local daemons, workers, and lightweight tooling, socket activation, file watching, and dependency management.
{
# Simple process
processes.api.exec = "pnpm run dev";
# Process with working directory
processes.frontend = {
exec = "pnpm run dev";
cwd = "./frontend";
};
# Process dependencies (start order)
processes.api = {
exec = "pnpm run start:api";
after = [ "devenv:processes:db" ];
};
# File watching (auto-restart on changes)
processes.worker = {
exec = "node worker.js";
watch.paths = [ ./src/worker ];
};
# Port allocation
processes.api = {
exec = "node server.js --port $PORT";
ports.http.allocate = 8080;
# Access: config.processes.api.ports.http.value
};
}
Process management
Process management is handled via the devenv processes command suite or the devenv MCP server.
[!IMPORTANT]
Use the MCP Server to Manage Processes when Available:
If the devenv MCP server is enabled in your environment, always prefer using the MCP tools (list_processes, start_process, stop_process, get_process_status, get_process_logs) over shell CLI commands.
The MCP server is executed in the exact context of the workspace's background daemon, which prevents the path and socket mismatches described below.
Mismatched Shell Sessions & Orphaned Supervisors
-
CLI Commands are Not Shareable Across Shells:
devenv process manager states and sockets are saved in a temporary runtime directory (DEVENV_RUNTIME) keyed to a hash of the environment. If you enter the environment in different ways (e.g. nested shells, different terminal sessions, or wrapper scripts), their DEVENV_RUNTIME values may point to different directories (e.g., /tmp/devenv-e864f60 vs /tmp/devenv-c92e103).
As a result, commands like devenv processes list or devenv processes down run in one session might fail to find the active supervisor's control socket and report "No process manager is running", even though the supervisor is running and actively managing processes in the background.
-
Do Not Run Processes in Ephemeral or Nested Shells:
Avoid running devenv up or devenv up -d inside ephemeral shells (such as nested scripts, short-lived tasks, or subagent runs). If the shell parent exits or is cancelled, the supervisord daemon may exit or lose its control socket, but the children (Vite, Go backend, Postgres) will remain orphaned, running invisibly in the background, consuming CPU, and blocking port bindings.
-
Prefer Foreground in Persistent/Backgrounded Shell Sessions:
To ensure processes clean up automatically, run devenv up or devenv processes start in the foreground of a terminal session. If you need the stack to run in the background, background the terminal session itself (e.g., using tmux, screen, or a background system daemon), while keeping the processes running in the foreground of that session.
This way, when the session is closed or killed, the terminal controller sends signals (SIGINT/SIGHUP) to the entire process group, ensuring all child services are cleanly terminated instead of being orphaned.
-
Testing Foreground Exit Behavior:
You can test this clean teardown using terminal multiplexers (like tmux):
- Start
devenv up in a detached tmux session:
tmux new-session -d -s devenv-session 'devenv up'
- Verify that services are active and ports are bound (e.g.,
lsof -i :3000).
- Terminate the tmux session:
tmux kill-session -t devenv-session
- Verify that the child processes (Vite, Go, PostgreSQL) have terminated cleanly and the ports are freed.
-
Shutting Down Orphaned Supervisors:
If a supervisor daemon gets orphaned or runs under a mismatched socket path, manual child process kills (e.g. killing postgres or node) will just trigger immediate restarts by the supervisor. To shut down the daemon and its children permanently:
- Kill the supervisor daemon itself:
pkill -f "daemon-processes"
- Kill any remaining children:
pkill -f "node.*vite" || true
pkill -f "postgres" || true
1. Starting Processes
-
Start all processes in the background:
devenv processes start
(Alternatively, you can run devenv up -d to start the process manager in detached mode).
-
Start a specific process:
devenv processes start <process-name>
Example:
devenv processes start service
2. Checking Status
-
List all processes and their current status/phase:
devenv processes list
-
Check details of a specific process:
devenv processes status <process-name>
Example:
devenv processes status service
3. Viewing Logs
4. Stopping Processes
-
Stop a specific process:
devenv processes stop <process-name>
Example:
devenv processes stop service
-
Stop the process manager and shut down all processes:
devenv processes down
Services
Pre-built service modules (databases, caches, etc.):
{
services.postgres = {
enable = true;
listen_addresses = "127.0.0.1";
port = 5432;
initialDatabases = [{ name = "myapp"; }];
extensions = ext: [ ext.postgis ];
};
services.redis.enable = true;
services.mysql = {
enable = true;
initialDatabases = [{ name = "myapp"; }];
};
services.minio.enable = true;
services.elasticsearch.enable = true;
}
Service state is stored in $DEVENV_STATE/<service>. Services start with devenv up.
Tasks
Cacheable, dependency-aware build tasks inside devenv.nix:
{
# Basic task
tasks."myapp:build" = {
exec = "pnpm run build";
before = [ "devenv:enterShell" ]; # Runs before shell entry
};
# Task with status check (skip if already done)
tasks."myapp:install" = {
exec = "pnpm install";
status = "test -d node_modules";
before = [ "devenv:enterShell" ];
};
# Conditional re-execution on file changes
tasks."myapp:codegen" = {
exec = "pnpm run codegen";
execIfModified = [ "schema/**/*.graphql" ];
before = [ "devenv:enterShell" ];
};
# Task with inputs (CLI-passable)
tasks."myapp:migrate" = {
exec = ''
echo "Running migration: $TASK_INPUT_NAME"
'';
input = {
name = {
description = "Migration name";
default = "latest";
};
};
};
}
Run tasks: devenv tasks run myapp:build
With inputs: devenv tasks run myapp:migrate --input name=v2
Devenv MCP server
TIP: You can also use the devenv MCP server to manage processes, tasks, read their logs, etc.
Tests
{
enterTest = ''
echo "Running tests..."
wait_for_port 5432 # Helper: wait for service port
pnpm run test
'';
}
Run: devenv test (starts processes, runs enterTest, then stops everything).
Use config.devenv.isTesting to conditionally disable things during tests:
{
processes.heavy-worker = lib.mkIf (!config.devenv.isTesting) {
exec = "node heavy-worker.js";
};
}
Git Hooks (pre-commit)
Integration with git-hooks.nix:
{
git-hooks.hooks = {
nixfmt.enable = true;
prettier = {
enable = true;
excludes = [ "pnpm-lock.yaml" ];
};
eslint.enable = true;
shellcheck.enable = true;
# Custom hook
check-todos = {
enable = true;
entry = "grep -r 'ACTION_ITEM' --include='*.ts' && exit 1 || exit 0";
language = "system";
};
};
}
The .pre-commit-config.yaml is an auto-generated symlink - do not edit it directly.
Overlays
Modify or extend the nixpkgs package set:
{
overlays = [
# Override an existing package
(final: prev: {
hello = prev.hello.overrideAttrs (old: {
patches = (old.patches or []) ++ [ ./my-patch.patch ];
});
})
# Add a custom package
(final: prev: {
my-tool = final.callPackage ./nix/my-tool.nix {};
})
];
}
Inputs & Multiple Nixpkgs
In devenv.yaml, add extra inputs:
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
nixpkgs-stable:
url: github:NixOS/nixpkgs/nixos-24.11
Use in devenv.nix:
{ pkgs, inputs, ... }:
let
pkgs-stable = import inputs.nixpkgs-stable {
system = pkgs.stdenv.system;
};
in {
packages = [
pkgs.nodejs_22 # From rolling
pkgs-stable.terraform # From stable
];
}
Update/lock inputs: devenv update
Outputs (Building Artifacts)
{
outputs = {
my-app = config.languages.rust.import ./. {};
container = config.containers.shell.derivation;
};
}
Build: devenv build (all) or devenv build outputs.my-app
Profiles
Named configurations for different contexts:
# devenv.nix
{
# Shared config available to all profiles
packages = [ pkgs.git ];
}
# profiles/backend.nix - referenced in devenv.yaml imports
{ pkgs, ... }: {
languages.go.enable = true;
services.postgres.enable = true;
}
Activate: devenv --profile backend shell
Containers
{
containers.shell.name = "my-app-shell";
containers.processes = {
name = "my-app";
startupCommand = config.procfileScript;
};
}
Build: devenv container build shell
Run: devenv container run shell
Copy to registry: devenv container copy shell docker://registry/image:tag
For monorepos, use profiles or imports to compose per-service environments.
Dotenv Integration
{
dotenv.enable = true;
dotenv.filename = ".env"; # default
}
Warning: .env contents are copied into the Nix store (world-readable). For real secrets, use secretspec or inject via environment outside of Nix.
Creating Files
{
files."config/settings.json".text = builtins.toJSON {
port = 3000;
debug = true;
};
files.".editorconfig".text = ''
root = true
[*]
indent_style = space
indent_size = 2
'';
}
CLI Command Reference
| Command | Description |
|---|
devenv init | Initialize a new devenv project |
devenv shell | Enter the development shell |
devenv up | Start all processes |
devenv up -d | Start processes in background (detached) |
devenv test | Run tests (starts processes, runs enterTest, stops) |
devenv build | Build all outputs |
devenv build outputs.<name> | Build a specific output |
devenv search <query> | Search available packages |
devenv info | Show environment info |
devenv update | Update and lock inputs |
devenv gc | Garbage collect old generations |
devenv container build <name> | Build a container |
devenv container run <name> | Run a container |
devenv container copy <name> <url> | Copy container to registry |
devenv tasks run <name> | Run a specific task |
devenv processes up | Start processes (alias) |
Common flags:
--profile <name> - Use a specific profile
--impure - Allow impure evaluation (access host env)
--option <key> <value> - Override a Nix option
Common Nix Patterns in devenv.nix
let/in bindings
{ pkgs, ... }:
let
port = "3000";
dbName = "myapp";
in {
env.PORT = port;
services.postgres.initialDatabases = [{ name = dbName; }];
}
Conditional configuration
{ pkgs, lib, config, ... }:
{
# Only include when testing
processes.seed-db = lib.mkIf config.devenv.isTesting {
exec = "node seed.js";
};
# Optional attrs
packages = [ pkgs.git ] ++
lib.optionals pkgs.stdenv.isLinux [ pkgs.strace ];
}
Module imports
{ pkgs, ... }:
{
imports = [ ./shared.nix ];
# This merges with shared.nix configuration
packages = [ pkgs.curl ];
}
Rules
- ALWAYS read the existing
devenv.nix and devenv.yaml before making changes - understand the current configuration.
- NEVER manually edit
devenv.lock - it is auto-generated by devenv update.
- NEVER manually edit
.pre-commit-config.yaml - it is an auto-generated symlink from git-hooks configuration.
- NEVER put real secrets in
devenv.nix or .env with dotenv enabled - Nix store is world-readable.
- Use
devenv search <name> to find the correct package attribute before adding to packages.
- Prefer tasks over enterShell for setup steps that can be cached or have dependencies.
- Keep
enterShell small and idempotent - copying .env.example to .env is fine; installs, migrations, and long-running commands are not.
- Use
lib.mkIf for conditional configuration, not string interpolation or if/then/else at the module level.
- Pin binaries in scripts with
${pkgs.foo}/bin/foo when PATH conflicts are possible.
- Use
devenv.local.nix for personal overrides that should not be committed.
- Run
devenv test to validate changes - it exercises the full environment including processes.