Skip to content

2026

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 makes Nix disappear. Not the power, the friction. Here's what that looks like.

Interactive

You shouldn't have to guess what Nix is doing, and your shell shouldn't get hijacked every time a file changes.

Terminal UI

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

Navigate with arrow keys or j/k, expand logs with Ctrl+E, and copy text with mouse selection (works over SSH via OSC 52).

devenv test streams test output live through the TUI, showing each phase (evaluation, building, starting processes, running tests) with timing and status.

Native shell reloading

Before: 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.

After: 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.

When you run devenv shell, it:

  1. Watches all files that were inputs to the Nix evaluation
  2. Rebuilds in the background when changes are detected
  3. Shows a status line with build progress
  4. Waits for you to press Ctrl+Alt+R to apply

Shell reloading is currently supported for bash, with fish and zsh coming soon (#2487). 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. Change a database migration, save, and your local server picks up the new schema without you touching anything. We're building toward that. If any task fails during shell entry, devenv still enters the shell rather than blocking you. direnv is still supported for automatic activation when switching directories; see the direnv integration.

Native process manager

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

devenv.nix
{ config, ... }:

{
  processes = {
    database = {
      exec = "postgres -D $PGDATA";
      ready.exec = "pg_isready -d template1";
    };

    api = {
      exec = "myserver";
      after = [ "database" ];
      ports.http.allocate = 8080;
      ready.http.get = { port = 8080; path = "/health"; };
    };

    frontend = {
      exec = "npm start";
      after = [ "api" ];
    };
  };
}

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

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

Instant

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

This time it takes milliseconds.

devenv has had an evaluation cache since 1.3, but it worked by caching the inputs and outputs of individual nix CLI invocations. A single command like devenv test would spawn five or more separate Nix processes, each evaluating from scratch, and the cache could only replay whole invocations. Change one thing and several of those calls would miss.

devenv 2.0 replaces the old approach of shelling out to nix with a C FFI backend built on nix-bindings-rust. Instead of spawning subprocesses, we call the Nix evaluator and store directly through the C API. This means one evaluation serves all of devenv shell, devenv test, devenv build, and every other command. The evaluation cache now stores the complete output of that single evaluation along with every file and environment variable it touched. On the next run, if 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.

The C FFI backend also gives us faster evaluation, 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.

Composable

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. Before devenv 2.0, each one was its own island.

Polyrepo support

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.nix
{ inputs, ... }:
let
  my-service = inputs.my-service.devenv.config.outputs.my-service;
in {
  packages = [ my-service ];
  processes.my-service.exec = "${my-service}/bin/my-service";
}

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

Out of tree devenvs

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

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

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

Task improvements

Tasks now support per-task environment variables and CLI inputs:

$ devenv tasks run deploy --input environment=staging --input version=2.1.0

And soft dependencies with @complete for tasks that should run regardless of whether their dependency succeeded:

devenv.nix
{
  tasks."cleanup" = {
    exec = "rm -rf ./tmp";
    after = [ "devenv:processes:server@complete" ];
  };
}

For coding agents

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

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

devenv 2.0 fixes both problems.

Automatic port allocation

Define named ports and devenv finds free ones automatically:

devenv.nix
{ config, ... }:

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

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

Secret isolation with SecretSpec

devenv 2.0 ships with SecretSpec 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.

When secrets are missing, the TUI stops gracefully, restores your terminal, and walks you through entering each secret interactively. The prompts support multi-line input, so pasting TLS certificates, SSH keys, and JSON blobs works out of the box. See the SecretSpec integration guide and SecretSpec 0.7: Declarative Secret Generation for details on auto-generation support.

MCP server

We didn't just make devenv work for agents. We gave agents a way to understand devenv.

The MCP server runs over HTTP in addition to stdio:

$ devenv mcp --http 8080

We host a public instance at mcp.devenv.sh that any MCP-compatible AI assistant (Claude Code, Cursor, Windsurf, and others) can query for packages and options without needing a local devenv installation. The Claude Code integration connects to mcp.devenv.sh by default.

devenv.new is a web-based environment generator powered by the same MCP server, letting you scaffold a devenv configuration from your browser.

And more

Language servers. Most language modules now have lsp.enable and lsp.package options, giving you completion and diagnostics for devenv.nix out of the box. Run devenv lsp to start a pre-configured nixd instance.

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

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

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

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 (#1090).

Breaking changes

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

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

Final words

Your shell is instant. Your processes manage themselves. Your environments compose across repos. Your agents can't steal your secrets.

This is what working with Nix should feel like.

If you hit an issue, please open a report. Join the devenv Discord community to share feedback!

Domen

SecretSpec 0.7: Declarative Secret Generation

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

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

The Problem

When onboarding to a project, developers typically need to:

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

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

The Solution: type + generate

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

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

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

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

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

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

Summary: 4 found, 0 missing

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

Five Generation Types

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

Custom Options

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

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

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

Shell Commands

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

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

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

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

Design Decisions

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

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

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

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

Upgrading

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

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

See the configuration reference for full documentation.

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

Domen