Skip to content

Blog

devenv 1.11: Module changelogs and SecretSpec 0.4.0

devenv 1.11 brings the following improvements:

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

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

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

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

Each entry includes:

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

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

Terminal window
$ devenv update
...
📋 changelog
2025-11-24: **git-hooks.package now defaults to pkgs.prek**
The git-hooks integration now uses prek by default.
If you were using pre-commit hooks, update your configuration:
git-hooks.package = pkgs.pre-commit;

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

View all relevant changelogs anytime with:

Terminal window
$ devenv changelogs

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

See the contributing guide for details.

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

devenv.yaml
profile: fullstack

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

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

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

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

Define provider aliases in your user config:

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

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

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

Combine that with profile-level defaults to avoid repetition:

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

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

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

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

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

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

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

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

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

New to devenv? Check out the getting started guide.

Join the devenv Discord community to share feedback!

Domen

devenv 1.10: monorepo Nix support with devenv.yaml imports

devenv 1.10 brings new capabilities for structuring monorepo projects:

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

This lets services consistently reference shared configurations:

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

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

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

All three projects reference /nix regardless of their location.

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

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

Useful when reusing modules across different directories.

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

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

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

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

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

Both files are git-ignored for local overrides:

devenv.local.yaml
allowUnfree: true

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

Join the devenv community to share your monorepo experience!

Domen

devenv 1.9: Scaling Nix projects using modules and profiles

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

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

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

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

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

devenv.nix
{ lib, config, pkgs, ... }: {
options.myteam = {
languages.rust.enable = lib.mkEnableOption "Rust development stack";
services.database.enable = lib.mkEnableOption "Database services";
};
config = {
packages = lib.mkIf config.myteam.languages.rust.enable [
pkgs.cargo-watch
];
languages.rust = lib.mkIf config.myteam.languages.rust.enable {
enable = true;
channel = "nightly";
};
services.postgres = lib.mkIf config.myteam.services.database.enable {
enable = true;
initialScript = "CREATE DATABASE myapp;";
};
};
}

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

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

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

This automatically includes your centrally managed module.

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

devenv.nix
{ pkgs, config, ... }: {
packages = [ pkgs.jq ];
profiles = {
backend.module = {
myteam.languages.rust.enable = true;
myteam.services.database.enable = true;
};
frontend.module = {
languages.javascript.enable = true;
};
fullstack.extends = [ "backend" "frontend" ];
};
}

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

Terminal window
$ devenv --profile backend shell

Using backend profile to launch the database:

Terminal window
$ devenv --profile backend up

Using frontend profile for JavaScript development:

Terminal window
$ devenv --profile frontend shell

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

Terminal window
$ devenv --profile fullstack shell

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

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

Profiles can activate automatically based on hostname or username:

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

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

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

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

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

{ config, ... }: {
myteam.services.database.enable = false;
profiles = {
hostname."dev-server".module = {
myteam.services.database.enable = true;
};
user."alice".module = {
myteam.services.database.enable = false;
};
qa.module = {
myteam.services.database.enable = true;
};
};
}

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

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

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

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

Check out the profiles documentation for complete examples.

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

Domen

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

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

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

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

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

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

The typical workflow:

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

The same pattern works for all languages:

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

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

Add the crate2nix input:

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

Import your Rust application:

{ config, ... }:
let
# ./app is the directory containing your Rust project's Cargo.toml
myapp = config.languages.rust.import ./app {};
in
{
# Provide developer environment
languages.rust.enable = true;
# Expose our application inside the environment
packages = [ myapp ];
# https://devenv.sh/outputs
outputs = {
inherit myapp;
};
}

Build your application:

Terminal window
$ devenv build outputs.myapp

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

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

For feedback, join our Discord community.

Domen

devenv devlog: Processes are now tasks

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

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

Execute setup tasks before the process starts

Section titled “Execute setup tasks before the process starts”
devenv.nix
{
processes.backend = {
exec = "cargo run --release";
};
tasks."db:migrate" = {
exec = "diesel migration run";
before = [ "devenv:processes:backend" ];
};
}

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

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

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

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

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

Domen