Making devenv start fast, and the whole nixpkgs with it

I'm sitting here next to Farid Zakaria at Tacosprint where we looked at the stat storm that has been haunting nixpkgs for a decade.

The Tacosprint table

devenv auto activation runs devenv hook-should-activate on every shell prompt to decide whether you've stepped into a project directory. It does almost nothing: discover the project, check the trust database, print a path. So its runtime is pure startup overhead, and it runs on every single prompt redraw.

$ time devenv hook-should-activate
/home/domen/dev/myproject
real    0m0.070s
...

70ms before a prompt, every prompt.

And this isn't devenv's tax to pay, it's nixpkgs'. Every program pays it before it runs a line of its own code: the dynamic loader has to find each shared library, and the way Nix scatters packages across the store makes that search slow. This is not news. The cost has been measured, written up, and partly fixed more than once, and yet it has sat in limbo for the better part of a decade with no general fix merged into nixpkgs.

Most of that is the dynamic loader looking for a shared object that is sitting right there in the store, just not in the first directory it tried. The loader knocks on 486 wrong doors before it finds the right ones, and almost all of it happens before main even starts.

That number is the whole game. Above ~30ms you have to bolt a caching layer on top of the hook; in single digit milliseconds you just run it on every prompt and throw the cache away.

And it scales with the closure: imagemagick's magick --version makes 1225 failing opens:

$ strace -f -e openat magick --version 2>&1 >/dev/null | grep '\.so' | grep -c ENOENT
1225

The community has been circling a real fix for years. This post walks through the problem, the approaches people have tried with their tradeoffs, and a more radical one we spiked for devenv to see if it was even possible: deleting the dynamic loader altogether by linking the whole program into one static binary.

The umbrella tracking issue for the general problem is NixOS/nixpkgs#481620.

Why Nix makes the loader work so hard

On a traditional distribution every shared library lives in a handful of global directories such as /usr/lib. The dynamic loader has a short, mostly cached search path, and ld.so.cache (built by ldconfig) turns soname lookups into a hash table hit.

Nix is different by design. Every package lives in its own /nix/store/<hash>-name/lib directory, and there is no global ld.so.cache for store libraries. To make a binary find its dependencies, Nix records a DT_RUNPATH in the ELF header that lists one directory per dependency. A program linked against fifty libraries gets a DT_RUNPATH with dozens of entries.

Now recall how glibc resolves a DT_NEEDED soname with DT_RUNPATH present: it walks every DT_RUNPATH directory in order, trying to open dir/soname in each, until one succeeds. So resolving N libraries against a path of M directories costs on the order of N times M openat() attempts, almost all of which fail. That is the stat storm.

It gets worse. For every directory it searches, glibc first probes the glibc-hwcaps subdirectories for your CPU (x86-64-v3, x86-64-v2, and so on), which adds roughly three more failing opens per directory on a modern machine. On a fast SSD with a warm cache none of this is noticeable. On a slow disk, a network filesystem, a cold cache, or a low power ARM board, it is the difference between snappy and sluggish, and it multiplies across every process a shell script spawns.

Concretely, the two workloads we traced most closely:

Workload Loaded libraries DT_RUNPATH dirs Failing .so opens
devenv version 83 12 (leaf binary) ~486
imagemagick magick --version 91 35 ~1225

The wider a binary's own DT_RUNPATH and the deeper its transitive graph, the worse the storm.

What a good fix has to preserve

The reason this problem has stayed open so long is that the obvious fixes break things people rely on. Any serious solution is judged against a checklist:

  • LD_LIBRARY_PATH override. NixOS injects the GPU driver by putting /run/opengl-driver/lib on LD_LIBRARY_PATH. If a fix stops that from winning, graphics break.
  • LD_PRELOAD. Interposers and shims must still load first.
  • The libGL / glvnd runtime swap. A program built against Mesa must be able to pick up the vendor driver at runtime.
  • Two libraries with the same soname. This is the heart of the Nix model: different parts of one closure can legitimately depend on different builds of the same soname, and resolution must stay per object.
  • dlopen. Plugins loaded at runtime are a related but separate problem.
  • Cross compilation. A fix that has to run the target loader cannot cross compile cleanly.
  • Disk and closure size. Whatever metadata you add ships in every NAR.
  • Maintenance burden. A glibc or loader patch has to be rebased onto every new glibc release, and patching glibc rebuilds the world.

No approach so far ticks every box. The interesting part is how each one chooses which boxes to give up.

Approach 1: freeze the resolution with absolute paths

The simplest idea: rewrite every DT_NEEDED entry from a bare soname like libfoo.so.1 to the absolute store path of the library it resolves to. glibc has a "slash short circuit": a DT_NEEDED containing a / is opened directly, skipping all search. No search means no storm, and not even the glibc-hwcaps probes happen.

This is well trodden ground:

  • Farid Zakaria's shrinkwrap and the nix-harden-needed tool do exactly this as external post processing. Shrinkwrap is described in the paper Mapping Out the HPC Dependency Chaos (Zakaria, Scogland, Gamblin, Maltzahn, 2022; arXiv:2211.05118), which measures the storm directly: an Emacs launch drops from 1823 stat/openat syscalls to 104, a 36 times speedup, and a 900 library MPI application starting across 2048 processes on NFS goes from 344.6s to 47.8s, 7.2 times faster. Those NFS numbers are the clearest evidence that this overhead, invisible on a warm local cache, becomes brutal on a network or cold filesystem.
  • patchelf PR #357 (--shrink-wrap, open since 2021) pulls all transitive DT_NEEDED up onto the top binary and rewrites them to absolute paths.
  • Spack has a similar bind feature in the HPC world.
  • Inside nixpkgs, this mechanism is already used ad hoc in dozens of packages.

The cost is steep on the checklist. Absolute paths lose the LD_LIBRARY_PATH override, so the glvnd driver swap stops working, and you need an exemption list for libc, the loader itself, the GL stack, and initrd. There is also no runtime fallback: if the pinned path is gone, the program does not start.

There is also a build time fork in the road here. To rewrite a soname to an absolute path you first have to resolve it, and there are two ways to do that: run the binary's own loader and record what glibc actually picks, or walk DT_RUNPATH statically and resolve it yourself. The first is exact but executes target code, so it cannot cross compile; the second cross compiles cleanly. The absolute path tooling only ever did the first, which is why it stays a manual, per package tool rather than a default. The static walk is the same technique the ELF note cache (approach 3) later builds on.

