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

devenv 1.11: Module changelogs and SecretSpec 0.4.0

devenv 1.11 brings the following improvements:

Module changelogs

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

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

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

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

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

Each entry includes:

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

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

$ devenv update
...

📋 changelog

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

  The git-hooks integration now uses prek by default.

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

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

View all relevant changelogs anytime with:

$ devenv changelogs

For module authors

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

See the contributing guide for details.

Profile configuration in devenv.yaml

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

devenv.yaml
profile: fullstack

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

SecretSpec 0.4.0

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

Multiple providers with fallback chains

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

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

Define provider aliases in your user config:

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

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

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

Combine that with profile-level defaults to avoid repetition:

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

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

Provisioning secrets as a file

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

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

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

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

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

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

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

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

Getting started

New to devenv? Check out the getting started guide.

Join the devenv Discord community to share feedback!

Domen

devenv 1.10: monorepo Nix support with devenv.yaml imports

devenv 1.10 brings new capabilities for structuring monorepo projects:

Absolute / parent path imports

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

This lets services consistently reference shared configurations:

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

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

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

All three projects reference /nix regardless of their location.

Git root prefixing

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

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

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

Useful when reusing modules across different directories.

devenv.yaml imports

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

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

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

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

devenv.local.yaml support

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

Both files are git-ignored for local overrides:

devenv.local.yaml
allowUnfree: true

Monorepo guide

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

Join the devenv community to share your monorepo experience!

Domen

devenv 1.9: Scaling Nix projects using modules and profiles

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

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

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

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

Extending devenv modules

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

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

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

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

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

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

Using Profiles

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

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

This automatically includes your centrally managed module.

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

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

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

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

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

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

$ devenv --profile backend shell

Using backend profile to launch the database:

$ devenv --profile backend up

Using frontend profile for JavaScript development:

$ devenv --profile frontend shell

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

$ devenv --profile fullstack shell

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

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

User and Hostname Profiles

Profiles can activate automatically based on hostname or username:

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

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

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

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

Profile priorities

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

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

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

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

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

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

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

Building Linux containers on macOS

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

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

Getting Started

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

Check out the profiles documentation for complete examples.

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

Domen

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

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

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

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

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

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

One Interface for All Languages

The typical workflow:

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

The same pattern works for all languages:

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

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

Starting with Rust

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

Add the crate2nix input:

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

Import your Rust application:

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

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

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

Build your application:

$ devenv build outputs.myapp

Other Languages

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

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

That's it

For feedback, join our Discord community.

Domen

devenv devlog: Processes are now tasks

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

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

Usage

Execute setup tasks before the process starts

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

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

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

Run cleanup after the process stops

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

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

Implementation

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

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

What's next?

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

Domen

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

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

Progress TUI

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

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

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

devenv progress bar

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

SecretSpec Integration

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

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

Learn more in Announcing SecretSpec Declarative Secrets Management.

Task improvements

Listing tasks

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

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

Running multi-level tasks

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

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

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

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

CLI improvements

Package options support

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

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

Container optimizations

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

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

Thank You

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

Domen

Announcing SecretSpec: Declarative Secrets Management

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

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

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

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

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

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

The Hidden Problem: Conflating Three Concerns

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

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

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

One Spec, Multiple Environments, Different Providers

Imagine you commit a secretspec.toml file that declares:

# secretspec.toml - committed to your repo
[project]
name = "my-app"
revision = "1.0"

[profiles.default]
DATABASE_URL = { description = "PostgreSQL connection string", required = true }
REDIS_URL = { description = "Redis connection string", required = false }
STRIPE_API_KEY = { description = "Stripe API key", required = true }

[profiles.development]
# Inherits from default profile - only override what changes
DATABASE_URL = { default = "postgresql://localhost/myapp_dev" }
REDIS_URL = { default = "redis://localhost:6379" }
STRIPE_API_KEY = { description = "Stripe API key (test mode)" }

[profiles.production]
# Production keeps strict requirements from default profile
Now, here's the magic:

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

