Skip to content

Domen Kožar

27 posts by Domen Kožar

devenv 2.2: attach to running processes and persistent out-of-tree environments

devenv 2.2 ships:

  • Attach to running processes: share one native process manager across terminals, with live status, ports, logs, and a choice to detach or stop.
  • Persistent out-of-tree environments: bind a directory to a local or remote --from source, including profiles and the source’s complete devenv.yaml.
  • More reliable shell activation: automatically load hooks in fish and nushell, select the correct login shell in stripped environments, and behave predictably in multiplexers and nested shells.
  • SecretSpec 0.17 and Cachix integration: upgrade from SecretSpec 0.8, pull and push to private caches without exporting CACHIX_AUTH_TOKEN, and run the bundled SecretSpec CLI with the same profile and provider as devenv.
  • A calmer and sturdier TUI: use near-zero CPU while idle, distinguish every process state at a glance, and handle unusual terminal input safely.
  • Evaluation cache fixes: invalidate correctly when local inputs, copied sources, and files modified during evaluation change.
  • Better automation interfaces: inspect tasks as JSON, access inputs in the REPL, distinguish trace callers, and avoid streaming interactive output into coding agents.
  • Smaller installs and better diagnostics: remove unnecessary LLVM and debug-toolchain dependencies while surfacing clearer errors in terminals and CI.

