Skip to content

Blog

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

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:

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.

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.

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

Terminal window
$ 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

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

Terminal window
# 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

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

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

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.

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

Section titled “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

Section titled “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.

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

First, choose your default provider and profile:

Terminal window
$ 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

Create secretspec.toml from your existing .env:

Terminal window
$ secretspec init --from dotenv

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

Section titled “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:

Terminal window
$ devenv up
✓ Starting minio...
.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
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.

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.

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

We’re exploring features for future workflows:

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