Same specification. Different providers. Zero code changes.

Example: One Spec, Three Environments

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

Setting up secretspec for development

First, choose your default provider and profile:

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

Importing secrets

Create secretspec.toml from your existing .env:

$ secretspec init --from dotenv

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

Enable SecretSpec in devenv.yaml:

secretspec:
  enable: true

In devenv.nix:

{ pkgs, lib, config, ... }:

{
  languages.rust.enable = true;

  services.minio = {
    enable = true;
    buckets = [ config.secretspec.secrets.BUCKET_NAME ];
  };
}

Start the minio process:

$ devenv up
✓ Starting minio...

2. CI/CD (GitHub Actions)

# .github/workflows/test.yml
- name: Run tests
  env:
    DATABASE_URL: {{ secrets.TEST_DATABASE_URL }}
    STRIPE_API_KEY: {{ secrets.STRIPE_TEST_KEY }}
  run: |
    secretspec run --provider env --profile production -- npm test

3. Production (Fly.io)

# fly.toml
[processes]
web = "secretspec run --provider env --profile production -- npm start"

# Set secrets using fly CLI:
# fly secrets set DATABASE_URL=postgresql://... STRIPE_API_KEY=sk_live_...
# SecretSpec will read these from environment variables

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

Loading secrets in your application

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

The Rust SDK provides compile-time guarantees:

// Generate typed structs from secretspec.toml
secretspec_derive::declare_secrets!("secretspec.toml");

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Load secrets using the builder pattern
    let secretspec = SecretSpec::builder()
        .with_provider("keyring")  // Can use provider name or URI like "dotenv:/path/to/.env"
        .with_profile(Profile::Production)  // Can use string or Profile enum
        .load()?;

    // Access secrets (field names are lowercased)
    println!("Database: {}", secretspec.secrets.database_url);  // DATABASE_URL → database_url
    println!("Stripe: {}", secretspec.secrets.stripe_api_key);  // STRIPE_API_KEY → stripe_api_key

    // Optional secrets are Option<String>
    if let Some(redis) = &secretspec.secrets.redis_url {
        println!("Redis: {}", redis);
    }

    // Access profile and provider information
    println!("Using profile: {}", secretspec.profile);
    println!("Using provider: {}", secretspec.provider);

    // For backwards compatibility, export as environment variables
    secretspec.secrets.set_as_env_vars();

    Ok(())
}

Add to your Cargo.toml:

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

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

Building SDKs for Other Languages

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

A world of possibilities

We're exploring features for future workflows:

Final words

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

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

Domen

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

devenv 1.7 brings several practical improvements:

Progress on Snix Support

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.

Platform-Specific Configuration

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

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

Tasks Enhancements

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

{
  tasks = {
    "frontend:build" = {
      exec = "npm run build";
      execIfModified = [ "src/**/*.tsx" "src/**/*.css" "package.json" ];
    };

    "backend:compile" = {
      exec = "cargo build --release";
      execIfModified = [ "src/**/*.rs" "Cargo.toml" "Cargo.lock" ];
    };
  };
}

This dramatically speeds up incremental builds by skipping unnecessary work.

Namespace-Based Task Execution

Run all tasks within a namespace using prefix matching:

# Run all frontend tasks
$ devenv tasks run frontend

Model Context Protocol (MCP) Support

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

# Start the MCP server
$ devenv mcp

AI assistants can now:

  • Search for packages and their options
  • Understand devenv's configuration format
  • Generate valid configurations based on your requirements

Quality of Life Improvements

  • Shell Integration: Your shell aliases and functions now work correctly
  • Clean Mode: Fixed shell corruption when using --clean
  • Error Messages: More helpful error messages when commands fail
  • State Handling: Automatically recovers from corrupted cache files
  • Direnv Integration: Fewer unnecessary environment reloads

Upcoming 1.8 Release

Standardized Language Tooling Configuration

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

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

Rust Import Functionality

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

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

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

Async Core

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

Getting Started

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

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

Domen