In this post, I will go over how you can use Cachix’s devenv tool to help create/set up consistent
repeatable developer environments. You could use nix flakes if you wanted to as well, without needing another tool.
However, I like how devenv provides a few other “tools” within that we can set up from a single devenv.nix
file. Such as
pre-commit hooks, container support etc.
This blog leverages devenv to create/set up its developer environment.
Note: I’m pretty new to using devenv myself so I’m probably going to make a follow-up post as my developer workflow changes.
Why Use devenv
Well, I mainly use it in my projects in two main ways.
To set up pre-commit hooks and to make sure certain binaries and tools are available. Imagine another developer
cloning our project doesn’t need to make sure they have say hugo
or go-task
globally available.
It is set up automatically if they are using devenv
and direnv
.
It can also be used to manage services like Postgresql.
Install Devenv
First I will assume you are using home-manager to manage your nix environment and are using nix flakes.
So first let’s install devenv, go to flake.nix
and add the following input:
inputs = {
devenv.url = "github:cachix/devenv/latest";
};
This will make it available to the rest of our configuration as input. Now my flake config looks like this:
outputs = {
self,
nixpkgs,
home-manager,
...
} @ inputs: let
inherit (self) outputs;
lib = nixpkgs.lib // home-manager.lib;
systems = ["x86_64-linux" "aarch64-linux"];
forEachSystem = f: lib.genAttrs systems (sys: f pkgsFor.${sys});
pkgsFor = nixpkgs.legacyPackages;
in {
inherit lib;
homeConfigurations = {
# Desktops
mesmer = lib.homeManagerConfiguration {
modules = [./hosts/mesmer/home.nix];
pkgs = nixpkgs.legacyPackages.x86_64-linux;
extraSpecialArgs = {inherit inputs outputs;};
};
};
};
The home module inherits the inputs from the flake file, so we can access the devenv input in our home-manager
config. Then in in our home.nix
module we can add something:
{
inputs,
pkgs,
...
}: {
home.packages = [
inputs.devenv.packages."${pkgs.system}".devenv
pkgs.cachix
];
programs.direnv = {
enable = true;
nix-direnv.enable = true;
};
}
In my config, I have this in its module called devenv.nix
, because I like to split up my config.
To install devenv we can do inputs.devenv.packages."${pkgs.system}".devenv
. We also need cachix (I think), so *
installed that from Nix packages. Like we would with any other package.
You will also notice I setup another tool called direnv
, which is a generic tool that allows us to create a new shell
env when we change directories if there is a .envrc
, where we can load things like env variables etc.
However, we can also leverage it to auto-run out devenv development environment i.e. devenv shell
for us.
If set up devenv will create a .envrc
file which contains devenv use
. So when we change the directory, to one with devenv
setup it will set up our devenv environment automatically.
Note: The first time we use direnv we need to run direnv allow
, the output on your shell will remind you to do this.
After this, you can run your normal command to update your state using home-manager i.e.
home-manager switch --flake ~/dotfiles#mesmer
.
Without direnv
we could also run devenv shell
, however, I found this would change my shell to bash whereas I normally
use fish.
Create an env
Okay now that we have devenv installed, let’s set up our first devenv.
First we run devenv init
:
devenv init
Creating .envrc
Creating devenv.nix
Creating devenv.yaml
Appending defaults to .gitignore
Done.
direnv is installed. Running direnv allow.
direnv: loading ~/Downloads/.envrc
direnv: loading https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cfd0f51481d17f77586997/direnvrc (sha256-YBzqskFZxmNb3kYVoKD9ZixoPXJh1C9ZvTLGFRkauZ0=)
direnv: using devenv
direnv: .envrc changed, reloading
Building shell ...
warning: creating lock file '/home/haseeb/Downloads/devenv.lock'
[1/4 built, 1/0/1 copied (4.2/42.7 MiB), 4.1/40.5 MiB DL] fetching git-2.41.0-debug from https://cache.nixos.orgdirenv: ([/nix/store/h77a0hqm3jcfqq7fgs310rf5l9w9g66y-direnv-2.32.3/bin/direnv export fish]) is taking a while to execute. Use CTRL-C to give up.
direnv: updated devenv shell cache
hello from devenv
git version 2.41.0
direnv: export +C_INCLUDE_PATH +DEVENV_DOTFILE +DEVENV_PROFILE +DEVENV_ROOT +DEVENV_STATE +GREET +IN_NIX_SHELL +LIBRARY_PATH +PKG_CONFIG_PATH +name ~LD_LIBRARY_PATH ~PATH ~XDG_CONFIG_DIRS ~XDG_DATA_DIRS
ls -al
If we explore a bit more:
~/Downloads
❯ exa -al
drwxr-xr-x - haseeb 25 Aug 20:00 .devenv
.rw-r--r-- 3.4k haseeb 25 Aug 19:59 .devenv.flake.nix
drwxr-xr-x - haseeb 25 Aug 20:00 .direnv
.rw-r--r-- 176 haseeb 25 Aug 19:59 .envrc
.rw-r--r-- 93 haseeb 25 Aug 19:59 .gitignore
.rw-r--r-- 474 haseeb 23 Aug 23:02 config.yml
.rw-r--r-- 4.1k haseeb 25 Aug 19:59 devenv.lock
.rw-r--r-- 567 haseeb 25 Aug 19:59 devenv.nix
.rw-r--r-- 66 haseeb 25 Aug 19:59 devenv.yaml
direnv: error /home/haseeb/Downloads/a/.envrc is blocked. Run `direnv allow` to approve its content
~/Downloads
❯ direnv allow
direnv: loading ~/Downloads/a/.envrc
direnv: loading https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cfd0f51481d17f77586997/direnvrc (sha256-YBzqskFZxmNb3kYVoKD9ZixoPXJh1C9ZvTLGFRkauZ0=)
direnv: using devenv
direnv: .envrc changed, reloading
Building shell ...
direnv: updated devenv shell cache
hello from devenv
git version 2.41.0
direnv: export +C_INCLUDE_PATH +DEVENV_DOTFILE +DEVENV_PROFILE +DEVENV_ROOT +DEVENV_STATE +GREET +IN_NIX_SHELL +LIBRARY_PATH +PKG_CONFIG_PATH +name ~LD_LIBRARY_PATH ~PATH ~XDG_CONFIG_DIRS ~XDG_DATA_DIRS
We’ve now set up direnv so it will run our devenv env automatically.
devenv.nix
The meat and potatoes of our environment exist here, so let us open the file it will look like this:
{ pkgs, ... }:
{
# https://devenv.sh/basics/
env.GREET = "devenv";
# https://devenv.sh/packages/
packages = [ pkgs.git ];
# https://devenv.sh/scripts/
scripts.hello.exec = "echo hello from $GREET";
enterShell = ''
hello
git --version
'';
# https://devenv.sh/languages/
# languages.nix.enable = true;
# https://devenv.sh/pre-commit-hooks/
# pre-commit.hooks.shellcheck.enable = true;
# https://devenv.sh/processes/
# processes.ping.exec = "ping example.com";
# See full reference at https://devenv.sh/reference/options/
}
enterShell
This also explains some of the input we saw above i.e
hello from devenv
git version 2.41.0
Which matches what’s in our enterShell
, so this is run when we enter the devenv environment.
env
We can also set ENV variables using the env
i.e. env.GREET
makes the greet env variable inside the devenv.
echo $GREET
devenv
packages
These are packages we want to be available in our devenv, that we don’t need to globally installed. These are the same ones available on nixos pkgs.
We can search for packages on the cli using devenv search
i.e.
devenv search go_1_19
name version description
---- ------- -----------
pkgs.go_1_19 1.19.12 The Go Programming language
No options found for 'go_1_19'.
Found 1 packages and 0 options for 'go_1_19'.
Now this will guarantee that these packages are available within our devenv. This for me is one of the biggest reasons
to use devenv. So now other devs don’t need to make sure they have certain tools installed globally. Such as say
jq
, we can just make them available in a devenv.
scripts
We can also make shell scripts available in a single location like the hello
script above. This works well for simple
one-liners. Then within the devenv we can do:
hello
hello from devenv
We can also specify tools to have available for shell script but not make them available in the devenv. We can do something like:
scripts.silly-example.exec = ''
${pkgs.curl}/bin/curl "https://httpbin.org/get?$1" | ${pkgs.jq}/bin/jq '.args'
'';
This means the script can use jq
and curl
.
pre-commit
The other main construct I use is pre-commit hooks, this will auto-generate a .pre-commit-config.yaml
and add it
to our .gitignore
. As it is generated from the devenv.nix
file. We can define them like so:
pre-commit.hooks = {
# built in
shellcheck.enable = true;
# custom
golangci-lint = {
enable = true;
name = "golangci-lint";
description = "Lint my golang code";
files = "\.go$";
entry = "${pkgs.golangci-lint}/bin/golangci-lint run --new-from-rev HEAD --fix";
require_serial = true;
pass_filenames = false;
};
};
The spellcheck
is a builtin hooks.
Where golangci-lint
, is a custom hook we have defined ourselves.
Updating
Finally, if we want to update the nixpkgs for example, say when a new version of Golang releases. Normally we could
nix flake update
, to update all of our nix flake inputs. We can do the same devenv we do devenv update
.
This updates the devenv.lock
, which is the same as flake.lock
file.
The lock files tie us to a specific version of nixpkgs so that means as long as this file stays the same we will install the version of all the tools as anyone else who runs devenv
.
Why not devcontainers?
So as a slight aside you might be asking why not use devcontainers. Well, the main reason for not doing devcontainers is that I lose access to my shell, with all of my tools. I needed to do some funky stuff with a dotfiles repo and a dotfiles script. It ended up slowing me down more than providing value.
I think Nix Flakes/devenv provides a good middle ground. They are also naturally far more reproducible than docker containers or dev containers.
P.S: At some point I will try out devbox and normal nix flakes .
That’s It! We set up a devenv!