devenv: Modules, Services, and Processes
devenv: Modules, Services, and Processes
pkgs.mkShell plus flake.lock plus direnv is still the desk default for a compiler and an LSP. devenv is the next layer: a NixOS-style module system for the developer environment — languages, processes, and local databases — with its own CLI and lock. Use it when mkShell { packages = [ pkgs.go pkgs.gopls ]; } is no longer enough. Do not use it to replace buildGoModule or to pin nixpkgs to a rolling branch.
This chapter is devenv 2.x (native process manager, optional git-hooks input). Commands below assume a Nix 2.35+ daemon.
Mental model
devenv evaluates devenv.nix (modules) against inputs in devenv.yaml, pins them in devenv.lock, and exposes a shell (devenv shell) plus a process supervisor (devenv up).
devenv.yaml → inputs (nixpkgs, extra flakes)
devenv.nix → packages, languages.*, services.*, processes.*, env
devenv.lock → rev + narHash (commit this)
│
├── devenv shell PATH, env, enterShell
└── devenv up postgres, redis, desk-api, …
│
▼
$DEVENV_STATE service data (not in git)
| Need | Tool |
|---|---|
go + gopls only |
mkShell + flake.lock (earlier chapters) |
| Language module (LSP, formatter, version knob) | languages.go.enable |
| Local Postgres / Redis like Compose | services.postgres / services.redis + devenv up |
| Ad-hoc long-running command | processes.<name>.exec |
Same env as a flake devShell |
devenv.lib.mkShell — impure (--no-pure-eval) |
$DEVENV_ROOT is the project directory. $DEVENV_STATE is where Postgres writes its cluster. Changing initialScript does not migrate an existing cluster — delete that state dir on purpose.
devenv 2.x drives processes with a native Rust manager. process.manager.implementation = "process-compose"; is a transition flag, not the default.
Worked examples
Case 1: Init, pin 26.05, Go toolchain
nix run github:cachix/devenv -- init• Creating devenv.nix
• Creating devenv.yaml
• Creating .gitignore
Overwrite the generated devenv.yaml so nixpkgs is this book’s train, not cachix/devenv-nixpkgs/rolling:
# devenv.yaml
inputs:
nixpkgs:
url: github:NixOS/nixpkgs/nixos-26.05Save as devenv.nix:
# devenv.nix
{ pkgs, ... }:
{
packages = [ pkgs.git pkgs.jq ];
languages.go.enable = true;
# 26.05's pkgs.go; do not also set a random languages.go.package unless you measured it
env.GOTOOLCHAIN = "local";
env.CGO_ENABLED = "0";
env.GOPRIVATE = "git.desk.internal";
enterShell = ''
echo "desk-api devenv: $(go env GOVERSION) GOTOOLCHAIN=$(go env GOTOOLCHAIN)"
'';
}devenv update
git add devenv.nix devenv.yaml devenv.lock .gitignore
devenv shell -- go versionOutput (shape — versions follow 26.05):
desk-api devenv: go1.24.x GOTOOLCHAIN=local
go version go1.24.x linux/amd64
devenv.lock is the pin. devenv update without a lock commit is a channel. languages.go.enable pulls the language module (compiler, typically gopls / tools — inspect with devenv info). Extra CLIs still go in packages.
Do not put pkgs.go on Home Manager and languages.go here. One compiler, this project.
Case 2: Postgres + API process (devenv up)
This is why devenv exists on the desk: a local Postgres that is not Docker Desktop, plus the API in the same supervisor.
# devenv.nix
{ pkgs, lib, config, ... }:
{
languages.go.enable = true;
env.GOTOOLCHAIN = "local";
services.postgres = {
enable = true;
package = pkgs.postgresql_16;
listen_addresses = "127.0.0.1";
port = 5432;
initialDatabases = [{ name = "desk"; }];
initialScript = ''
CREATE USER desk WITH PASSWORD 'desk-dev';
GRANT ALL PRIVILEGES ON DATABASE desk TO desk;
'';
};
env.DATABASE_URL = "postgres://desk:desk-dev@127.0.0.1:${toString config.services.postgres.port}/desk";
processes.desk-api = {
exec = "go run ./cmd/desk-api";
after = [ "devenv:processes:postgres" ];
};
}devenv uppostgres | database system is ready to accept connections
desk-api | listening on :8080
Stop with Ctrl-C or devenv processes down / devenv down (2.x). Background: devenv up -d.
Dev password stays in devenv. Production is sops EnvironmentFile. After you change initialScript, wipe state or Postgres will ignore it:
rm -rf "$DEVENV_STATE/postgres"
devenv uppostgresql_16 is whatever 26.05 named; if the attr is missing, nix search github:NixOS/nixpkgs/nixos-26.05 postgresql. Do not pin postgresql_15 from a blog.
Readiness: after = [ "devenv:processes:postgres" ] waits until the process is considered ready, not until pg_isready unless the service module defines a probe. Check with devenv processes / logs. For HTTP:
{
processes.desk-api = {
exec = "go run ./cmd/desk-api";
after = [ "devenv:processes:postgres" ];
process-compose.readiness_probe.http_get = {
host = "127.0.0.1";
port = 8080;
path = "/healthz";
};
};
}On devenv 2.x the native manager has its own probe fields (process-compose.* is the old name if you set process.manager.implementation = "process-compose"). Prefer the native manager; read devenv info / the option reference for your lock if a field eval-errors.
Case 3: Extra inputs, git-hooks, lock hygiene
devenv 2.x does not include git-hooks by default. If you want hooks, add the input.
# devenv.yaml
inputs:
nixpkgs:
url: github:NixOS/nixpkgs/nixos-26.05
git-hooks:
url: github:cachix/git-hooks.nix# devenv.nix fragment
{
git-hooks.hooks = {
nixfmt-rfc-style.enable = true;
};
}devenv update git-hooks
devenv shell # installs hooks into .git/hooks via a task
git commit --allow-empty -m 'hook smoke'pre-commit was renamed toward prek in 2.x. If eval says the hook attr moved, follow the error — do not copy a 1.x gist.
One-input updates:
devenv update nixpkgsThat is nix flake update nixpkgs for this tree. Do not devenv update of everything on Friday.
cachix.pull defaults to the devenv cache. The desk still uses one team cache for desk-api NARs. Pulling devenv.cachix.org is optional sugar for devenv’s own closures; it is not a substitute for signing what you ship.
Case 4: direnv — use devenv, not a second mkShell
devenv’s generated .envrc (or):
# .envrc
eval "$(devenv direnvrc)"
use devenv
watch_file devenv.nix
watch_file devenv.yaml
watch_file devenv.lockdirenv allow
go env GOVERSIONOn cd, direnv loads the devenv shell. devenv up then uses that cache (faster; skips a full re-eval). Gitignore .devenv/ and $DEVENV_STATE (devenv’s .gitignore already should). Commit .envrc.
Do not also use flake in the same directory pointing at a competing devShells.default. One loader.
Named profiles (2.x) if backend vs frontend:
# devenv.nix fragment
{
profiles.backend.module = {
services.postgres.enable = true;
languages.go.enable = true;
};
profiles.frontend.module = {
languages.javascript.enable = true;
};
}devenv --profile backend shell
devenv --profile backend upThat is devenv’s answer to devShells.backend. Prefer it over two ad-hoc flakes if you already paid for devenv.
Case 5: Flake wrapper (when CI already speaks nix develop)
Keep devenv.nix as source of truth. Export a devShell for tooling that only knows flakes:
# flake.nix
{
description = "desk-api via devenv (optional flake wrap)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
devenv.url = "github:cachix/devenv";
devenv.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, devenv, ... }@inputs:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = devenv.lib.mkShell {
inherit inputs pkgs;
modules = [ ./devenv.nix ];
};
};
}nix develop --no-pure-eval--no-pure-eval is required. Flake pure eval cannot see the project directory (devenv.root). Forgetting the flag is a confusing eval error, not a missing package. An alternative is hard-coding devenv.root = /absolute/path; — that flake is not portable. Prefer the flag, or skip the wrapper and call devenv shell in CI:
# .github/workflows/check.yml fragment
- run: nix run github:cachix/devenv -- testdevenv test / enterTest runs tasks. Starting processes during flake-based nix flake check is not supported the same way — devenv up is the CLI. Do not pretend flake check boots Postgres.
languages.*.import / devenv build outputs exist (crate2nix, uv2nix, …). The desk still ships buildGoModule + vendorHash for desk-api. devenv outputs are an extra packaging stack; they are not the boring production path.
The trap
The trap is inputs.nixpkgs.url: github:cachix/devenv-nixpkgs/rolling (the init default) while production flakes pin nixos-26.05. Laptop go and CI go diverge. Pin github:NixOS/nixpkgs/nixos-26.05 in devenv.yaml and commit devenv.lock.
The other traps:
- devenv as the package builder.
devenv shellis PATH.nix build .#desk-apiis the artifact. nix developwithout--no-pure-evalon adevenv.lib.mkShellflake.- Two supervisors:
docker compose upanddevenv upfor the same Postgres. Pick one. Compose stays for the weird native addon; devenvservices.postgresis the Nix-shaped default. - Leaving
$DEVENV_STATE/postgresforever after a major/package bump. Cluster files from 15 will not start 16. git-hookscopied from a 1.x gist without thegit-hooksinput on 2.x.languages.go.enableplushome.packages = [ pkgs.go ]. Two toolchains.
The boring rule
mkShell+flake.lock+ direnv for a compiler. devenv when you need languages / services / processes.devenv.yamlnixpkgs =nixos-26.05. Commitdevenv.lock. Update one input per PR.devenv shell/ direnvuse devenv.devenv upfor Postgres and the API. State in$DEVENV_STATE, not git.- Native process manager (2.x default). process-compose only if a probe option you need is still there.
- Production packages:
buildGoModule(etc.), notdevenv buildoutputs. - Flake wrap is optional and impure (
--no-pure-eval). CI can calldevenv testinstead. - One Go, one Postgres, one loader. No Compose + devenv + mkShell fighting.
Try this
- Case 1:
devenv init, rewritedevenv.yamlto 26.05,devenv update,devenv shell -- go env GOVERSION GOTOOLCHAIN. Confirm a store pathwhich go. - Case 2:
devenv up,psql "$DATABASE_URL" -c 'SELECT 1'. ChangeinitialScript, restart without wiping state, observe it did nothing;rm -rf "$DEVENV_STATE/postgres"and retry. git check-ignore -v devenv.lock— must not be ignored.git grep rolling -- devenv.yaml— empty.- Add
use devenvto.envrc,direnv allow,cdout and back. Confirmgostays a store path. - Optional: wrap Case 5, run
nix developwithout--no-pure-eval, read the error, then with the flag.