devenv: Modules, Services, and Processes

Updated

September 12, 2026

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.mkShellimpure (--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.05

Save 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 version

Output (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 up
postgres   | 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 up

postgresql_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 nixpkgs

That 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.lock
direnv allow
go env GOVERSION

On 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 up

That 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 -- test

devenv 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 shell is PATH. nix build .#desk-api is the artifact.
  • nix develop without --no-pure-eval on a devenv.lib.mkShell flake.
  • Two supervisors: docker compose up and devenv up for the same Postgres. Pick one. Compose stays for the weird native addon; devenv services.postgres is the Nix-shaped default.
  • Leaving $DEVENV_STATE/postgres forever after a major/package bump. Cluster files from 15 will not start 16.
  • git-hooks copied from a 1.x gist without the git-hooks input on 2.x.
  • languages.go.enable plus home.packages = [ pkgs.go ]. Two toolchains.

The boring rule

  • mkShell + flake.lock + direnv for a compiler. devenv when you need languages / services / processes.
  • devenv.yaml nixpkgs = nixos-26.05. Commit devenv.lock. Update one input per PR.
  • devenv shell / direnv use devenv. devenv up for 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.), not devenv build outputs.
  • Flake wrap is optional and impure (--no-pure-eval). CI can call devenv test instead.
  • One Go, one Postgres, one loader. No Compose + devenv + mkShell fighting.

Try this

  1. Case 1: devenv init, rewrite devenv.yaml to 26.05, devenv update, devenv shell -- go env GOVERSION GOTOOLCHAIN. Confirm a store path which go.
  2. Case 2: devenv up, psql "$DATABASE_URL" -c 'SELECT 1'. Change initialScript, restart without wiping state, observe it did nothing; rm -rf "$DEVENV_STATE/postgres" and retry.
  3. git check-ignore -v devenv.lock — must not be ignored. git grep rolling -- devenv.yaml — empty.
  4. Add use devenv to .envrc, direnv allow, cd out and back. Confirm go stays a store path.
  5. Optional: wrap Case 5, run nix develop without --no-pure-eval, read the error, then with the flag.