So absolute paths are the zero disk, maximum speed option, attractive for self contained leaf applications, but wrong as a default because of the override semantics.

If the problem is that the loader searches many directories, give it one. The farm idea, floated early on by Linus Heckemann (see #24844), is: for each ELF, create a single directory of symlinks pointing at exactly the libraries that ELF needs, and set its DT_RUNPATH to that one directory.

The crucial detail is that the sonames in DT_NEEDED stay short. The farm only changes where they are found, not how. Because the farm lives in DT_RUNPATH, which the loader consults after LD_LIBRARY_PATH, every override keeps working. And it builds with nothing but stock patchelf --set-rpath and symlinks, with no glibc or patchelf fork, and never executes the target binary, so it cross compiles.

But keeping the sonames short is also where it breaks the Nix model. A farm directory is a flat namespace keyed by soname, so it can hold exactly one libfoo.so.1. When a closure legitimately pulls two different builds of the same soname (the case Nix exists to allow), the farm cannot represent both, and glibc's soname based dedup collapses them to whichever loads first. Absolute paths (approach 1) sidestep this because the store path becomes the key; the farm, which deliberately keeps the bare soname, cannot.

The remaining costs are store pollution and the hwcaps floor. Every ELF gains its own extra directory of symlinks, so the store fills up with farm directories that shadow the real libraries. And the farm collapses the per directory multiplier but not the per hwcaps multiplier: the loader still probes glibc-hwcaps inside the one farm directory. So it is a large constant factor win, not an asymptotic one.

How large depends entirely on how much of the graph you farm:

Farmed scope Failing opens Reduction
imagemagick, binary only (wide 35 dir DT_RUNPATH) 1225 → ~213 83%
devenv, leaf binary only (narrow 12 dir DT_RUNPATH) 486 → 392 19%
devenv, whole graph (every dep built with the hook) 486 → 88 82%

The two devenv rows are the lesson. Farming the leaf alone barely moves the needle because the storm there is dominated by the 83 libraries resolving each other, which a leaf only farm never touches. Only whole graph adoption reaches 82%, and the residual 88 are irreducible hwcaps probes rather than real library searches. So the farm pays off immediately when a package's own binary has a wide DT_RUNPATH, but needs whole graph adoption for closure heavy applications.

Approach 3: a per DSO resolution cache in an ELF note

This is the most ambitious approach and, on the checklist, the best. The idea, designed by pennae in #207893: have patchelf write a small PT_NOTE into each library that records, for each DT_NEEDED soname, where the loader should find it. A patched glibc reads that note during loading, between the LD_LIBRARY_PATH step and the DT_RUNPATH walk, and resolves the dependency straight from it.

Placing the read after LD_LIBRARY_PATH is what makes it safe: overrides, LD_PRELOAD, and the glvnd swap all keep winning, and soname based dedup is unchanged because the sonames stay short. Each cache entry is either an exact path, which is opened directly with no search and therefore no hwcaps probing, or a directory hint for the rare cases that cannot be resolved at build time ($ORIGIN relative entries, or directories that themselves contain a glibc-hwcaps tree).

This is the only approach that preserves every semantic, adds zero closure references, and eliminates the hwcaps floor as well. pennae's original benchmark showed an armv7 workload dropping from 44s to 29s (seconds, not ms, measured under strace -cf) with about 24000 fewer syscalls. In our own end to end test of a revived, cleaned up version, a note bearing binary resolved its dependency with zero failing search probes, versus the full storm for the same binary without the note, while the LD_LIBRARY_PATH override still took precedence.

The price is the heaviest of any approach. It needs two source changes: a glibc patch so the loader understands the note, and a patchelf change to write it. It is a staging mass rebuild, because patching glibc rebuilds the world. pennae's draft was closed for lack of a go or no go decision rather than any technical failure; the main worry raised was the long term maintenance of a glibc patch.

Approach 4: a Guix style per package ld.so.cache

Guix solves the same problem in production by shipping a per package ld.so.cache, the same binary format ldconfig produces, and having a patched loader consult it (written up in their Taming the 'stat' storm with a loader cache; #207061 proposed it for nixpkgs). It preserves LD_LIBRARY_PATH and is proven at scale, but building the cache needs ldconfig/ldd for the target architecture, which breaks cross compilation, and it hits buildEnv collisions and dlmopen namespace issues. The ELF note (approach 3) was in part a response: it reads DT_NEEDED and DT_RUNPATH statically and never runs a foreign binary, so it keeps the same LD_LIBRARY_PATH guarantee without those costs.

Approach 5: delete the loader with static linking

The four approaches above all make the loader's job easier. Static linking removes the loader instead. For devenv, a self contained CLI, we spiked it: building the whole closure through pkgsStatic (which means musl, since glibc doesn't support a complete static link) drops devenv version and hook-should-activate from about 70ms to about 16ms.

Build Loaded libraries startup
Baseline (all dynamic, glibc) 83 ~70ms
Fully static (musl) 0 ~16ms

This is not a nixpkgs fix and was never meant to be. Deleting the loader also deletes everything the loader does at runtime: loading plugins on demand, honouring driver and interposer overrides, swapping in the GPU vendor's GL stack. A lot of nixpkgs depends on that, so static linking can never be a general default. It works for devenv only because devenv is a self contained CLI that talks to Nix through its own linked in C API and needs none of it.

One thing surprised us: at 16ms, with the loader gone, devenv is still far above the ~2ms a static musl hello world starts in, the rest being execve mapping the image and devenv's own startup work. Even so, 16ms is fast enough for the shell hook to drop its per directory activation cache and just run the check every prompt.

What about macOS?

macOS uses a different loader, dyld, and the storm isn't there. Nix on Darwin already ships approach 1: every Mach-O records its dependencies as absolute store paths in LC_LOAD_DYLIB rather than bare sonames, and carries no LC_RPATH. So dyld opens each library directly on the first path it tries, and system frameworks come straight from the in memory dyld shared cache without touching disk. Where the glibc devenv made ~486 failing opens, the macOS one makes essentially none.

The startup cost macOS does have is specific to Nix. To decide whether to advertise x86_64-darwin as an extra platform, libstore forked a child running arch -arch x86_64 /usr/bin/true on startup, costing ~13ms on every Nix process on Apple silicon. The fix answers the same question with a stat of Rosetta 2's fixed install path in ~0.01ms (NixOS/nix#16067).

Side by side

Every column is framed as a property you want, so ✅ is always good and ❌ always a cost. Legend: ✅ yes · ⚠️ with caveats · ❌ no · ➖ not applicable. Caveats marked ⚠️ or worth a word are footnoted below.

Approach No glibc fork No patchelf change Cheap on disk Keeps LD_LIBRARY_PATH / glvnd Keeps dup sonames Kills hwcaps floor Cross safe
Absolute DT_NEEDED a ⚠️ b ⚠️ c
RUNPATH symlink farm d e
Per DSO ELF note
Per package ld.so.cache ⚠️ f
Static linking (musl) g h i

a stock patchelf --replace-needed · b breaks on the rare duplicate soname · c only the static-resolution variant cross compiles, and it is unbuilt · d stock patchelf --set-rpath · e every ELF gains its own symlink directory in the store · f buildEnv collisions · g uses musl, not glibc, so no glibc fork to maintain · h ~82MB binary · i via pkgsStatic

The final approach

Over the week at Tacosprint we revived the ELF note cache, cleaned it up, and got it built and tested end to end. After a decade in limbo it now works: the note writer, patchelf --build-resolution-cache (#647), shipped in patchelf 0.19.0, the first patchelf release since 0.18.0 in April 2023.

The last thing to land is nixpkgs#535735, which turns the note on across the whole package set. Because it patches glibc it has to go through staging, which rebuilds the world, so every binary in nixpkgs comes out the other side resolving its libraries straight from the note. That is also where it gets exercised at scale, and we're committed to fixing whatever shakes out as we go.

Once it has proven itself there, the longer term goal is to upstream the loader patch into glibc itself, so the fix isn't a nixpkgs carry but something every store based, nix style package manager, guix included, can rely on.

devenv 2.1: Nix with zsh, fish, and nushell via libghostty

devenv 2.0 gave you hot reload, the status line, and instant cache hits, but devenv shell always dropped you into bash, and you still needed direnv for activation on cd.

devenv 2.1 closes both gaps and adds structured handles for coding agents.

Every shell, first class

Native zsh, fish, and nushell

Shell reloading in zsh

devenv 2.1 adds native support for zsh, fish, and nushell (devenv#2718) with rcfile generation, environment diff tracking, reload hooks, and prompt integration implemented per shell rather than shimmed through bash. The shell is picked from $SHELL, or set explicitly:

$ devenv shell
$ SHELL=/bin/zsh devenv shell

Closes devenv#36 (open since November 2022), devenv#2487, and devenv#2592.

libghostty under the hood

The virtual terminal emulator was replaced with libghostty, the terminal engine from Ghostty, giving devenv a single VT parser that handles every shell the same way.

Building libghostty reliably on Nix took upstream patches in libghostty-rs#27, ghostty#12364, and ghostty#12548. Thanks to the Ghostty maintainers for landing them.

Auto reload

2.0 required Ctrl+Alt+R to apply environment changes after a rebuild, and that keybind clashed with reverse search on macOS. 2.1 re evaluates in the background on file changes and applies the new environment at the next prompt (devenv#2595).

Auto activation without direnv

devenv hook replaces direnv for cd based activation. Add one line to your shell config:

~/.bashrc
eval "$(devenv hook bash)"
~/.zshrc
eval "$(devenv hook zsh)"
~/.config/fish/config.fish
devenv hook fish | source
config.nu
devenv hook nu | save --force ~/.cache/devenv/hook.nu
source ~/.cache/devenv/hook.nu

Activation happens on cd into a trusted directory and reverses on the way out. No .envrc, no external dependencies. Trust is managed with devenv allow and devenv revoke.

For coding agents

In 2.0, an agent that wanted to restart your API after a config change had to kill the whole devenv session or scrape the TUI through ANSI codes. 2.1 replaces that with structured handles.

Process management from the command line

New subcommands act on a running devenv up (devenv#2621):

$ devenv processes list
$ devenv processes status
$ devenv processes logs api
$ devenv processes restart api
$ devenv processes stop worker
$ devenv processes start worker

These work with the native process manager and are also exposed as MCP tools.

Quiet mode by default

devenv detects agents via CLAUDECODE, OPENCODE_CLIENT, and AI_AGENT and switches to quiet mode automatically, suppressing TUI progress output that would waste tokens (devenv#2723). Override with --verbose or --tui.

OpenTelemetry trace export

devenv 2.1 exports OTLP traces (devenv#2415). Every Nix evaluation, derivation build, task run, and managed process becomes a span with attributes like devenv.activity.kind, devenv.derivation_path, devenv.url, and devenv.outcome.

Enable it through the new unified --trace-to flag:

$ devenv --trace-to otlp-grpc shell
$ devenv --trace-to otlp-http-protobuf:http://localhost:4318 shell

Three OTLP formats are supported: otlp-grpc (built in), otlp-http-protobuf, and otlp-http-json (opt in via cargo features). Endpoints can be set on the flag or via the standard OTEL_EXPORTER_OTLP_* variables.

Trace context propagates across process boundaries: spawned tasks, shell commands, and processes inherit TRACEPARENT and TRACESTATE, so instrumented children show up on the same trace as the parent devenv up run.

--trace-to replaces --trace-output and --trace-format with a single [format:]destination syntax, and accepts multiple destinations:

$ devenv --trace-to pretty:stderr --trace-to otlp-grpc shell
$ DEVENV_TRACE_TO=json:file:/tmp/trace.json,otlp-grpc devenv shell

Tasks and processes

devenv tasks run defaults to before mode, so dependencies run too (devenv#2551); --mode single restores the old behavior. The same --mode flag now also controls which processes devenv up starts (devenv#2721).

Tasks can print messages on shell entry by writing to $DEVENV_TASK_OUTPUT_FILE (devenv#2500).

And more

Nix 2.34. Multithreaded tarball unpacking, evaluator performance improvements, and REPL enhancements.

require_version in devenv.yaml. Enforce a minimum devenv CLI version for your project. Set require_version: true to match the modules version, or use a constraint string like ">=2.1" (devenv#2391).

ROCm support. New nixpkgs.rocmSupport option for enabling ROCm in nixpkgs configuration.

Full stack traces on error. show-trace is now always enabled, so evaluation errors include the full stack trace instead of a truncated message suggesting a nonexistent --show-trace flag (devenv#2725).

Ctrl+X to stop processes. Stop individual processes from the TUI while keeping them visible and restartable.

Ctrl+H to hide stopped processes. Toggle hiding stopped processes in the TUI to focus on what's still running. Failed processes stay visible, and the process count shows how many are hidden (devenv#2692).

Port allocation fixes. Port values (config.processes.<name>.ports.<port>.value) now resolve correctly in devenv shell and devenv tasks run, matching the ports allocated by devenv up (devenv#2710). Ports bound to 0.0.0.0 or [::] are now detected, preventing multiple devenv instances from allocating the same port (devenv#2567). Strict port restarts no longer fail with "port already in use" during kernel socket teardown (devenv#2647).

Dozens of other bug fixes. File watcher deduplication, import precedence, eval cache consistency, process lifecycle fixes, and terminal compatibility improvements. See the full changelog for details.

Breaking changes

  • devenv tasks run now runs dependencies by default (before mode instead of single). Use --mode single for the old behavior.

Final words

Open an issue or join the Discord with feedback.

Domen

devenv 2.0: A Fresh Interface to Nix

You type nix develop. The terminal fills with a single cryptic line: copying path, 47 of 312, 28.3 MiB, something something NAR. Five seconds. Ten. Is it evaluating? Downloading? Both? You change one line in your config and wait again. When it finally drops you into a shell, you switch to another branch and direnv hijacks your prompt for a rebuild you didn't ask for. You switch back, and Nix evaluates everything from scratch, even though nothing changed.

Nix gives you reproducibility that nothing else can match. But the moment to moment experience of using it has never matched the power underneath.

devenv 2.0 polishes Nix developer experience. Keeps the power, removes frictions. Here's what that looks like.

Interactive

To fully leverage what's going on in your development shell, we've made it fully interactive.

Terminal UI

Every devenv command now shows a live terminal interface. Instead of scrolling Nix build logs, you see structured progress: what Nix is evaluating, how many derivations need to be built and downloaded, task execution with dependency hierarchy, and error details that expand automatically on failure.

Terminal UI

Native shell reloading

You save a file, direnv fires, your prompt locks up for thirty seconds while Nix rebuilds, and you sit there staring at a frozen terminal.

With native shell, you save a file, devenv rebuilds in the background, a status line at the bottom of your terminal shows progress, and you press Ctrl+Alt+R when you're ready to apply the new environment. Your shell stays interactive the entire time. If the rebuild fails, the error appears in the status line without disrupting your session.

An example empty environment with only joe package:

devenv.nix
{ pkgs, ... }:

{
  packages = [ pkgs.joe ];
}

Shell reloading

Shell reloading is currently supported for bash, with fish and zsh coming soon (#2487).

direnv isn't needed anymore with devenv shell but it's still supported for automatic activation when switching directories; see the direnv integration

Native process manager

devenv 2.0 ships a built in Rust process manager that replaces process-compose.

Dependency ordering, restart policies, readiness probes (exec, HTTP, and systemd notify), systemd socket activation, watchdog heartbeats, file watching, and port allocation. All declarative, all in one place. Dependencies use @ready by default (wait for the probe to pass) or @completed (wait for the process to exit). You can freely mix processes and tasks in the same dependency chains.

devenv.nix
{ pkgs, config, ... }:

{
  services.postgres.enable = true;

  processes = {
    api = {
      exec = "${pkgs.python3}/bin/python -m http.server ${toString config.processes.api.ports.http.value}";
      after = [ "devenv:processes:postgres" ];
      ports.http.allocate = 8080;
      ready.http.get = {
        port = config.processes.api.ports.http.value;
        path = "/";
      };
    };

    worker = {
      exec = ''
        echo "Worker connected to API on port ${toString config.processes.api.ports.http.value}"
        exec sleep infinity
      '';
      after = [ "devenv:processes:api" ];
    };
  };
}

Process manager

This foundation opens the door to a fully integrated development loop: running processes in the background directly from your shell session, and automatically restarting them when the shell reloads.

process-compose is still available via process.manager.implementation = "process-compose". If something is missing from the native manager, let us know.

Instant

Run devenv shell. Wait a few seconds while Nix evaluates your configuration and builds what's needed. Now run it again.

This time it takes milliseconds.

Instant

Most of the performance gain comes from replacing multiple nix CLI invocations with a C FFI backend built on nix-bindings-rust. Instead of spawning five or more separate Nix processes per command, devenv 2.0 calls the Nix evaluator and store directly through the C API, evaluating one attribute at a time. This also gives us better error messages and real time progress in the TUI. We currently carry patches against Nix to extend the C FFI interface, but these are fully upstreamable and we plan to contribute them back. Thanks to Robert Hensing for creating nix-bindings-rust and making this possible.

This makes the evaluation cache incremental. Each evaluated attribute is cached individually along with the files and environment variables it touched. When you change one thing, only the attributes that depend on that change are re-evaluated; everything else is served from cache. A single evaluation now covers devenv shell, devenv test, devenv build, and every other command. When nothing changed (verified by content hash), the cached result is returned immediately without invoking Nix at all.

The cache invalidates when:

  • Any source file that was read during evaluation changes
  • Environment variables that were accessed during evaluation change
  • The devenv version, system, or configuration options change

You can force a refresh with --refresh-eval-cache or disable caching with --no-eval-cache.

Polyrepo support

Most teams don't live in a single repo. You have a backend in one repository, a frontend in another, shared libraries in a third.

Referencing outputs from another devenv project was the third most upvoted issue. Now you can reference any option or output from another project through inputs.<name>.devenv.config:

devenv.yaml
inputs:
  my-service:
    url: github:myorg/my-service
    flake: false
devenv.nix
{ inputs, ... }:
let
  my-service = inputs.my-service.devenv.config.outputs.my-service;
in {
  packages = [ my-service ];
  processes.my-service.exec = "${my-service}/bin/my-service";
}

This builds on the existing monorepo support and extends it to multi-repository workflows. See the polyrepo guide for full documentation.

Out of tree devenvs

Not every project has a devenv.nix checked in, and sometimes you want one configuration to serve multiple repositories. This was the fourth most upvoted issue. devenv 2.0 adds --from:

$ devenv shell --from github:myorg/devenv-configs?dir=rust-web
$ devenv shell --from path:../shared-config

Works with devenv shell, devenv test, and devenv build. Currently --from only works with projects that use devenv.nix alone; projects that also rely on devenv.yaml for extra inputs aren't supported yet.

For coding agents

A coding agent spins up your project in the background. It starts the dev server. Port 8080 is already taken by another agent running the same project. The process crashes. The agent retries, hits the same port, crashes again.

Meanwhile, that agent has full read access to every .env file in your project. Your API keys, database credentials, third party tokens. It never asks permission. It never tells you what it read.

devenv 2.0 fixes both problems.

Automatic port allocation

Define named ports and devenv finds free ones automatically:

devenv.nix
{ config, ... }:

{
  processes.server = {
    ports.http.allocate = 8080;
    exec = "python -m http.server ${toString config.processes.server.ports.http.value}";
  };
}

If port 8080 is taken, devenv tries 8081, 8082, and so on. Ports are held during evaluation to prevent races, then released just before the process starts. Use devenv up --strict-ports to fail instead of searching.

Secret isolation with SecretSpec

devenv 2.0 ships with SecretSpec 0.7.2 for declarative, provider-agnostic secrets management. Declare what secrets your project needs in secretspec.toml, and each developer provides them from their preferred backend: keyring, dotenv, 1Password, or environment variables.

Here's the thing: because password managers prompt for credentials before giving them out, secrets are never silently leaked to agents running in the background. This is a fundamental difference from .env files that any process can read.

Let's declare some secrets:

secretspec.toml
[project]
name = "myapp"
revision = "1.0"

[profiles.default]
DATABASE_URL = { description = "PostgreSQL connection string", required = true }
STRIPE_KEY = { description = "Stripe API secret key", required = true }
SENTRY_DSN = { description = "Sentry error tracking DSN", required = false }

And see how devenv asks for them and starts:

SecretSpec

MCP server

The devenv MCP server exposes package and option search over stdio and HTTP:

$ devenv mcp --http 8080

We host a public instance at mcp.devenv.sh that any MCP compatible tool can query without needing a local devenv installation.

devenv.new is a coding agent powered by the same package and option search that generates devenv.nix files for you.

And more

Language servers for your code. Most language modules now have lsp.enable and lsp.package options, giving you a language server for your project out of the box.

Language server for devenv.nix. Get completion and diagnostics while editing your devenv configuration:

$ devenv lsp

devenv eval. Evaluate any attribute in devenv.nix and return JSON:

$ devenv eval languages.rust.channel services.postgres.enable
{
  "languages.rust.channel": "stable",
  "services.postgres.enable": true
}

devenv build returns JSON. devenv build now outputs structured JSON mapping attribute names to store paths:

$ devenv build languages.rust.channel services.postgres.enable
{
  "languages.rust.channel": "/nix/store/...-stable",
  "services.postgres.enable": "/nix/store/...-postgresql-16.6"
}

NIXPKGS_CONFIG. devenv now sets a global NIXPKGS_CONFIG environment variable, ensuring that nixpkgs configuration (like allowUnfree, CUDA settings) is consistently applied across all Nix operations within the environment.

Breaking changes

For a step by step upgrade guide, see Migrating to devenv 2.0.

  • The git-hooks input is no longer included by default. If you use git-hooks.hooks, add it to your devenv.yaml.
  • devenv container --copy <name> has been removed. Use devenv container copy <name>.
  • devenv build now outputs JSON instead of plain store paths. Update any scripts that parse the output.
  • The native process manager is now the default. Set process.manager.implementation = "process-compose" if you need the old behavior.

Deprecation of devenv 0.x

devenv 0.x is now deprecated. Support will be dropped entirely in devenv 3.

Final words

Over the next few weeks we will be focused on fixing bugs and stabilizing the release. If you run into any issues, please open a report and we will prioritize it. Join the devenv Discord community to share feedback!

Domen

SecretSpec 0.7: Declarative Secret Generation

If you haven't tried SecretSpec yet, see Announcing SecretSpec for an introduction.

SecretSpec 0.7 introduces declarative secret generation — declare that secrets should be auto-generated when missing, directly in your secretspec.toml.

The Problem

When onboarding to a project, developers typically need to:

  1. Read docs to understand which secrets are needed
  2. Manually generate passwords and tokens
  3. Store them in the right provider

Some secrets — like local database passwords or session keys — don't need to be shared at all. They just need to exist.

The Solution: type + generate

Add type and generate to any secret declaration, and SecretSpec handles the rest:

[project]
name = "my-app"
revision = "1.0"

[profiles.default]
DB_PASSWORD = { description = "Database password", type = "password", generate = true }
API_TOKEN = { description = "Internal API token", type = "hex", generate = { bytes = 32 } }
SESSION_KEY = { description = "Session signing key", type = "base64", generate = { bytes = 64 } }
REQUEST_ID = { description = "Request ID prefix", type = "uuid", generate = true }

Run secretspec check or secretspec run, and any missing secret with generate configured is automatically created and stored in your provider:

$ secretspec check
Checking secrets in my-app (profile: default)...

✓ DB_PASSWORD - generated and saved to keyring (profile: default)
✓ API_TOKEN - generated and saved to keyring (profile: default)
✓ SESSION_KEY - generated and saved to keyring (profile: default)
✓ REQUEST_ID - generated and saved to keyring (profile: default)

Summary: 4 found, 0 missing

On subsequent runs, the stored values are reused — generation is idempotent.

Five Generation Types

Type Default Output Options
password 32 alphanumeric characters length, charset ("alphanumeric" or "ascii")
hex 64 hex characters (32 bytes) bytes
base64 44 characters (32 bytes) bytes
uuid UUID v4 none
command stdout of a shell command command (required)

Custom Options

Use a table instead of true for fine-grained control:

# 64-character password with printable ASCII
ADMIN_PASSWORD = { description = "Admin password", type = "password", generate = { length = 64, charset = "ascii" } }

# 64 random bytes, hex-encoded (128 chars)
ENCRYPTION_KEY = { description = "Encryption key", type = "hex", generate = { bytes = 64 } }

Shell Commands

The command type runs arbitrary shell commands, covering any generation need:

# WireGuard private key
WG_PRIVATE_KEY = { description = "WireGuard key", type = "command", generate = { command = "wg genkey" } }

# MongoDB keyfile
MONGO_KEYFILE = { description = "MongoDB keyfile", type = "command", generate = { command = "openssl rand -base64 765" } }

# SSH public key (from existing key)
SSH_PUBKEY = { description = "SSH public key", type = "command", generate = { command = "ssh-keygen -y -f ~/.ssh/id_ed25519" } }

Design Decisions

Generate if missing, never overwrite. Existing secrets are always preserved. This makes generation safe to declare in shared config files — it only fills in gaps.

No separate generate command. Generation happens automatically during check and run. A dedicated CLI command for rotation is planned for a future release.

type without generate is valid. You can annotate secrets with a type for documentation purposes without enabling generation. This is useful for secrets that must be manually provisioned but benefit from type metadata.

Conflicts are caught early. generate + default on the same secret is an error (which value should win?). type = "command" with generate = true (no command string) is also an error.

Upgrading

Update to SecretSpec 0.7 and add type/generate to any secrets you want auto-generated. Existing configurations continue to work without changes — both fields are optional.

curl -sSL https://install.secretspec.dev | sh

See the configuration reference for full documentation.

Share your thoughts on our Discord community or open an issue on GitHub.

Domen

devenv 1.11: Module changelogs and SecretSpec 0.4.0

devenv 1.11 brings the following improvements:

Module changelogs

The Nix module system already handles renames and deprecations well—you get clear warnings when using old option names. But communicating behavior changes is harder. When a default value changes or a feature works differently, users often discover this through unexpected behavior rather than explicit notification.

Recently we've wanted to change git-hooks.package from pkgs.pre-commit to pkgs.prek, a reimplementation in Rust.

The new changelog option lets module authors declare important changes directly in their modules:

devenv.nix
{ config, ... }: {
  changelogs = [
    {
      date = "2025-11-26";
      title = "git-hooks.package now defaults to pkgs.prek";
      when = config.git-hooks.enable;
      description = ''
        The git-hooks integration now uses [prek](https://github.com/cachix/prek) by default for speed and smaller binary size.

        If you were using pre-commit hooks, update your configuration:
        ```nix
        git-hooks.package = pkgs.pre-commit;
        ```
      '';
    }
  ];
}

Each entry includes:

  • date: When the change was introduced (YYYY-MM-DD)
  • title: Short summary of what changed
  • when: Condition for showing this changelog (show only to affected users)
  • description: Markdown-formatted details and migration steps

After running devenv update, relevant new changelogs are displayed automatically:

$ devenv update
...

📋 changelog

2025-11-24: **git-hooks.package now defaults to pkgs.prek**

  The git-hooks integration now uses prek by default.

  If you were using pre-commit hooks, update your configuration:
    git-hooks.package = pkgs.pre-commit;

The when condition ensures changelogs only appear to users who have the relevant feature enabled. A breaking change to PostgreSQL configuration won't bother users who don't use PostgreSQL.

View all relevant changelogs anytime with:

$ devenv changelogs

For module authors

If you maintain devenv modules (either in-tree or as external imports), add changelog entries when making breaking changes. This helps your users stay informed without requiring them to read through commit history or release notes.

See the contributing guide for details.

Profile configuration in devenv.yaml

You can now specify the default profile in devenv.yaml or devenv.local.yaml:

devenv.yaml
profile: fullstack

This can be overridden with the --profile CLI flag.

SecretSpec 0.4.0

We've released SecretSpec 0.4.0 with two major features: multiple provider support and file-based secrets.

Multiple providers with fallback chains

You can now configure different providers for individual secrets, with automatic fallback:

secretspec.toml
[profiles.production]
DATABASE_URL = { description = "Production DB", providers = ["prod_vault", "keyring"] }
API_KEY = { description = "API key", providers = ["env"] }

Define provider aliases in your user config:

$ secretspec providers add prod_vault onepassword://vault/Production
$ secretspec providers add shared_vault onepassword://vault/Shared

When multiple providers are specified, SecretSpec tries each in order until it finds the secret. This enables:

  • Shared vs local: Try a team vault first, fall back to local keyring
  • Migration: Gradually move secrets between providers
  • Multi-source setups: Projects that need to source secrets from different providers

Combine that with profile-level defaults to avoid repetition:

[profiles.production.defaults]
providers = ["prod_vault", "keyring"]
required = true

[profiles.production]
DATABASE_URL = { description = "Production DB" }  # Uses default providers
API_KEY = { description = "API key", providers = ["env"] }  # Override

Provisioning secrets as a file

Some tools require secrets as file paths rather than values—certificates, SSH keys, service account credentials.

[profiles.default]
TLS_CERT = { description = "TLS certificate", as_path = true }

With as_path = true, SecretSpec writes the secret value to a secure temporary file and returns the path instead:

$ secretspec get TLS_CERT
/tmp/secretspec-abc123/TLS_CERT

In Nix, we don't want to leak secrets into the world-readable store, so passing them as paths avoids this issue:

devenv.nix
{ pkgs, config, ... }: {
  services.myservices.certPath = config.secretspec.secrets.TLS_CERT;
}

Temporary files are automatically cleaned up when the resolved secrets are dropped.

If you haven't tried SecretSpec yet, see Announcing SecretSpec for an introduction.

Getting started

New to devenv? Check out the getting started guide.

Join the devenv Discord community to share feedback!

Domen

devenv 1.10: monorepo Nix support with devenv.yaml imports

devenv 1.10 brings new capabilities for structuring monorepo projects:

Absolute / parent path imports

Paths starting with / are now resolved from your git repository root, and parent imports are also supported (#998).

This lets services consistently reference shared configurations:

services/worker/devenv.yaml
imports:
  - /nix/devenv.nix
  - ../api/devenv.nix

This is particularly handy in monorepos where projects are nested at different depths:

my-monorepo/
├── nix/
│   └── devenv.nix       # Shared base configuration
├── services/
│   ├── api/
│   │   └── devenv.yaml  # imports: [/nix]
│   └── worker/
│       └── devenv.yaml  # imports: [/nix]
└── apps/
    └── web/
        └── devenv.yaml  # imports: [/nix]

All three projects reference /nix regardless of their location.

Git root prefixing

The new config.git.root variable provides the git repository root path for specifying working directories in tasks and processes (#1850, #316).

services/api/devenv.nix
{ config, ... }: {
  tasks."db:migrate" = {
    exec = "npm run migrate";
    cwd = "${config.git.root}/services/api";
  };

  processes.api = {
    exec = "npm start";
    cwd = "${config.git.root}/services/api";
  };
}

Useful when reusing modules across different directories.

devenv.yaml imports

Most upvoted feature with 75 votes (#14) is here!

Local imports now load and merge both devenv.nix and devenv.yaml configurations:

shared/devenv.yaml
allowUnfree: true
inputs:
  nixpkgs:
    url: github:NixOS/nixpkgs/nixpkgs-unstable
services/api/devenv.yaml
imports:
  - /shared

The API service inherits the allowUnfree setting and nixpkgs input. Note that this merging only applies to local filesystem imports — imports from inputs still only load Nix configurations (#2205).

devenv.local.yaml support

Just like devenv.local.nix, you can now use devenv.local.yaml for developer-specific overrides (#817).

Both files are git-ignored for local overrides:

devenv.local.yaml
allowUnfree: true

Monorepo guide

Check out the new Monorepo Guide for detailed examples and patterns.

Join the devenv community to share your monorepo experience!

Domen

devenv 1.9: Scaling Nix projects using modules and profiles

Profiles are a new way to organize and selectively activate parts of development environment.

While we try our best to ship sane defaults for languages and services, each team has its own preferences. We're still working on uniform interface for language configuration so you'll be able to customize each bit of the environment.

Typically, these best practices are created using scaffolds, these quickly go out of date and don't have the ability to ship updates in a central place.

On top of that, when developing in a repository with different components, it's handy to be able to activate only part of the development environment.

Extending devenv modules

Teams can define their own set of recommended best practices in a central repository to create even more opinionated environments:

devenv.nix
{ lib, config, pkgs, ... }: {
  options.myteam = {
    languages.rust.enable = lib.mkEnableOption "Rust development stack";
    services.database.enable = lib.mkEnableOption "Database services";
  };

  config = {
    packages = lib.mkIf config.myteam.languages.rust.enable [
      pkgs.cargo-watch
    ];

    languages.rust = lib.mkIf config.myteam.languages.rust.enable {
      enable = true;
      channel = "nightly";
    };

    services.postgres = lib.mkIf config.myteam.services.database.enable {
      enable = true;
      initialScript = "CREATE DATABASE myapp;";
    };
  };
}

We have defined our defaults for myteam.languages.rust and myteam.services.database.

Using Profiles

Once you have your team module defined, you can start using it in new projects:

devenv.yaml
inputs:
  myteam:
    url: github:myorg/devenv-myteam
    flake: false
imports:
- myteam

This automatically includes your centrally managed module.

Since options default to false, you'll need to enable them per project. You can enable common defaults globally and use profiles to activate additional components on demand:

devenv.nix
{ pkgs, config, ... }: {
  packages = [ pkgs.jq ];

  profiles = {
    backend.module = {
      myteam.languages.rust.enable = true;
      myteam.services.database.enable = true;
    };

    frontend.module = {
      languages.javascript.enable = true;
    };

    fullstack.extends = [ "backend" "frontend" ];
  };
}

Let's do some Rust development with the base configuration:

$ devenv --profile backend shell

Using backend profile to launch the database:

$ devenv --profile backend up

Using frontend profile for JavaScript development:

$ devenv --profile frontend shell

Using fullstack profile to get both backend and frontend tools (extends both profiles):

$ devenv --profile fullstack shell

The fullstack profile automatically includes everything from both the backend and frontend profiles through extends. Use ad-hoc environment options to further customize:

$ devenv -P fullstack -O myteam.languages.rust.enable:bool false shell

User and Hostname Profiles

Profiles can activate automatically based on hostname or username:

{
  profiles = {
    hostname."dev-server".module = {
      myteam.services.database.enable = true;
    };

    user."alice".module = {
      myteam.languages.rust.enable = true;
    };
  };
}

When user alice runs devenv shell on dev-server hostname, both her user profile and the hostname profile automatically activate.

This gives teams fine-grained control over development environments while keeping individual setups simple and centralized.

Profile priorities

To keep profile-heavy projects from fighting each other we wrap every profile module in an automatic override priority. The base configuration is applied first, hostname profiles stack on top, then user profiles, and finally any manual --profile flags—if you pass several, the last flag wins. Extends chains apply parents before children so overrides land where you expect.

Here is a simple example where every tier toggles the same option, yet the final value stays deterministic:

{ config, ... }: {
  myteam.services.database.enable = false;

  profiles = {
    hostname."dev-server".module = {
      myteam.services.database.enable = true;
    };

    user."alice".module = {
      myteam.services.database.enable = false;
    };

    qa.module = {
      myteam.services.database.enable = true;
    };
  };
}

Alice starting a shell on dev-server will see the base configuration turn the database off, the hostname profile enable it, her user profile disable it again, and a manual devenv --profile qa shell flip it back on. Even with conflicting assignments, priorities make the outcome predictable and avoid merge conflicts.

Building Linux containers on macOS

Oh, we've also removed restriction so you can now build containers on macOS if you configure a linux builder.

Containers are likely to get a simplification redesign, as we've learned a lot since they were introduced in devenv 0.6.

Getting Started

New to devenv? Start with the getting started guide to learn the basics.

Check out the profiles documentation for complete examples.

Join the devenv Discord community to share how your team uses profiles!

Domen

Closing the Nix Gap: From Environments to Packaged Applications for Rust

This tweet shows a common problem in Nix: "Should I use crate2nix, cargo2nix, or naersk for packaging my Rust application?"

devenv solved this for development environments differently: instead of making developers package everything with Nix, we provide tools through a simple languages.rust.enable. You get cargo, rustc, and rust-analyzer in your shell without understanding Nix packaging.

But when you're ready to deploy, you face the same problem: which lang2nix tool should you use? Developers don't want to compare crate2nix vs cargo2nix vs naersk vs crane—they want a tested solution that works.

devenv now provides languages.rust.import, which packages Rust applications using crate2nix. We evaluated the available tools and chose crate2nix, so you don't have to.

We've done this before. In PR #1500, we replaced fenix with rust-overlay for Rust toolchains because rust-overlay was better maintained. Users didn't need to change anything—devenv handled the transition while keeping the same languages.rust.enable = true interface.

One Interface for All Languages

The typical workflow:

  1. Development: Enable the language (languages.rust.enable = true) to get tools like cargo, rustc, and rust-analyzer.
  2. Packaging: When ready to deploy, use languages.rust.import to package with Nix.

The same pattern works for all languages:

{ config, ... }: {
  # https://devenv.sh/languages
  languages = {
    rust.enable = true;
    python.enable = true;
    go.enable = true;
  };

  # https://devenv.sh/outputs
  outputs = {
    rust-app = config.languages.rust.import ./rust-app {};
    python-app = config.languages.python.import ./python-app {};
    go-app = config.languages.go.import ./go-app {};
  };
}

Starting with Rust

languages.rust.import automatically generates Nix expressions from Cargo.toml and Cargo.lock.

Add the crate2nix input:

$ devenv inputs add crate2nix github:nix-community/crate2nix --follows nixpkgs

Import your Rust application:

{ config, ... }:
let
  # ./app is the directory containing your Rust project's Cargo.toml
  myapp = config.languages.rust.import ./app {};
in
{
  # Provide developer environment
  languages.rust.enable = true;

  # Expose our application inside the environment
  packages = [ myapp ];

  # https://devenv.sh/outputs
  outputs = {
    inherit myapp;
  };
}

Build your application:

$ devenv build outputs.myapp

Other Languages

This API extends to other languages, each using the best packaging tool:

We've also started using uv2nix to provide a similar interface for Python in PR #2115.

That's it

For feedback, join our Discord community.

Domen

devenv devlog: Processes are now tasks

Building on the task runner, devenv now exposes all processes as tasks named devenv:processes:<name>.

Now you can run tasks before or after a process runs - addressing a frequently requested feature for orchestrating the startup sequence.

Usage

Execute setup tasks before the process starts

devenv.nix
{
  processes.backend = {
    exec = "cargo run --release";
  };

  tasks."db:migrate" = {
    exec = "diesel migration run";
    before = [ "devenv:processes:backend" ];
  };
}

When you run devenv up or the individual process task, migrations run first.

Run cleanup after the process stops

devenv.nix
{
  processes.app = {
    exec = "node server.js";
  };

  tasks."app:cleanup" = {
    exec = ''
      rm -f ./server.pid
      rm -rf ./tmp/*
    '';
    after = [ "devenv:processes:app" ];
  };
}

Implementation

Under the hood, process-compose now runs processes through devenv-tasks run --mode all devenv:processes:<name> instead of executing them directly. This preserves all existing process functionality while adding task capabilities.

The --mode all flag ensures that both before and after tasks are executed, maintaining the expected lifecycle behavior.

What's next?

Future work on process dependencies (#2037) will also address native health check support (process-compose#371), eliminating the need for manual polling scripts.

Domen

devenv 1.8: Progress TUI, SecretSpec Integration, Listing Tasks, and Smaller Containers

devenv 1.8 fixes a couple of annoying regressions since the 1.7 release, but also includes several new features:

Progress TUI

We've rewritten our tracing integration to improve reporting on what devenv is doing.

More importantly, devenv is now fully asynchronous under the hood, enabling parallel execution of operations. This means faster performance in scenarios where multiple independent tasks can run simultaneously.

The new progress interface provides real-time feedback on what devenv is doing:

devenv progress bar

We're continuing to improve visibility into Nix operations to give you even better insights into the build process.

SecretSpec Integration

We've integrated SecretSpec, a new standard for declarative secrets management that separates secret declaration from provisioning.

This allows teams to define what secrets applications need while letting each developer, CI system, and production environment provide them from their preferred secure provider.

Learn more in Announcing SecretSpec Declarative Secrets Management.

Task improvements

Listing tasks

The devenv tasks list command now groups tasks by namespace, providing a cleaner and more organized view:

$ devenv tasks list
backend:
  └── lint (has status check)
      └── test
          └── build (watches: src/backend/**/*.py)
deploy:
  └── production
docs:
  └── generate (watches: docs/**/*.md)
      └── publish
frontend:
  └── lint
      └── test (has status check)
          └── build

Running multi-level tasks

You can now run tasks at any level in the hierarchy. By default, tasks run in single mode (only the specified task):

# Run only frontend:build (default single mode)
$ devenv tasks run frontend:build
Running tasks     frontend:build
Succeeded         frontend:build                           5ms
1 Succeeded                         5.75ms

# Run frontend:build with all its dependencies (before mode)
$ devenv tasks run frontend:build --mode before
Running tasks     frontend:build
Succeeded         frontend:lint                            4ms
Succeeded         frontend:test                            10ms
Succeeded         frontend:build                           4ms
3 Succeeded                         20.36ms

# Run frontend:build and all tasks that depend on it (after mode)
$ devenv tasks run frontend:build --mode after
Running tasks     frontend:build
Succeeded         frontend:build                           5ms
Succeeded         deploy:production                        5ms
2 Succeeded                         11.44ms

CLI improvements

Package options support

The CLI now supports specifying single packages via the --option flag (#1988). This allows for more flexible package configuration directly from the command line:

$ devenv shell --option "languages.java.jdk.package:pkg" "graalvm-oracle"

Container optimizations

The CI container ghcr.io/cachix/devenv/devenv:v1.8 has been reduced (uncompressed) from 1,278 MB in v1.7 to 414 MB in v1.8—that's a reduction of over 860 MB (67% smaller!).

This makes devenv container much faster to pull and more efficient in CI/CD pipelines.

Thank You

Join our Discord community to share your experiences and help shape devenv's future!

Domen