Skip to content

Blog

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