devenv 2.0 introduced automatic port allocation. Your dev server starts even when another project is already using port 3000. But now it’s on 3001, your browser still points to 3000, and you’re looking at the wrong app.
devenv 2.3 gives your processes stable <process>.<project>.localhost URLs and makes the terminal interface configurable, from statusline placement and colors to keybindings and log behavior.
Run devenv up and open the URL shown in the TUI: http://web.myapp.localhost for a project named myapp. The URL stays the same even when devenv chooses a different port.
The shared proxy is built on Pingora, Cloudflare’s Rust framework for building proxies and network services. devenv manages the routes as part of its native process manager.
On Linux, devenv asks for sudo authentication to let the proxy listen on port 80.
devenv generates local certificates with mkcert and shows the HTTPS URL in the TUI. Your application keeps serving HTTP; the proxy handles HTTPS for you.
The first setup may ask you to trust the local certificate authority. Restart an already running proxy when first enabling HTTPS.
For services that need to bind a privileged port themselves, the native process manager can grant Linux capabilities while the service keeps running as your user (devenv#3151):
devenv.nix
{
processes.web= {
exec="caddy run";
linux.capabilities= [ "net_bind_service" ];
};
}
devenv shows the requested capabilities and authenticates with sudo once.
Two common requests since 2.0: let me keep my own shell prompt, and let me hide the statusline. In 2.3, you can do both (devenv#3117):
~/.config/devenv/config.yaml
version: 1
shell:
prompt_prefix: false
tui:
statusline:
enabled: false
shell.prompt_prefix: false removes the (devenv) prefix from your shell prompt. tui.statusline.enabled: false hides the statusline, including the persistent bar in devenv shell. Set either one or both.
These are personal preferences that apply across projects. Save the file at ~/.config/devenv/config.yaml, or $XDG_CONFIG_HOME/devenv/config.yaml if you’ve set it, and start a new shell.
You can also change statusline placement and colors, remap shortcuts, and adjust log behavior. See TUI customization for the full configuration.
Run devenv user-config validate to check the file, including key conflicts and statusline formats. Add # yaml-language-server: $schema=https://devenv.sh/devenv.user.schema.json at the top for editor completion.
The log viewer also gained fullscreen search with highlighted matches, vim style scrolling, and a copied line counter.
devenv 2.2 introduced nixpkgs-multiverse pins such as multiverse.cmake."3.16.5". Each pin resolved on its own, so five pins could mean five nixpkgs revisions to fetch and evaluate. multiverse.pins resolves the whole set through the fewest revisions that can serve every requested version:
devenv.nix
{ multiverse,... }:
{
packages=multiverse.pins {
cmake="3.26.4";
bun="0.7.0";
};
}
You get exactly those versions, and Farid Zakaria’s write up explains why the selection is minimal. See pinning for details.
SecretSpec 0.20. Git and Docker credential helpers, inline secret specifications, and five new providers: Azure App Configuration, Kubernetes, EJSON, Fly.io, and Cloudflare Secrets Store. See the release announcement for details.
Better dotenv support. The new dotenv-ng parser runs in the devenv CLI and handles quotes, multiline values, comments, export, and optional variable substitution. It supports ordered loading of several files, files in subdirectories, and files generated by tasks. Dotenv changes participate in evaluation caching and shell hot reloads, while explicit env definitions retain precedence. Older CLIs fall back to the legacy parser when using newer modules.
Arguments for auto-activated shells. Forward shell arguments through the native hook, for example devenv hook fish -- --no-tui (devenv#3128). Bash, zsh, fish, and nushell are supported.
Configurable process shutdown.processes.<name>.shutdown.signal and .grace control how the native manager and process-compose stop and restart a process. PostgreSQL now uses SIGINT for fast shutdown.
Faster garbage collection. With a Nix daemon running 2.35 or newer, devenv gc removes old environments in a single batch and shows progress. The bundled Nix is now 2.35.2.
More reliable cleanup. Processes started as task dependencies are stopped when devenv tasks run exits. A second Ctrl+C no longer abandons processes during shutdown, and temporary shell capture scripts no longer accumulate in .devenv.
Recovery after crashes. A guardian cleans abandoned service sessions, and the next manager reconciles them before starting the same process again. Detached manager state is also kept in .devenv, so devenv processes down works after logging back in.
External process managers. Detached mode is supported by process-compose, Honcho, Hivemind, and Overmind. Unsupported operations fail before launch, and devenv down gracefully stops Overmind and waits for it to exit.
Smaller closure and faster shells. The devenv closure shrank from 528 MB to 376 MB by removing duplicate dependencies. Git hook installation is skipped when installed hooks already match, file watching uses fewer allocations, and GC roots survive moving a project directory.
More useful traces. Process ports, readiness probes, exits, and restarts now appear as structured trace data. OTLP exports Nix evaluator heap and garbage collection metrics, and trace serialization uses fewer allocations.
Better diagnostics. Module errors point at the file that defined the offending option, and devenv tasks list prints a tree with inline descriptions. The test harness reports runtime and shell closure size and can enforce a max_closure_size limit.
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.
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
$devenvup-d# start processes in the background
$devenvup# attach: live status, ports, and logs
$devenvprocessesattach# watch without requesting any starts
$devenvprocessesstartpostgres# 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.
--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:
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--fromgithub:myorg/devenv-configs\
--profilebackend\
--profileobservability\
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:
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).
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.
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.
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:
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.
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
The simplest idea: rewrite every DT_NEEDED entry from a bare soname like
libfoo.so.1 to the absolute store path of the library it resolves to. glibc
has a “slash short circuit”: a DT_NEEDED containing a / is opened directly,
skipping all search. No search means no storm, and not even the glibc-hwcaps
probes happen.
This is well trodden ground:
Farid Zakaria’s shrinkwrap and the
nix-harden-needed tool do exactly
this as external post processing. Shrinkwrap is described in the paper
Mapping Out the HPC Dependency Chaos
(Zakaria, Scogland, Gamblin, Maltzahn, 2022; arXiv:2211.05118), which measures
the storm directly: an Emacs launch drops from 1823 stat/openat syscalls
to 104, a 36 times speedup, and a 900 library MPI application starting across
2048 processes on NFS goes from 344.6s to 47.8s, 7.2 times faster. Those NFS
numbers are the clearest evidence that this overhead, invisible on a warm
local cache, becomes brutal on a network or cold filesystem.
patchelf PR #357
(--shrink-wrap, open since 2021) pulls all transitive DT_NEEDED up onto
the top binary and rewrites them to absolute paths.
Spack has a similar bind feature in the HPC world.
Inside nixpkgs, this mechanism is already used ad hoc in dozens of packages.
The cost is steep on the checklist. Absolute paths lose the LD_LIBRARY_PATH
override, so the glvnd driver swap stops working, and you need an exemption
list for libc, the loader itself, the GL stack, and initrd. There is also no
runtime fallback: if the pinned path is gone, the program does not start.
There is also a build time fork in the road here. To rewrite a soname to an
absolute path you first have to resolve it, and there are two ways to do that:
run the binary’s own loader and record what glibc actually picks, or walk
DT_RUNPATH statically and resolve it yourself. The first is exact but executes
target code, so it cannot cross compile; the second cross compiles cleanly. The
absolute path tooling only ever did the first, which is why it stays a manual,
per package tool rather than a default. The static walk is the same technique
the ELF note cache (approach 3) later builds on.
So absolute paths are the zero disk, maximum speed option, attractive for self
contained leaf applications, but wrong as a default because of the override
semantics.
If the problem is that the loader searches many directories, give it one. The
farm idea, floated early on by Linus Heckemann
(see #24844), is: for each ELF,
create
a single directory of symlinks pointing at exactly the libraries that ELF needs,
and set its DT_RUNPATH to that one directory.
The crucial detail is that the sonames in DT_NEEDED stay short. The farm only
changes where they are found, not how. Because the farm lives in DT_RUNPATH,
which the loader consults after LD_LIBRARY_PATH, every override keeps working.
And it builds with nothing but stock patchelf --set-rpath and symlinks, with
no glibc or patchelf fork, and never executes the target binary, so it cross
compiles.
But keeping the sonames short is also where it breaks the Nix model. A farm
directory is a flat namespace keyed by soname, so it can hold exactly one
libfoo.so.1. When a closure legitimately pulls two different builds of the
same soname (the case Nix exists to allow), the farm cannot represent both, and
glibc’s soname based dedup collapses them to whichever loads first. Absolute
paths (approach 1) sidestep this because the store path becomes the key; the
farm, which deliberately keeps the bare soname, cannot.
The remaining costs are store pollution and the hwcaps floor. Every ELF gains
its own extra directory of symlinks, so the store fills up with farm directories
that shadow the real libraries. And the farm collapses the per directory
multiplier but not the per hwcaps multiplier: the loader still probes
glibc-hwcaps inside the one farm directory. So it is a large constant factor
win, not an asymptotic one.
How large depends entirely on how much of the graph you farm:
Farmed scope
Failing opens
Reduction
imagemagick, binary only (wide 35 dir DT_RUNPATH)
1225 → ~213
83%
devenv, leaf binary only (narrow 12 dir DT_RUNPATH)
486 → 392
19%
devenv, whole graph (every dep built with the hook)
486 → 88
82%
The two devenv rows are the lesson. Farming the leaf alone barely moves the
needle because the storm there is dominated by the 83 libraries resolving each
other, which a leaf only farm never touches. Only whole graph adoption reaches
82%, and the residual 88 are irreducible hwcaps probes rather than real library
searches. So the farm pays off immediately when a package’s own binary has a
wide DT_RUNPATH, but needs whole graph adoption for closure heavy
applications.
Approach 3: a per DSO resolution cache in an ELF note
This is the most ambitious approach and, on the checklist, the best. The idea,
designed by pennae in
#207893: have patchelf write a
small PT_NOTE into each library that records, for each DT_NEEDED soname,
where the loader should find it. A patched glibc reads that note during loading,
between the LD_LIBRARY_PATH step and the DT_RUNPATH walk, and resolves the
dependency straight from it.
Placing the read after LD_LIBRARY_PATH is what makes it safe: overrides,
LD_PRELOAD, and the glvnd swap all keep winning, and soname based dedup is
unchanged because the sonames stay short. Each cache entry is either an exact
path, which is opened directly with no search and therefore no hwcaps probing,
or a directory hint for the rare cases that cannot be resolved at build time
($ORIGIN relative entries, or directories that themselves contain a
glibc-hwcaps tree).
This is the only approach that preserves every semantic, adds zero closure
references, and eliminates the hwcaps floor as well. pennae’s original benchmark
showed an armv7 workload dropping from 44s to 29s (seconds, not ms, measured
under strace -cf) with about 24000 fewer syscalls. In our own end to end test of a revived, cleaned up version, a note
bearing binary resolved its dependency with zero failing search probes,
versus the full storm for the same binary without the note, while the
LD_LIBRARY_PATH override still took precedence.
The price is the heaviest of any approach. It needs two source changes: a
glibc patch so the loader understands the note, and a patchelf change to write
it. It is a staging mass rebuild, because patching glibc rebuilds the world.
pennae’s draft was closed for lack of a go or no go decision rather than any
technical failure; the main worry raised was the long term maintenance of a
glibc patch.
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.
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 ·
fbuildEnv 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.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.
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:
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.
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).
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.
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:
Three OTLP formats are supported: otlp-grpc (built in), otlp-http-protobuf, and otlp-http-json (opt in via cargo features). Endpoints can be set on the flag or via the standard OTEL_EXPORTER_OTLP_* variables.
Trace context propagates across process boundaries: spawned tasks, shell commands, and processes inherit TRACEPARENT and TRACESTATE, so instrumented children show up on the same trace as the parent devenv up run.
--trace-to replaces --trace-output and --trace-format with a single [format:]destination syntax, and accepts multiple destinations:
devenv 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).
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.
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.
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.
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 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
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.
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.
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:
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.
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.
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
$devenvlsp
devenv eval. Evaluate any attribute in devenv.nix and return JSON:
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.
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!
SecretSpec 0.7 introduces declarative secret generation — declare that secrets should be auto-generated when missing, directly in your secretspec.toml.
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.
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:
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.
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.
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).
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.
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--profilebackendshell
Using backend profile to launch the database:
Terminal window
$devenv--profilebackendup
Using frontend profile for JavaScript development:
Terminal window
$devenv--profilefrontendshell
Using fullstack profile to get both backend and frontend tools (extends both profiles):
Terminal window
$devenv--profilefullstackshell
The fullstack profile automatically includes everything from both the backend and frontend profiles through extends. Use ad-hoc environment options to further customize:
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.
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.
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.
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:
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.
The CLI now supports specifying single packages via the --option flag (#1988). This allows for more flexible package configuration directly from the command line:
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.
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?
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.
// 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’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.
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!
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 }}' }}
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.
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!
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.
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
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.
In February 2020, I went on a 16-day, 1200km moped trip across northern Thailand with a couple of friends.
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.
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
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.
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:
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:
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.
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.
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.
We’ve talked to many teams that dropped Nix after a while and they usually fit into two categories:
Maintaining Nix was too complex and the team didn’t fully onboard, creating friction inside the teams.
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:
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 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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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:
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’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.