Running devenv up while the native process manager is already active now attaches to it instead of failing (devenv#2936). The second invocation connects over the control socket, starts any requested processes that aren’t running yet, and streams status, ports, and logs.

Terminal window
$ devenv up -d # start processes in the background
$ devenv up # attach: live status, ports, and logs
$ devenv processes attach # watch without requesting any starts
$ devenv processes start postgres # start one process, cold-starting a manager if needed

The manager resolves before and after ordering through its own task scheduler. An attached terminal first receives the current state and recent log output, then follows live changes. Other terminals can attach and detach independently without taking ownership away from the daemon.

Ctrl-C on an attached devenv up asks whether you want to detach and leave the processes running, or stop the whole manager. devenv processes attach always detaches on Ctrl-C.

devenv processes start <name> no longer requires a manager to be running. It can cold-start one in the background with that process and its dependency closure, equivalent to devenv up -d <name> (devenv#2930). Named starts always start the requested process, even when its default start.enable is false.

Attaching is deliberately interactive. CI jobs, piped commands, and detected coding agents report that processes are already running instead of entering a live view that could wait indefinitely.

Under the hood, the process manager became the single owner of process state, the daemon pushes attach sessions as an event stream instead of being polled, and every process launch goes through the same task graph. This fixed a family of lifecycle bugs along the way: double launches, processes vanishing from list, stuck relaunches, concurrent daemon startup, and a foreground devenv up corrupting a running daemon’s PID file.

Closes devenv#971, open since February 2024.

Configure an environment once, use it anywhere

Section titled “Configure an environment once, use it anywhere”

--from lets you use a devenv without checking devenv.nix into the project. In 2.1 you had to repeat the flag for every command. In 2.2, devenv allow can bind the current directory to that source:

Terminal window
$ cd my-project
$ devenv --from github:myorg/devenv-configs?dir=rust-web allow
$ devenv shell

Every subsequent devenv command in that directory loads the bound configuration. The native shell hook auto activates it on cd, just like a project with a local devenv.nix.

Profiles persist with the binding:

Terminal window
$ devenv --from github:myorg/devenv-configs \
--profile backend \
--profile observability \
allow

An explicit --profile still takes priority when you need a one-off override.

Local sources now bring their entire configuration with them. --from path:../shared-devenv loads the source’s devenv.yaml, inputs, imports, and sibling modules from its Git repository. Modules are read from the live directory, so edits take effect without fetching or rebinding the source.

Normal in-tree projects are easier to navigate too: devenv now walks up parent directories to find devenv.nix. Commands work from any subdirectory while the shell and devenv shell -- <command> keep the directory where you invoked them (devenv#2232).

The native shell integration added in 2.1 now loads automatically in fish and nushell through their vendor configuration mechanisms. Bash and zsh have no equivalent, so they still need the one-line devenv hook setup in your shell configuration.

Shell selection is more reliable in editor terminals and stripped environments where $SHELL is missing. devenv looks up the user’s login shell before falling back to bash, and warns when a shell explicitly requested through --shell or devenv.yaml is unsupported (devenv#2880, devenv#2992).

Activation and deactivation have also been hardened across real-world shell setups:

  • New tmux and zellij panes, SSH sessions, and nested shells no longer inherit a marker that can close them when they change directory (devenv#2861).
  • Leaving and immediately re-entering a project activates it again on the first try.
  • Fish preserves cd - history and works when zoxide overrides cd (devenv#2853).
  • A manually entered devenv shell, or an environment already loaded by direnv, no longer gets another devenv shell stacked on top.
  • Nushell now deactivates reliably when leaving a project.

If you still use direnv, devenv init --include-envrc (or DEVENV_INCLUDE_ENVRC) adds an .envrc to a new project. Native activation remains the default and requires no project-local activation file.

devenv 2.2 upgrades its SecretSpec integration from 0.8 to SecretSpec 0.17. The release adds scopes, secrets caching, cross-secret validation, GitHub and Forgejo Actions support, and new providers including SOPS, age, KeePass KDBX, OpenBao, Scaleway Secret Manager, and systemd credentials.

Pulling from and pushing to private Cachix caches no longer requires exporting CACHIX_AUTH_TOKEN. devenv can resolve the token through SecretSpec without exposing it to the development shell:

devenv.yaml
secretspec:
enable: true
provider: keyring
cachix_auth_token: true

No declaration in secretspec.toml is required. If SecretSpec does not return a token, devenv falls back to the token stored by the Cachix CLI through cachix authtoken. The resolved token is used for both pulls and the Cachix push daemon.

If your secrets backend grants access to the token under a different name, configure it in devenv.yaml:

devenv.yaml
secretspec:
enable: true
cachix_auth_token: CI_CACHIX_TOKEN

The string is the secret’s name, not the token itself.

The matching secretspec command is now bundled with devenv. The profile and provider selected in devenv.yaml are exported as SECRETSPEC_PROFILE and SECRETSPEC_PROVIDER, so runtime commands use the same configuration that devenv used during evaluation:

Terminal window
$ devenv shell
$ secretspec run -- npm start

Process status at a glance. Each process now shows a status dot whose shape encodes its lifecycle instead of an identical spinner on every row. Waiting, starting, running, ready, stopped, exited, failed, and gave-up states remain distinct. The shape carries the state, so it reads without relying on color.

Near zero idle CPU. devenv up was burning roughly 10 to 15% CPU per managed process even when everything was quiet, because the UI recomputed its layout dozens of times per second. The TUI now only redraws when something actually changes (devenv#2915).

Fuzzed nightly. The TUI gained property-based tests and a nightly terminal fuzzing run, which already flushed out crashes on multi-byte characters, progress overflow, and narrow terminals.

Better terminal rendering. Long structured logs and stack traces wrap instead of being truncated. Shell output is read in larger batches, reducing rendering overhead, and long lines copied from devenv shell no longer acquire a hard newline at the terminal’s wrap point (devenv#2865).

Local path: inputs now participate in cache invalidation. Editing a shared configuration applies on the next command instead of returning stale results until .devenv is deleted.

The cache also tracks local files and directories copied into the Nix store during evaluation. Directories are hashed recursively, so changes to nested scripts and imported source trees invalidate the attributes that depend on them (devenv#2886, devenv#2893).

If a tracked file changes while an evaluation is still running, that result is no longer stored. This closes a race where editing devenv.nix at the wrong moment could leave stale tasks or options behind (devenv#2745).

Other fixes cover newly created files with sub-second timestamps, SQLite caches on VM-mounted filesystems, and shells built through relocated or chrooted Nix stores (devenv#2499, devenv#2947).

devenv down. A shorthand for devenv processes down, mirroring devenv up (devenv#2862).

devenv tasks list --json. Machine readable task graph inspection (devenv#2966).

inputs in the REPL. devenv repl now exposes inputs alongside devenv and pkgs, so you can poke at inputs declared in devenv.yaml directly (e.g. inputs.nixpkgs.lib.version).

Better agent detection. Quiet mode now kicks in for more coding agents (Aider, autonomous and cloud agents, and others) via the detect-coding-agent crate, not just Claude Code. Set DEVENV_NO_AI_AGENT=1 to opt out.

Trace callers. OpenTelemetry spans now identify whether devenv was invoked by the CLI, direnv, or the native shell hook through the devenv.caller attribute (devenv#2965). DEVENV_TRACE_DEFAULT_TO configures a default trace destination without overriding an explicit --trace-to or DEVENV_TRACE_TO.

Statically linking nixd, used by devenv lsp, removes the monolithic LLVM shared library and reduces the devenv closure and container image by roughly 550 MiB. Building libghostty-vt in release mode also avoids pulling a debug toolchain containing Zig and LLVM into every installation.

Non-TUI output now shows useful Nix evaluation and build progress while hiding internal debug noise. Evaluation warnings no longer replace the real error message, devenv test --no-tui preserves test output in CI, and unfree-package errors point to the relevant devenv configuration instead of generic NixOS advice.

devenv shell also terminates its inner shell when the terminal closes instead of leaving a background process at 100% CPU (devenv#2845). GitHub inputs resolve over SSH when a url.insteadOf rule rewrites them, using your SSH agent (devenv#2842).

See the full changelog for the rest.

  • Dropped x86_64-darwin (Intel macOS). devenv is no longer built, tested, or released for Intel Macs. Pin an older devenv release if you’re on one. x86_64-darwin environments still run through Rosetta 2 on Apple Silicon, though nixpkgs 26.05 will be its final supported nixpkgs release.
  • Auto activation detects devenv.nix. The shell hook and devenv allow now look for devenv.nix instead of devenv.yaml. Projects with only a devenv.yaml no longer auto activate; add a devenv.nix to restore activation.

Open an issue or join the Discord with feedback.

Domen

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.

Terminal window
$ 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:

Terminal window
$ 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.

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.

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

Section titled “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

Section titled “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

Section titled “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

Section titled “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.

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).

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

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.

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:

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

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

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.

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).

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

~/.bashrc
eval "$(devenv hook bash)"

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.

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.

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

Terminal window
$ 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.

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.

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:

Terminal window
$ 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:

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

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).

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.

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

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.

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

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

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

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.

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.

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.

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:

Terminal window
$ 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.

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.

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.

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

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

Terminal window
$ 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.

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:

Terminal window
$ devenv lsp

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

Terminal window
$ 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:

Terminal window
$ 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.

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.

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

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.

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.

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.

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)

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 } }

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" } }

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.

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.

Terminal window
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:

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:

Terminal window
$ 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:

Terminal window
$ devenv changelogs

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.

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.

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

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:

Terminal window
$ 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

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:

Terminal window
$ 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.

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:

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.

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.

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).

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

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.

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.

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:

Terminal window
$ devenv --profile backend shell

Using backend profile to launch the database:

Terminal window
$ devenv --profile backend up

Using frontend profile for JavaScript development:

Terminal window
$ devenv --profile frontend shell

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

Terminal window
$ 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:

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

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.

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.

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.

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.

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 {};
};
}

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

Add the crate2nix input:

Terminal window
$ 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:

Terminal window
$ devenv build outputs.myapp

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.

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.

Execute setup tasks before the process starts

Section titled “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.

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

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.

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:

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.

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.

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

Terminal window
$ 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

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

Terminal window
# 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

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

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

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.

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

Domen

Announcing SecretSpec: Declarative Secrets Management

We’ve supported .env integration for managing secrets, but it has several issues:

  • Apps are disconnected from their secrets - applications lack a clear contract about which secrets they need
  • Parsing .env is unclear - comments, multiline values, and special characters all have ambiguous behavior across different parsers
  • Password manager integration is difficult - requiring manual copy-paste or template workarounds
  • Vendor lock-in - applications use custom parsing logic, making it hard to switch providers
  • No encryption - .env files are stored as plain text, vulnerable to accidental commits or unauthorized access

While we could recommend solutions like dotenvx to encrypt .env files or sops for general secret encryption, these bring new challenges:

  • Single key management - requires distributing and managing a master key
  • Trust requirements - everyone with the key can decrypt all secrets
  • Rotation complexity - departing team members require key rotation and re-encrypting all secrets

Larger teams often adopt solutions like OpenBao (the open source fork of HashiCorp Vault), requiring significant infrastructure and operational overhead. Smaller teams face a gap between simple .env files and complex enterprise solutions.

What if instead of choosing one tool, we declared secrets uniformly and let each environment use its best provider?

The Hidden Problem: Conflating Three Concerns

Section titled “The Hidden Problem: Conflating Three Concerns”

We’ve created SecretSpec and integrated it into devenv. SecretSpec separates secret management into three distinct concerns:

  • WHAT - Which secrets does your application need? (DATABASE_URL, API_KEY)
  • HOW - Requirements (required vs optional, defaults, validation, environment)
  • WHERE - Where are these secrets stored? (environment variables, Vault, AWS Secrets Manager)

By separating these concerns, your application declares what secrets it needs in a simple TOML file. Each developer, CI system, and production environment can provide those secrets from their preferred secure storage - without changing any application code.

One Spec, Multiple Environments, Different Providers

Section titled “One Spec, Multiple Environments, Different Providers”

Imagine you commit a secretspec.toml file that declares:

# secretspec.toml - committed to your repo
[project]
name = "my-app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "PostgreSQL connection string", required = true }
REDIS_URL = { description = "Redis connection string", required = false }
STRIPE_API_KEY = { description = "Stripe API key", required = true }
[profiles.development]
# Inherits from default profile - only override what changes
DATABASE_URL = { default = "postgresql://localhost/myapp_dev" }
REDIS_URL = { default = "redis://localhost:6379" }
STRIPE_API_KEY = { description = "Stripe API key (test mode)" }
[profiles.production]
# Production keeps strict requirements from default profile

Now, here’s the magic:

  • You (on macOS): Store it in Keychain, retrieve with secretspec --provider keyring run -- cmd args
  • Your teammate (on Linux): Store it in GNOME Keyring, same command works
  • That one developer: Still uses a .env file locally (we don’t judge, we’ve been there)
  • CI/CD: Reads from environment variables in GitHub Actions secretspec --provider env run -- cmd args
  • Production: Secrets get provisioned using AWS Secret Manager

Same specification. Different providers. Zero code changes.

Let’s walk through migrating from .env to SecretSpec.

First, choose your default provider and profile:

Terminal window
$ secretspec config init
? Select your preferred provider backend:
> keyring: Uses system keychain (Recommended)
onepassword: OnePassword password manager
dotenv: Traditional .env files
env: Read-only environment variables
lastpass: LastPass password manager
? Select your default profile:
> development
default
none
Configuration saved to ~/.config/secretspec/config.toml

Create secretspec.toml from your existing .env:

Terminal window
$ secretspec init --from dotenv

1. Local Development with devenv (You’re on macOS)

Section titled “1. Local Development with devenv (You’re on macOS)”

Enable SecretSpec in devenv.yaml:

secretspec:
enable: true

In devenv.nix:

{ pkgs, lib, config, ... }:
{
languages.rust.enable = true;
services.minio = {
enable = true;
buckets = [ config.secretspec.secrets.BUCKET_NAME ];
};
}

Start the minio process:

Terminal window
$ devenv up
Starting minio...
.github/workflows/test.yml
- name: Run tests
env:
DATABASE_URL: {{ secrets.TEST_DATABASE_URL }}
STRIPE_API_KEY: {{ secrets.STRIPE_TEST_KEY }}
run: |
secretspec run --provider env --profile production -- npm test
fly.toml
[processes]
web = "secretspec run --provider env --profile production -- npm start"
# Set secrets using fly CLI:
# fly secrets set DATABASE_URL=postgresql://... STRIPE_API_KEY=sk_live_...
# SecretSpec will read these from environment variables

Notice what didn’t change? Your secretspec.toml. Same specification, different providers, zero code changes.

While secretspec run provides secrets as environment variables, your application remains disconnected from knowing which secrets it requires. The Rust SDK bridges this gap by providing type-safe access to your declared secrets.

The Rust SDK provides compile-time guarantees:

// Generate typed structs from secretspec.toml
secretspec_derive::declare_secrets!("secretspec.toml");
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load secrets using the builder pattern
let secretspec = SecretSpec::builder()
.with_provider("keyring") // Can use provider name or URI like "dotenv:/path/to/.env"
.with_profile(Profile::Production) // Can use string or Profile enum
.load()?;
// Access secrets (field names are lowercased)
println!("Database: {}", secretspec.secrets.database_url); // DATABASE_URL → database_url
println!("Stripe: {}", secretspec.secrets.stripe_api_key); // STRIPE_API_KEY → stripe_api_key
// Optional secrets are Option<String>
if let Some(redis) = &secretspec.secrets.redis_url {
println!("Redis: {}", redis);
}
// Access profile and provider information
println!("Using profile: {}", secretspec.profile);
println!("Using provider: {}", secretspec.provider);
// For backwards compatibility, export as environment variables
secretspec.secrets.set_as_env_vars();
Ok(())
}

Add to your Cargo.toml:

[dependencies]
secretspec = "0.2.0"
secretspec_derive = "0.2.0"

The application code never specifies where to get secrets - only what it needs through the TOML file. This keeps your application logic clean and portable.

We’d love to see more SDKs that bring this same declarative approach to Python, JavaScript, Go, and other languages.

We’re exploring features for future workflows:

Let’s make secret management as declarative as package management. Let’s stop sharing .env files over Slack. Let’s build better tools for developers.

Share your thoughts on our Discord community or open an issue on GitHub. We’d love to hear how you handle secrets in your team.

Domen

devenv 1.7: CUDA Support, Enhanced Tasks, and MCP support

devenv 1.7 brings several practical improvements:

We’ve started work on supporting multiple Nix implementations in devenv. The codebase now includes a backend abstraction layer that will allow users to choose between different Nix implementations.

This architectural change paves the way for integrating Snix (our development fork). While the Snix backend isn’t functional yet, the groundwork is in place for building out this Rust-based reimplementation to the C++ Nix implementation. See PR #1950 for implementation details.

Here’s how to enable CUDA support only on Linux systems while keeping your environment working smoothly on macOS:

  • CUDA-enabled packages are built with GPU support on Linux
  • macOS developers can still work on the same project without CUDA
  • The correct CUDA capabilities are set for your target GPUs
devenv.yaml
nixpkgs:
config:
allowUnfree: true
x86_64-linux:
cudaSupport: true
cudaCapabilities: ["7.5", "8.6", "8.9"]

Tasks now skip execution when their input files haven’t changed, using the new execIfModified option:

{
tasks = {
"frontend:build" = {
exec = "npm run build";
execIfModified = [ "src/**/*.tsx" "src/**/*.css" "package.json" ];
};
"backend:compile" = {
exec = "cargo build --release";
execIfModified = [ "src/**/*.rs" "Cargo.toml" "Cargo.lock" ];
};
};
}

This dramatically speeds up incremental builds by skipping unnecessary work.

Run all tasks within a namespace using prefix matching:

Terminal window
# Run all frontend tasks
$ devenv tasks run frontend

devenv now includes a built-in MCP server that enables AI assistants like Claude to better understand and generate devenv configurations:

Terminal window
# Start the MCP server
$ devenv mcp

AI assistants can now:

  • Search for packages and their options
  • Understand devenv’s configuration format
  • Generate valid configurations based on your requirements
  • Shell Integration: Your shell aliases and functions now work correctly
  • Clean Mode: Fixed shell corruption when using --clean
  • Error Messages: More helpful error messages when commands fail
  • State Handling: Automatically recovers from corrupted cache files
  • Direnv Integration: Fewer unnecessary environment reloads

Standardized Language Tooling Configuration

Section titled “Standardized Language Tooling Configuration”

All language modules will support the same configuration pattern (PR #1974):

{
languages.rust.dev = {
lsp.enable = false;
debugger.enable = false;
linter.enable = false;
formatter.enable = false;
};
}

Import Rust projects and their dependencies as Nix packages with the new languages.rust.import configuration (PR #1946):

{
languages.rust.enable = true;
languages.rust.import = {
mypackage = {
root = ./.;
};
};
packages = languages.rust.import.mypackage.packages;
}

That allows us to bridge the gap between developer environments and fully packaged Rust applications using Nix.

Operations that can run in parallel will (PR #1970).

Join our Discord community to share your experiences and help shape devenv’s future.

We’re particularly interested in feedback on the standardized language tooling configuration coming in 1.8 - let us know if this approach works for your use cases!

Domen

devenv 1.6: Extensible Ad-Hoc Nix Environments

devenv 1.6 has been tagged, allowing you to:

  • Create temporary environments directly from the command line without requiring a devenv.nix file.
  • Temporarily modify existing environments.

Developer environments on demand using the new --option (-O) flag:

Terminal window
$ devenv --option languages.python.enable:bool true \
--option packages:pkgs "ncdu git ripgrep" \
shell

This command creates a temporary Python environment without writing any configuration files.

Ad-hoc environments are ideal for quickly testing languages or tools without committing to a full project setup:

Terminal window
$ devenv -O languages.elixir.enable:bool true shell iex

The --option flag supports multiple data types, making it flexible for various use cases:

  • :string for text values
  • :int for integers
  • :float for decimal numbers
  • :bool for true/false values
  • :path for file paths
  • :pkgs for specifying Nix packages

One of the most powerful applications of ad-hoc environments is in CI pipelines, where you can easily implement testing matrices across different configurations:

jobs:
test:
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11']
steps:
- uses: actions/checkout@v3
- uses: cachix/install-nix-action@v31
- uses: cachix/cachix-action@v16
with:
name: devenv
- name: Install devenv.sh
run: nix profile install nixpkgs#devenv
- name: Test with Python {{ '${{ matrix.python-version }}' }}
run: |
devenv --option languages.python.enable:bool true \
--option languages.python.version:string {{ '${{ matrix.python-version }}' }} \
test

This approach lets you validate your code across multiple language versions or dependency combinations without maintaining separate configuration files for each scenario.

When used with an existing devenv.nix file, --option values override the configuration settings in the file, making it easy to temporarily modify your environment.

Ad-hoc options are perfect for switching between predefined profiles in your development environment:

Terminal window
$ devenv --option profile:string backend up

This enables you to switch between frontend, backend, or other custom profiles without modifying your configuration files.

See our Profiles guide for more details on setting up and using profiles.

For complete documentation on this feature, visit our Ad-hoc Developer Environments guide.

We’re excited to see how you’ll use ad-hoc environments to streamline your development workflow. Share your feedback on GitHub or join our Discord community!

devenv 1.5: Overlays Support and Performance Improvements

In this release, we’re introducing a powerful Nix concept: overlays for modifying and extending the nixpkgs package set, along with significant performance and TLS certificate improvements.

Overlays allow you to modify or extend the default package set (pkgs) that devenv uses. This is particularly useful when you need to:

  • Apply patches to existing packages
  • Use different versions of packages than what’s provided by default
  • Add custom packages not available in nixpkgs
  • Use packages from older nixpkgs versions

Here’s an example of using overlays in your devenv.nix file to apply a patch to the hello package:

{ pkgs, ... }:
{
# Define overlays to modify the package set
overlays = [
# Override an existing package with a patch
(final: prev: {
hello = prev.hello.overrideAttrs (oldAttrs: {
patches = (oldAttrs.patches or []) ++ [ ./hello-fix.patch ];
});
})
];
# Use the modified packages
packages = [ pkgs.hello pkgs.my-tool ];
}

Using packages from a different nixpkgs version

Section titled “Using packages from a different nixpkgs version”

You can even use packages from a different nixpkgs version by adding an extra input to your devenv.yaml:

inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
nixpkgs-unstable:
url: github:nixos/nixpkgs/nixpkgs-unstable

And then using it in your devenv.nix:

{ pkgs, inputs, ... }:
{
overlays = [
(final: prev: {
nodejs = (import inputs.nixpkgs-unstable {
system = prev.stdenv.system;
}).nodejs;
})
];
# Now you can use the unstable version of Node.js
languages.javascript.enable = true;
}

For more details and examples, check out the overlays documentation.

TLS Improvements: Native System Certificates

Section titled “TLS Improvements: Native System Certificates”

We’ve heard from ZScaler how they are using devenv and we’ve fixed their major annoyance by ensuring devenv now respects system certificates that many enterprises rely on.

macOS Development Enhancements: Custom Apple SDK Support

Section titled “macOS Development Enhancements: Custom Apple SDK Support”

For macOS developers, we’ve added the ability to customize which Apple SDK is used for development:

{ pkgs, ... }:
{
apple.sdk = pkgs.apple-sdk_15;
}

This allows you to:

  • Control exactly which version of the SDK to use
  • Ensure consistency across development environments
  • Avoid incompatibilities between different macOS versions

Sander further tweaked the performance of developer environment activation at OceanSprint when it can be cached:

  • Linux: ~500ms -> ~150ms
  • macOS: ~1300ms -> ~300ms

Join our Discord to share feedback and suggestions!

Domen

devenv 1.4: Generating Nix Developer Environments Using AI

One of the main obstacles in using Nix for development environments is mastering the language itself. It takes time to become proficient writing Nix.

How about using AI to generate it instead:

$ devenv generate a Python project using Torch
• Generating devenv.nix and devenv.yaml, this should take about a minute ...

You can also use devenv.new to generate a new environment.

Generating devenv.nix for an existing project

Section titled “Generating devenv.nix for an existing project”

You can also tell devenv to create a scaffold based on your existing git source code:

$ devenv generate
• Generating devenv.nix and devenv.yaml, this should take about a minute ...

To continually enhance the AI’s recommendations, we collect anonymous data on the environments generated. This feedback helps us train better models and improve accuracy.

Of course, your privacy matters—if you prefer not to participate, just add the --disable-telemetry flag when generating environments. We also adhere to the donottrack standard.

Domen

devenv is switching its Nix implementation to Tvix

In February 2020, I went on a 16-day, 1200km moped trip across northern Thailand with a couple of friends.

Somewhere in northern Thailand
Somewhere in northern Thailand near Pai.

As we drove for hours on end, I was listening to an audiobook fittingly called Crossing the Chasm. The book explores the challenges faced by nacent technologies on their way to mainstream adoption.

Crossing the chasm

In the years that followed, I couldn’t help noticing the disconnect between Nix’s devoted user base and its apparent lack of widespread adoption in the broader tech community.

Over 2021 and 2022, I focused my efforts and started nix.dev, a resource for practical and accessible Nix tutorials, thinking that good documentation was the thing holding Nix back. Eventually, I came to the realization that improving documentation alone will only get us so far.

We needed to fix the foundations.

We needed to remove things to reduce the cognitive load when using Nix.

Over the years at Cachix we’ve talked to team after team abandoning Nix and have observed a surprisingly consistent pattern.

Nix is initially introduced by someone enthusiastic about the technology. Then, faced with a steep adoption curve, it is abandoned after backlash from the rest of the team.

Making it trivial for a project to adopt and maintain a development environment is crucial for other team members to see the benefits of Nix.

For example, Shopify was vocal about Nix way back in 2020, but eventually went quiet. Having companies like Shopify adopt Nix would be a major step forward for the whole ecosystem.

An interface as the heart of the Developer Experience

Section titled “An interface as the heart of the Developer Experience”

Since the 0.1 release two years ago, we’ve been rapidly iterating on a declarative interface for developer environments. We now have support for over 50 languages and 30 services:

devenv.nix
{ pkgs, config, ... }: {
packages = [
pkgs.cargo-watch
];
languages.rust = {
enable = true;
channel = "nightly";
rustflags = "-Z threads=8";
targets = [ "wasm32-unknown-unknown" ];
};
processes = {
backend.exec = "cargo watch -x run";
};
services = {
postgresql.enable = true;
};
}

With the introduction of tasks in the 1.2 release and Nix caching in 1.3, we’re pretty happy with the devenv command-line interface and the extensible nature of the module system.

The modular architecture of the module system allows for seamless addition, modification, and removal of configuration options. This flexibility extends to defining your own options for the software you’re writing.

We’ve been using the Nix command-line interface under the hood as a low-level API to the evaluator and Nix store. We would’ve preferred to use something akin to an SDK instead, however the command-line interface was the most sensible interface two years ago out of the available options.

The new C FFI (Foreign Function Interface) could potentially grow into a viable solution, but it would necessitate substantial development effort and still leave us vulnerable to memory-safety issues. Moreover, the architecture of the Nix codebase is structured more as a monolithic framework rather than a modular library.

Ideally, if we’re committing to fixing the developer experience over the next years, we’d want to have Nix implemented as a library in Rust.

Fortunately, such a project already exists and it’s called Tvix. Started by flokli and tazjin in “Rewriting Nix”, Tvix is a re-implementation of Nix in Rust, offering both memory-safety and a library-oriented architecture with independently usable components. Leveraging Rust’s abstractions and ecosystem (e.g. tracing.rs), Tvix is positioned to significantly enhance the developer experience for devenv developers and users.

There are many architectural differences besides the obvious “Rewrite In Rust” cliche, so we’ll talk about them as we start replacing our Nix command-line calls with Tvix libraries, starting with the evaluator.

The Nix evaluator directly traverses the abstract syntax tree (AST) during evaluation, while Tvix uses a bytecode virtual machine crafted according to the Crafting Interpreters book.

Tvix compiles Nix code into compact bytecode, then executes it in a virtual machine. This two-step approach offers potential performance benefits and optimization opportunities, like many other interpreted languages.

When you re-evaluate devenv.nix, you’re most likely changing devenv.nix and not one of the few dozen Nix files that come from the devenv repository, or even the few thousand Nix files from the nixpkgs repository that could all be cached as bytecode.

In order to integrate the Tvix evaluator with devenv we’ll need to:

  • Finish implementing builtins.fetchTree, where we have some ideas on how to simplify the caching layer and get rid of the annoying dependency on GitHub’s rate-limited api.github.com endpoint.
  • Implement an evaluation debugger that will allow inspecting a program’s state in case of errors.
  • Finish implementing tvix-eval-jobs that will be used for regression tests against nixpkgs to make sure that the evaluator behaves correctly.
  • Create debug tooling for when we discover regressions in the evaluator.
  • Integrate a nix-daemon layer to schedule builds.

We also recently streamed a Let’s explore the Tvix evaluator video for those interested in digging into the code.

Using language-specific package managers as the build system

Section titled “Using language-specific package managers as the build system”

Once we’ve integrated the evaluator, we can finally generalize building languages using Nix reproducible builds by running the underlying build system to generate Nix expressions:

graph TD
A[devenv];
A -->|Rust| C[Cargo];
A -->|JavaScript| D[npm];
A -->|PHP| E[Composer];
C -->|Cargo.lock| F{Nix};
D -->|package.json| F{Nix};
E -->|composer.lock| F{Nix};

In Build Systems à la Carte, Nix is labelled as a suspending task scheduler.

In the general case, the dependency graph is computed statically, but a dependency can declare its dependencies dynamically as part of the build by returning more Nix code.

That’s when evaluation and build phases start to mix, with evaluation depending on the result of a build, which is typically called import from derivation (as the naming comes from the implementation).

sequenceDiagram
autonumber
participant NixEvaluator as Nix evaluator
participant NixStore as Nix store
NixEvaluator->>NixEvaluator: evaluate
NixEvaluator->>NixStore: write derivation
NixStore->>NixStore: build
NixStore->>NixEvaluator: read derivation output
NixEvaluator->>NixEvaluator: evaluate

Since evaluation in Nix is single-threaded, the process described above gets blocked on each build requested during evaluation.

Implementing parallel evaluation in Tvix, after we figure out the architectural details of how it should work, will unlock the ability to support automatic conversion of language-specific build systems into Nix without sacrificing neither the developer experience, nor memory safety.

As we embark on this new chapter with Tvix, I’m reminded of the journey that brought us here. It’s been a decade since I wrote the we can do better blog post, highlighting the potential for improvement in configuration management and development environments, and I’m glad to see it all finally coming together.

Keep an eye out for updates and join the discussion:

Domen

devenv 1.3: Instant developer environments with Nix caching

Hot on the heels of the previous release of tasks, we’re releasing devenv 1.3! 🎉

This release brings precise caching to Nix evaluation, significantly speeding up developer environments.

Once cached, the results of a Nix eval or build can be recalled in single-digit milliseconds.

If any of the automatically-detected inputs change, the cache is invalidated and the build is performed.

Caching comparison

Behind the scenes, devenv now parses Nix’s internal logs to determine which files and directories were accessed during evaluation.

This approach is very much inspired by lorri, but doesn’t require a daemon running in the background.

The caching process works as follows:

  1. During Nix evaluation, devenv parses the Nix logs for any files and directories that are accessed.
  2. For each accessed path, we store:
    • the full path
    • a hash of the file contents
    • the last modification timestamp

This metadata is then saved to a SQLite database for quick retrieval.

When you run a devenv command, we:

  1. Check the database for all previously accessed paths
  2. Compare the current file hashes and timestamps to the stored values
  3. If any differences are detected, we invalidate the cache and perform a full re-evaluation
  4. If no differences are found, we use the cached results, significantly speeding up the process

This approach allows us to efficiently detect changes in your project, including:

  • Direct modifications to Nix files
  • Changes to imported files or directories
  • Updates to files read using Nix built-ins, like readFile or readDir

Comparison with Nix’s built-in flake evaluation cache

Section titled “Comparison with Nix’s built-in flake evaluation cache”

Nix’s built-in flake evaluation caches outputs based on the lock of the inputs, ignoring changes to Nix evaluation that often happen during development workflow.

Let’s take a closer look at how devenv’s new caching system compares to other popular tools in the Nix ecosystem. Running our own cache gives us more control and visibility over the caching process, and allows us to improve our integration with other tools, like direnv.

While lorri pioneered the approach of parsing Nix’s internal logs for caching, devenv builds on this concept, integrating caching as a built-in feature that works automatically without additional setup.

These tools excel at caching evaluated Nix environments, but have limitations in change detection:

  • Manual file watching: Users often need to manually specify which files to watch for changes.
  • Limited scope: They typically can’t detect changes in deeply nested imports or files read by Nix built-ins.

To leverage devenv’s caching capabilities with direnv, we’ve updated the .envrc file to utilize devenv’s new caching logic.

If you currently enjoy the convenience of our direnv integration to reload your development environment, make sure to update your .envrc to:

source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k="
use devenv

to benefit from the new caching system.

nix develop currently remains the last bit that’s rather slow and uncacheable, particularly on macOS. We’re working on bringing its functionality in-house to further bring down the overhead of launching a cached shell to under 100ms.

Join us on Discord if you have any questions,

Domen & Sander

devenv 1.2: Tasks for convergent configuration with Nix

For devenv, our mission is to make Nix the ultimate tool for managing developer environments. Nix excels at congruent configuration, where the system state is fully described by declarative code.

However, the real world often throws curveballs. Side-effects like database migrations, one-off tasks such as data imports, or external API calls don’t always fit neatly into this paradigm. In these cases, we often resort to convergent configuration, where we define the desired end-state and let the system figure out how to get there.

To bridge this gap and make Nix more versatile, we’re introducing tasks. These allow you to handle those pesky real-world scenarios while still leveraging Nix’s powerful ecosystem.

Tasks interactive example

For example if you’d like to execute python code after virtualenv has been created:

devenv.nix
{ pkgs, lib, config, ... }: {
languages.python.enable = true;
languages.python.venv.enable = true;
tasks = {
"python:setup" = {
exec = "python ${pkgs.writeText "setup.py" ''
print("hello world")
''}";
after = [ "devenv:python:virtualenv" ];
};
"devenv:enterShell".after = [ "python:setup" ];
};
}

python:setup task executes before devenv:enterShell but after python:virtualenv task:

For all supported use cases see tasks documentation.

We’ve talked to many teams that dropped Nix after a while and they usually fit into two categories:

    1. Maintaining Nix was too complex and the team didn’t fully onboard, creating friction inside the teams.
    1. Went all-in Nix and it took a big toll on the team productivity.

While devenv already addresses (1), bridging the gap between Nix provided developer environments and existing devops tooling written in your favorite language is still an unsolved problem until now.

We’ve designed Task Server Protocol so that you can write tasks using your existing automation by providing an executable that exposes the tasks to devenv:

devenv.nix
{ pkgs, ... }:
let
myexecutable = pkgs.rustPlatform.buildRustPackage rec {
pname = "foo-bar";
version = "0.1";
cargoLock.lockFile = ./myexecutable/Cargo.lock;
src = pkgs.lib.cleanSource ./myexecutable;
}
in {
task.serverProtocol = [ "${myexecutable}/bin/myexecutable" ];
}

In a few weeks we’re planning to provide Rust TSP SDK with a full test suite so you can implement your own abstraction in your language of choice.

You can now use your preferred language for automation, running tasks with a simple devenv tasks run <names> command. This flexibility allows for more intuitive and maintainable scripts, tailored to your team’s familiarity.

For devenv itself, we’ll slowly transition from bash to Rust for internal glue code, enhancing performance and reliability. This change will make devenv more robust and easier to extend, ultimately providing you with a smoother development experience.

If you run devenv update on your existing repository you should already be using tasks, without needing to upgrade to devenv 1.2.

Domen

devenv 1.1: Nested Nix outputs using the module system

devenv 1.1 brings support for Nix outputs, matching the last missing piece of functionality with Flakes.

It was designed to make outputs extensible, nested, and buildable as a whole by default.

This allows exposing Nix packages for installation/consumption by other tools.

If you have a devenv with outputs like this:

devenv.nix
{ pkgs, ... }: {
outputs = {
myproject.myapp = import ./myapp { inherit pkgs; };
git = pkgs.git;
};
}

You can build all outputs by running:

Terminal window
$ devenv build
/nix/store/mzq5bpi49h26cy2mfj5a2r0q69fh3a9k-git-2.44.0
/nix/store/mzq5bpi49h26cy2mfj5a2r0q71fh3a9k-myapp-1.0

Or build specific attribute(s) by listing them explicitly:

Terminal window
$ devenv build outputs.git
/nix/store/mzq5bpi49h26cy2mfj5a2r0q69fh3a9k-git-2.44.0

This is useful for tools that need to find and install specific outputs.

By default, any derivation specified in outputs nested attributes set is recognized as an output.

You can define custom options as output types in devenv. These will be automatically detected and built:

devenv.nix
{ pkgs, lib, config, ... }: {
options = {
myapp.package = lib.mkOption {
type = config.lib.types.outputOf lib.types.package;
description = "The package for myapp";
default = import ./myapp { inherit pkgs; };
defaultText = "myapp-1.0";
};
};
config = {
outputs.git = pkgs.git;
}
}

Building will pick up all outputs, in this case myapp.package and outputs.git:

Terminal window
$ devenv build
/nix/store/mzq5bpi49h26cy2mfj5a2r0q69fh3a9k-myapp-1.0
/nix/store/mzq5bpi49h26cy2mfj5a2r0q69fh3a9k-git-2.44.0

If you don’t want to specify the output type, you can just use config.lib.types.output.

If you import another devenv.nix file, the outputs will be merged together, allowing you to compose a developer environment and outputs in one logical unit.

You could also import outputs from other applications as inputs instead of composing them.

Leave a thumbs on the issue if you’d like to see it happen.

See Outputs section in documentation for the latest comprehensive guide to outputs.

We’re on Discord if you need help, Domen

devenv 1.0: Rewrite in Rust

We have just released devenv 1.0! 🎉

This is a rewrite of the CLI to Python Rust, which brings with it many new features and improvements.

I would like to thank mightyiam for a week-long, Rust pair-programming session at Thaiger Sprint.

Note: Read the migration guide at the end of this post, as 1.0 is not entirely backwards compatible.

When I started to write this blog post for the Python rewrite, I came up with only excuses as to why it is not fast and realized that we were simply breaking our promise to you.

The second reason is that in the Nix community there has been a lot of controversy surrounding flakes (that’s for another blog post); two years ago, the tvix developers decided to do something about it and started a rewrite of Nix in Rust. This leaves us with the opportunity in the future to use the same Rust libraries and tooling.

There are many contributions in this release, spanning over a year, but here are some of the highlights:

process-compose is now the default process manager

Section titled “process-compose is now the default process manager”

devenv up is now using process-compose, as it handles dependencies between processes and provides a nice ncurses interface to view the processes and their logs.

Testing has been a major focus of this release, and a number of features have been added to make it easier to write and run tests.

The new enterTest attribute in devenv.nix allows you to define testing logic:

{ pkgs, ... }: {
packages = [ pkgs.ncdu ];
services.postgres = {
enable = true;
listen_addresses = "127.0.0.1";
initialDatabases = [{ name = "mydb"; }];
};
enterTest = ''
wait_for_port 5432
ncdu --version | grep "ncdu 2.2"
'';
}

When you run devenv test, it will run the enterTest command and report the results.

If you have any processes defined, they will be started and stopped.

Read more about this in the testing documentation.

This allows for executing tests with all of your tooling and processes running—extremely convenient for integration and functional tests.

Since nixpkgs-unstable has fairly few tests, we have created devenv-nixpkgs to run tests on top of nixpkgs-unstable—applying patches we are upstreaming to address any issues.

We run around 300 tests across different languages and processes to ensure all regressions are caught.

Generated containers now run as a plain user—improving security and unlocking the ability to run software that forbids root.

Due to socket path limits, the DEVENV_RUNTIME environment variable has been introduced: pointing to $XDG_RUNTIME_DIR by default and falling back to /tmp.

First-class support for Python native libraries

Section titled “First-class support for Python native libraries”

This one was the hardest nut to crack.

Nix is known to provide a poor experience when using tools like pip.

A lot of work has been put in here, finally making it possible to use native libraries in Python without any extra effort:

{ pkgs, lib, ... }: {
languages.python = {
enable = true;
venv.enable = true;
venv.requirements = ''
pillow
'';
libraries = [ pkgs.cairo ];
};
}

If you need to add an input to devenv.yaml, you can now do:

devenv inputs add <name> <url>

To update a single input:

devenv update <input>

To build any attribute in devenv.nix:

devenv build languages.rust.package

To run the environment as cleanly as possible while keeping specific variables:

devenv shell --clean EDITOR,PAGER

The default number of cores has been tweaked to 2, and max-jobs to half of the number of CPUs. It is impossible to find an ideal default, but we have found that too much parallelism hurts performance—running out of memory is a common issue.

… plus a number of other additions:

https://devenv.sh 1.0.0: Fast, Declarative, Reproducible, and Composable Developer Environments
Usage: devenv [OPTIONS] <COMMAND>
Commands:
init Scaffold devenv.yaml, devenv.nix, .gitignore and .envrc.
shell Activate the developer environment. https://devenv.sh/basics/
update Update devenv.lock from devenv.yaml inputs. http://devenv.sh/inputs/
search Search for packages and options in nixpkgs. https://devenv.sh/packages/#searching-for-a-file
info Print information about this developer environment.
up Start processes in the foreground. https://devenv.sh/processes/
processes Start or stop processes.
test Run tests. http://devenv.sh/tests/
container Build, copy, or run a container. https://devenv.sh/containers/
inputs Add an input to devenv.yaml. https://devenv.sh/inputs/
gc Deletes previous shell generations. See http://devenv.sh/garbage-collection
build Build any attribute in devenv.nix.
version Print the version of devenv.
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose
Enable debug log level.
-j, --max-jobs <MAX_JOBS>
Maximum number of Nix builds at any time. [default: 8]
-j, --cores <CORES>
Maximum number CPU cores being used by a single build.. [default: 2]
-s, --system <SYSTEM>
[default: x86_64-linux]
-i, --impure
Relax the hermeticity of the environment.
-c, --clean [<CLEAN>...]
Ignore existing environment variables when entering the shell. Pass a list of comma-separated environment variables to let through.
-d, --nix-debugger
Enter Nix debugger on failure.
-n, --nix-option <NIX_OPTION> <NIX_OPTION>
Pass additional options to nix commands, see `man nix.conf` for full list.
-o, --override-input <OVERRIDE_INPUT> <OVERRIDE_INPUT>
Override inputs in devenv.yaml.
-h, --help
Print help
  • devenv container --copy <name> has been renamed to devenv container copy <name>.
  • devenv container --docker-run <name> has been renamed to devenv container run <name>.
  • devenv ci has been renamed to devenv test with a broader scope.
  • .env files must start with the .env prefix.

  • The need for the --impure flag has finally been removed, meaning that devenv is now fully hermetic by default.

    Things like builtins.currentSystem no longer work—you will have to use pkgs.stdenv.system.

    If you need to relax the hermeticity of the environment you can use devenv shell --impure.

  • Since the format of devenv.lock has changed, newly-generated lockfiles cannot be used with older versions of devenv.

There are a number of features that we are looking to add in the future—please vote on the issues:

While devenv is designed to be run on your local machine, we are looking to add support for running devenv inside a container.

Something like:

devenv shell --in-container
devenv test --in-container

This would be convenient when the environment is too complex to set up on your local machine; for example, when running two databases or when you want to run tests in a clean environment.

Generating containers with full environment

Section titled “Generating containers with full environment”

Currently, enterShell is executed only once the container has started. If we want to execute it as part of the container generation, we have to execute it inside a container to generate a layer.

Building containers on macOS is not currently supported, but it should be possible.

Wouldn’t it be cool if devenv could map language-specific dependencies to your local system? In this example, devenv should be able to determine that pillow requires pkgs.cairo:

{ pkgs, lib, ... }: {
languages.python = {
enable = true;
venv.enable = true;
venv.requirements = ''
pillow
'';
};
}

Give devenv a try, and hop on to our discord to let us know how it goes!

Domen

devenv 0.6: Generating containers and instant shell activation

After about two months of active development, I’m happy to announce devenv 0.6 is ready.

This release comes with the most notable improvements based on the feedback from existing users:

While devenv shell provides a simple native developer environment experience, devenv container <name> allows you to generate and copy OCI container into a registry.

Containers are a great way to distribute ready-made applications, leveraging platforms like fly.io to deploy them into production.

An example for Ruby:

devenv.nix
{
name = "simple-ruby-app";
languages.ruby.enable = true;
languages.ruby.version = "3.2.1";
}

We can generate a container called shell that enters the environment, copy it to the local Docker daemon and run it:

$ devenv container shell --docker-run
...
(devenv) bash-5.2# ruby --version
ruby 3.2.1 (2023-02-08 revision 31819e82c8) [x86_64-linux]

You can read more in the new Containers section of the documentation, specifically:

Especially monorepo developer environments can sometimes be even a few gigabytes of size, taking a few seconds for the environment to be activated.

A developer environment should only be built when something changes and if not, the environment can be used instantly using a cached snapshot.

With the latest direnv.net integration, we’ve finally reached that goal by making caching work properly (it will even watch each of your imports for changes!).

In the near future we’ll experiment to improve devenv shell experience.

Hosts and certificates can now be specified declaratively:

{ pkgs, config, ... }:
{
certificates = [
"example.com"
];
hosts."example.com" = "127.0.0.1";
services.caddy.enable = true;
services.caddy.virtualHosts."example.com" = {
extraConfig = ''
tls ${config.env.DEVENV_STATE}/mkcert/example.com.pem ${config.env.DEVENV_STATE}/mkcert/example.com-key.pem
respond "Hello, world!"
'';
};
}

And when you run devenv up to start the processes, these hosts and certificates will be provisioned locally.

For example in devenv.yaml:

allowUnfree: true
inputs:
nixpkgs:
url: github:NixOS/nixpkgs/nixpkgs-unstable
rust-overlay:
url: github:oxalica/rust-overlay
overlays:
- default

Will allow building unfree software and wire up default overlay into pkgs from rust-overlay.

  • Python: Added support for virtualenv creation and poetry by bobvanderlinden.
  • Ruby: First-class support for setting version or versionFile by bobvanderlinden.
  • Go: Received significant improvements by shyim.
  • PHP: Added first-class support for setting version to make it easier to set extensions by shyim.
  • Scala: Now allows changing the package and offers scala-cli as an option if the JDK is too old by domenkozar.
  • R: Added an option to specify the package by adfaure.
  • Rust: Can now find headers for darwin frameworks by domenkozar.
  • OCaml: Allowed using a different version of OCaml by ankhers.
  • Tex Live: Added support by BurNiinTRee.
  • Swift: Added support by domenkozar.
  • Raku: Added support by 0pointerexception.
  • Gawk: Added support by 0pointerexception.
  • Racket: Added support by totoroot.
  • Dart: Added support by domenkozar.
  • Julia: Added support by domenkozar.
  • Crystal: Added support by bcardiff.
  • Unison: Added support by ereslibre.
  • Zig: Added support by ereslibre.
  • Deno: Added support by janathandion.
  • Cassandra: Added by ankhers.

  • CouchDB: Added by MSBarbieri.

  • MariaDB: Corrected user and database handling by jochenmanz.

  • MinIO: Now allows specifying what buckets to provision by shyim.

  • process-compose: Faster shutdown, restart on failure by default, escape env variables properly by thenonameguy.

  • Support assertions in modules by bobvanderlinden.

  • Fix overmind root by domenkozar.

  • Make devenv info output pluggable from devenv modules by domenkozar.

  • Expand the flake guide by sandydoo.

  • Set LOCALE_ARCHIVE when missing by sandydoo.

  • Numerous option documentation fixes by sandydoo.

  • Fix starship integration with a custom config by domenkozar.

  • Test direnv integration with strict bash mode by stephank.

  • Add a shim devenv for flakes integration by rgonzalez.

devenv 0.5

  • devenv search now shows results from the options that can be set in devenv.nix:

devenv search results

  • Rust language support now integrates with fenix to provide stable/nightly/unstable toolchain for cargo, rustc, rust-src, rust-fmt, rust-analyzer and clippy.

  • Python language now sets $PYTHONPATH to point to any installed packages in packages attribute.

  • Ruby langauge support now defaults to the latest version 3.1.x, ships with an example running rails, sets $GEM_HOME and $GEM_PATH environment variables. Next release will support picking any version of Ruby - please leave a thumbs up.

  • jpetrucciani contributed Nim, V and HCL/Terraform languages support.

devenv 0.4

  • New command devenv info shows locked inputs, environment variables, scripts, processes and packages exposed in the environment.

  • Tracebacks are now printed with most relevent information at the bottom.

  • New option process.implementation allows you to choose how processes are run. New supported options are overmind and process-compose.

  • Instead of passing each input separately in devenv.nix, the new prefered and documented way is via inputs argument, for example inputs.pre-commit-hooks.

  • samjwillis97 contributed support for MongoDB.

  • shyim contributed MySQL/MariaDB support.

  • shyim made PHP configuration more configurable, for example you can now set extensions.

  • JanLikar improved PostgreSQL support to expose psql-devenv script for connecting to the cluster.

  • datakurre added robotframework support.

  • Composing using inputs has been fixed.

  • It’s now possible to use devenv on directories with spaces.

  • Update checker is no longer using environment variables to avoid some corner cases.

devenv 0.3

It has been 3 days since 0.2 release, so it’s time for 0.3:

Domen

devenv 0.2

After an intense weekend and lots of incoming contributions, v0.2 is out!

  • All the devenv.nix options you can define now come as an input (instead of being packaged with each devenv release). To update the options you can run devenv update and it will match devenv.nix reference.

  • New devenv search command:

Terminal window
$ devenv search ncdu
name version description
pkgs.ncdu 2.1.2 Disk usage analyzer with an ncurses interface
pkgs.ncdu_1 1.17 Disk usage analyzer with an ncurses interface
pkgs.ncdu_2 2.1.2 Disk usage analyzer with an ncurses interface
Found 3 results.
  • shyim contributed Redis support and is working on MySQL.

  • Languages: raymens contributed dotnet, ankhers contributed Elixir and Erlang support.

  • If devenv.local.nix exists it’s now also loaded, allowing you to override git committed devenv.nix with local changes. Hurrah composability!

  • Variables like env.DEVENV_ROOT, env.DEVENV_STATE and env.DEVENV_DOTFILE are now absolute paths paths
  • shyim fixed /dev/stderr that is in some environments not available.
  • domen fixed shell exiting on non-zero exit status code.

Domen

Hello world: devenv 0.1

After lengthy conversations at NixCon 2022 about Developer Experience and current painpoints around documentation, I’ve started hacking and experimenting.

The goal is to bring the strengths of Nix to the world with what we have best to offer, and I’m happy to announce:

devenv: Fast, Declarative, Reproducible, and Composable Developer Environments

One of the reasons why developer environments are moving into the cloud are the lack of good tooling how to make those environments reproducible.

In the last decade we’ve doubled down on shipping binary blobs in containerized environments.

Just as we went from virtual machines to containers, we can make one step further and create guarantees at the package level and treat those as a building block.

devenv 0.1 release brings the basic building blocks for many possibilities of what can be built in the future.

I invite you to explore the documentation and give it a try.

I’m looking forward in what ways the developer community uses devenv and stay tuned for roadmap updates by subscribing at our newsletter at the bottom of the page.

Domen