Development Environments With Emacs, Nix, and Direnv

Recently I decided to fire up Emacs, declare bankruptcy on my old config, and rewrite it again from scratch. I thought I would share how I use Emacs alongside Nix to create seamless, reproducible development environments for hacking on projects.

Setting Things Up

The secret sauce here is direnv. direnv allows us to load and unload environment variables depending on the current directory. When paired with Nix, we can automatically handle the environment variables for per-project dependencies specified in our flake.nix or shell.nix files.

Setting up direnv integration with Nix and Emacs is straightforward. Let’s write some code!

Home Manager

The easiest way to setup direnv in Nix is via its module in Home Manager.

We’ll enable nix-direnv in our configuration to replace direnv’s builtin implementation of use_nix and use_flake. Unlike the builtin implementation, nix-direnv can cache our Nix environment and prevent Nix’s garbage collector from cleaning up our build dependencies.

Adding the following to your home-manager configuration will get nix-direnv up and running in your user environment:

# Allow home-manager to manage our shell.
programs.bash.enable = true;

programs.direnv = {
  enable = true;
  enableBashIntegration = true;
  nix-direnv.enable = true;
};

If you use a shell other than bash, you can check the current Home Manager options for integrating with your shell of choice.

Emacs

emacs-direnv is a package that provides direnv integration for Emacs. It can be installed from MELPA with use-package. We’ll enable the global minor mode to automatically update the Emacs environment when the active buffer changes:

(use-package direnv
  :ensure t
  :config
  (direnv-mode))

Alternatively, we can install it manually:

M-x package-install RET direnv RET

And enable the global minor mode on startup in ~/.emacs.d/init.el:

(direnv-mode)

An Example Project

Let’s create a new project that will setup a development environment for working with Rust. In our example flake.nix file, we’ll include an overlay to pull in the latest stable Rust toolchain and rust-analyzer for LSP support in Emacs:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
  };

  outputs =
    { nixpkgs, ... }:
    let
      supportedSystems = [ "x86_64-linux" ];
      eachSystem = nixpkgs.lib.genAttrs supportedSystems;
    in
    {
      devShells = eachSystem (
        system:
        let
          overlays = [ (import rust-overlay) ];
          pkgs = import nixpkgs {
            inherit system overlays;
          };
        in
        {
          default = pkgs.mkShell {
            buildInputs = with pkgs; [
              rust-analyzer
              rust-bin.stable.latest.default
            ];
          };
        }
      );
    };
}

With just one line of shell code from our project’s root directory, direnv will know to automatically load our Nix flake:

echo "use flake" >> .envrc && direnv allow

To load the Nix environment, all we have to do now is navigate to the directory in Emacs. When direnv loads the environment, it will automatically download all of our specified packages in flake.nix and update the respective Emacs variables process-environment and exec-path so that packages from our flake started within Emacs use the correct $PATH with the proper environment variables set (even when using the daemon).

Compare this to working on the example project without direnv: we would first have to navigate to the project in our shell, manually run nix develop (or nix-shell if we are not using flakes) to pull in the toolchain and LSP, and then start Emacs from the shell to ensure those packages work properly.

Emacs will even display a helpful message in the minibuffer when direnv updates your environment variables. Here’s a snippet from my *Messages* buffer of direnv loading a Nix environment when I navigate to one of my projects:

direnv: +AR +AR_FOR_TARGET +AS +AS_FOR_TARGET +CC +CC_FOR_TARGET +CONFIG_SHELL +CXX +CXX_FOR_TARGET +HOST_PATH +IN_NIX_SHELL +LD +LD_FOR_TARGET +NIX_BINTOOLS +NIX_BINTOOLS_FOR_TARGET +NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_BINTOOLS_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu +NIX_BUILD_CORES +NIX_CC +NIX_CC_FOR_TARGET +NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_CC_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu +NIX_CFLAGS_COMPILE +NIX_ENFORCE_NO_NATIVE +NIX_HARDENING_ENABLE +NIX_LDFLAGS +NIX_LDFLAGS_FOR_TARGET +NIX_STORE +NM +NM_FOR_TARGET +OBJCOPY +OBJCOPY_FOR_TARGET +OBJDUMP +OBJDUMP_FOR_TARGET +RANLIB +RANLIB_FOR_TARGET +READELF +READELF_FOR_TARGET +SIZE +SIZE_FOR_TARGET +SOURCE_DATE_EPOCH +STRINGS +STRINGS_FOR_TARGET +STRIP +STRIP_FOR_TARGET +__structuredAttrs +buildInputs +buildPhase +builder +cmakeFlags +configureFlags +depsBuildBuild +depsBuildBuildPropagated +depsBuildTarget +depsBuildTargetPropagated +depsHostHost +depsHostHostPropagated +depsTargetTarget +depsTargetTargetPropagated +doCheck +doInstallCheck +dontAddDisableDepTrack +mesonFlags +name +nativeBuildInputs +out +outputs +patches +phases +preferLocalBuild +propagatedBuildInputs +propagatedNativeBuildInputs +shell +shellHook +stdenv +strictDeps +system ~PATH ~XDG_DATA_DIRS (~/Projects → ~/Projects/rebound)

And here’s the snippet of direnv unloading the Nix environment when I navigate outside of the project:

direnv: ~PATH ~XDG_DATA_DIRS -AR -AR_FOR_TARGET -AS -AS_FOR_TARGET -CC -CC_FOR_TARGET -CONFIG_SHELL -CXX -CXX_FOR_TARGET -HOST_PATH -IN_NIX_SHELL -LD -LD_FOR_TARGET -NIX_BINTOOLS -NIX_BINTOOLS_FOR_TARGET -NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu -NIX_BINTOOLS_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu -NIX_BUILD_CORES -NIX_CC -NIX_CC_FOR_TARGET -NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu -NIX_CC_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu -NIX_CFLAGS_COMPILE -NIX_ENFORCE_NO_NATIVE -NIX_HARDENING_ENABLE -NIX_LDFLAGS -NIX_LDFLAGS_FOR_TARGET -NIX_STORE -NM -NM_FOR_TARGET -OBJCOPY -OBJCOPY_FOR_TARGET -OBJDUMP -OBJDUMP_FOR_TARGET -RANLIB -RANLIB_FOR_TARGET -READELF -READELF_FOR_TARGET -SIZE -SIZE_FOR_TARGET -SOURCE_DATE_EPOCH -STRINGS -STRINGS_FOR_TARGET -STRIP -STRIP_FOR_TARGET -__structuredAttrs -buildInputs -buildPhase -builder -cmakeFlags -configureFlags -depsBuildBuild -depsBuildBuildPropagated -depsBuildTarget -depsBuildTargetPropagated -depsHostHost -depsHostHostPropagated -depsTargetTarget -depsTargetTargetPropagated -doCheck -doInstallCheck -dontAddDisableDepTrack -mesonFlags -name -nativeBuildInputs -out -outputs -patches -phases -preferLocalBuild -propagatedBuildInputs -propagatedNativeBuildInputs -shell -shellHook -stdenv -strictDeps -system (~/Projects/rebound/src → ~/Projects)