Direnv is a tool for loading custom environment variables per directory in your shell. With it, you can use environment variables to configure your applications and system environment. And it combines especially well with asdf.
Asdf is a shell extension for managing multiple versions on a single system. Think Rbenv, but for any tool, not just Ruby. Asdf allows us to specify that for a given project, we want to use Ruby 2.7.2, PostgreSQL 10.3 and Node.js 14.10. Once set up, asdf will intercept calls to, say,
ruby and route it to the right version. Because it also comes with nice tooling to install said versions, I try to use it as much as I can for my system dependencies.
Getting started with direnv and asdf takes a few steps. We are going to activate asdf in our shell through direnv, and we’re going to install and activate direnv through asdf. Don’t worry, it’ll make sense soon!
First, install asdf using your method of choice. I used Homebrew, so I ran:
brew install asdf
This will give you the
asdf command. Unless you hook it into your shell, as per the installation instructions, asdf will not do much. But we’ll skip the manual shell setup for now, and instead go through direnv.
Second, install the asdf-direnv plugin:
asdf plugin add direnv
This will allow you to use asdf to install different versions of direnv. Use it to install the latest version:
asdf install direnv latest
Then, mark whatever version was just installed as the global or “default” version:
asdf global direnv 2.21.3
Note that we still haven’t hooked asdf into our shell yet, so won’t do anything yet. We’ll get to that in a bit — but first, let’s activate direnv.
Hooking direnv into our shell
We’ll hook direnv into our shell using its hook function, explicitly ran through asdf:
# in ~/.bashrc eval "$(asdf exec direnv hook bash)"
direnv hook bash will output a short shell snippet for bash to that will call direnv when appropriate. Prefixing this with
asdf exec ensures we use whatever version of direnv asdf thinks is appropriate — i.e. the version we set as “global” in the previous step.
We’re almost there! Now we have set up our shell to use direnv, as installed by asdf; all that is left is to allow direnv to activate asdf on a per-directory basis for us. Let’s edit the global
direnvrc file to set up the proper asdf hooks:
# ~/.config/direnv/direnvrc source "$(asdf direnv hook asdf)"
This invokes the asdf-direnv plugin to load the shell functions necessary for us to be able to “enable” asdf where we want it.
What the hell did we just do?
- We installed asdf, which allows us to install run specific versions of programs using
- Using asdf, we installed direnv, which allows us to customise our shell environment on a per-directory basis.
- We used
asdf execto run
direnvto include its setup code in our shell, so direnv is now active in new shells.
- We finally added some code to the direnv “library” in
~/.config/direnv/direnvrcthat allows us to activate asdf’s shell integration on a per-directory basis.
- Now we can run asdf-managed programs without using
The reason we go through this whole routine is that we want to use asdf to manage our system versions. In the traditional approach without direnv, asdf would maintain shims of the programs it knows about. For example, running
ruby would be routed by asdf’s shell integration to
~/.asdf/shims/ruby, which would be a wrapper program to figure out the right version of Ruby to use, and then run that. That’s a little overhead on every invocation we don’t need to pay. Moreover, it might sometimes lead to compatibility issues. With direnv though, instead of at invocation time, we figure out when changing directories what the right versions to use are. Then we add those to our
$PATH, so we can directly invoke the right executables without having to go through a shim or incurring any overhead.
Let’s use our shiny new tools in a new project!
Let’s start a new project to try things out:
mkdir blog cd blog
To activate asdf and direnv in it, we create a
.envrc file in our project directory. The direnv initialisation code we inserted into our shell profile will look for this file when we change directories and run it.
We can use it to activate asdf for our project. Create the
.envrc file like so:
#!/bin/bash use asdf
The shebang line (
#!/bin/bash) is not strictly necessary but I do usually add it, as it helps code editors understand the syntax of the file.
use is a function provided by direnv, and the
asdf argument triggers the setup functions we loaded in
~/.config/direnv/direnvrc. A shell, when changing into this directory, will now use asdf!
The threat of untrusted code
…and immediately, we see an error in our shell if we save and close the
.envrc file. It will read:
direnv: error /path/to/blog/.envrc is blocked. Run `direnv allow` to approve its content
This is a security feature of direnv. Since running a shell script when you
cd into a directory has the potential to run untrusted and potentially malicious code, direnv will not load the
.envrc file by default. It will tell you that you will need to explicitly allow it. It will record a fingerprint of the file, so whenever a trusted file changes, you will have to approve it again. Go ahead and approve it by running
Picking a version of Ruby to use
To demonstrate how this works, let’s say we’ll create Ruby project. We haven’t configured anything with asdf yet, so let’s check what Ruby we will be using:
$ which ruby /usr/bin/ruby
ruby will invoke the system Ruby installation. Let’s change that. Create a file called
.tool-versions in your project directory with this content:
Asdf will look for this file to determine what version to use. You can now ask it what versions it would use of the programs it knows about:
$ asdf current direnv 2.21.3 /Users/avdgaag/.tool-versions ruby 2.7.2 Not installed. Run "asdf install ruby 2.7.2"
Asdf tells us running
direnv will use version 2.21.3 as specified in our global
.tool-versions file, which we created using
asdf global earlier. For
ruby it wants to use version 2.7.2, but that is not installed. Helpfully, it tells us how to install it.
Installing requested versions
We can install just the requested version of Ruby, or anything listed in our
.tool-versions file that we don’t have yet.
asdf install ruby 2.7.2 will install only Ruby 2.7.2, but
asdf install will install everything listed in
.tool-versions. Run that and go get a coffee while asdf downloads and installs Ruby for you.
When it’s done, check what Ruby is used now:
$ which ruby /Users/avdgaag/.asdf/installs/ruby/2.7.2/bin/ruby
Ah, now we’re not using the same system-wide Ruby anymore, but a custom version managed by asdf.
Managing environment variables
Let’s also understand what direnv does with an example. When working with Ruby projects it is common to use environment variables such as
RACK_ENV. We can check their values and see they are empty:
$ echo $RAILS_ENV
But we can edit our
.envrc file and add a new line to it to set that environment variable:
#!/bin/bash use asdf export RAILS_ENV=development
Now, when you have saved the file and ran
direnv allow to trust the new
.envrc file, we can see the environment variable was now correctly set:
$ echo $RAILS_ENV development
Asdf has installed direnv for us and hooked it into our shell, so that direnv can hook asdf into our shell on a per-directory basis. We can now modify our environment in our
.envrc file and specify tool versions to use in our
.tool-versions file. That’s a lot! But there’s more.
Direnv ships with some useful functions in its stdlib, that we can use in our
.envrc files. Two functions I like, are:
dotenv .envloads the contents of
.envinto the shell.
.envis a conventional place to put key/value pairs, which are to be loaded into an application’s environment on start-up by tools such as Foreman or language-specific tools such as Dotenv. Direnv can handle it for us!
PATH_add ./binwill add the
bindirectory in our current directory to our
$PATH, which means executables in that directory can be run without specifying their full path, and take precedence over system-wide programs of the same name.
But there are also a few
layout functions that wrap up commonly used functionality per project type.
Layout functions: Ruby
layout ruby. As per the documentation:
Sets the GEMHOME environment variable to $PWD/.direnv/ruby/RUBYVERSION. This forces the installation of any gems into the project’s sub-folder. If you’re using bundler it will create wrapper programs that can be invoked directly instead of using the bundle exec prefix.
layout ruby in your
.envrc file will set
$GEMHOME to a project-specific subdirectory. This effectively creates a gemset like RVM once provided. You still use Bundler to manage your Ruby gems, but there is no longer a need for running executables using
bundle exec because your project gems are isolated. I also quite like the idea that removing a project directory also removes all the gem dependencies it was using.
One more thing I like to do is to let Bundler install binstubs in my project directory. In a Rails project, I then let Spring modify those binstubs to have them go through Spring, giving me a performance boost of not booting Rails on every command. Finally, I add my project-specific
./bin directory to my
$PATH using direnv:
#!/bin/bash use asdf layout ruby PATH_add ./bin
Now, I can run
rails s or
rspec in my shell and know it will do the right thing.
Layout functions: Node.js
layout node. It does something similar for Node.js:
Adds “$PWD/nodemodules/.bin” to the PATH environment variable.
No more installing NPM packages globally; everything can be installed in your project directory. By adding the
node_modules/.bin directory to your path there is no more need to type out the path to an executable, or use
npx. Of course, if you want to install and manage Node.js versions via asdf, you will need to install the appropriate asdf plugin.
Custom layout functions
Finally, since direnv is “just” shell code, it allows us to write our own functions. For example, we can use direnv to write a
layout_postgres function that will configure PostgreSQL to be completely project-specific, including its version, data and configuration. See the Direnv wiki on GitHub for reference.
Integration with other tools
Running the right versions of programs in the right environment from your shell is one thing. But oftentimes, other programs need to use your project configuration, from outside your project directory. The primary example is your code editor needing to use your particular version of
eslint or needing to know the right environment variables. How can we make sure these programs are aware of the right configuration?
For starters, we can look for integration packages. My editor of choice is Emacs and there is a neat direnv package for Emacs that will look for and load the
.envrc contents into Emacs’ concept of the environment.
For asdf versions, we can invoke
asdf directly to run a program and let asdf pick the right version for us:
asdf exec ruby
We can even hard-code the right version number to use.
ASDF_RUBY_VERSION=2.7.2 asdf exec ruby
That should give you everything you need to use direnv and asdf in your development setup.
Asdf and direnv can be a bit dizzying to set up. But it pays off by simplifying your daily workflow, removing some friction we have grown accustomed to in modern development, such as prefixing all your commands with
bundle exec. I have found using these tools for the most part “just works”. I encourage you to try them out and read their documentation to discover what else they have to offer.