I have to admit, I am fully infected by nix, and today cannot see myself starting a project without first putting the `flake.nix` file into my work directory. While nix itself as a package manager is very neat, its feature of a development shell in a Nix flake is _excellent_ to me. Basically, it allows me to provide through a single file in a repository how to make a fully functional development environment for the current project. This compatible with any Linux distribution (not limited to NixOS) as well as macOS which is still my daily driver.
Let me show you how I do it.
## Prerequisites
If you want to follow along this article, all you need is a computer where Nix is installed and functional, be it Linux or macOS. Furthermore, you should ensure [Flakes](https://nixos.wiki/wiki/Flakes) are enabled as I rely on them.
## On the fly packages
Before going with a full project, the first and easiest step is to use `nix shell` which allows to get packages on-the fly in a new shell, without prior configuration. For example, if I need `drill` for a quick test, which comes from the `ldns` package, I can simply type the following command:
nix shell nixpkgs#ldns
And after a small time to download the dependency – if it is not already on the system – I am presented with a prompt in a subshell with `drill` available in the path. When I close the shell, I do not have access to the dependency anymore. It means that while I keep my global setup lean and with the minimal tools common to all projects, I have (near) instant access to any tool I need. You can provide multiple arguments for multiple packages.
However, if we always need the same tool in a project, and want to share this configuration through the repository, we need to write it down in a file.
## Starting a new project
As I said above, the first thing I do when creating a new project repository is to initialize a new `flake.nix` file that will contain the information on how to setup my work environment. Here is a minimal skeleton:
It is a little bit of boilerplate that probably does not make much sense if you never touched Nix. Basically, it setups some dependencies in `inputs`, then leverages `flake-parts` to generate a configuration for all the platforms nix supports. Then it declares in these outputs a `devShell` entry, which nix can use to create a crafted shell environment to work with, for now with no settings included.
At this point, you can run the `nix develop` command. After some preparation time, nix provides you with a new shell modified for development. By default it includes some development tools already, including a compiler.
## Adding some dependencies
The `pkgs.mkShell` is the most important part of the file above as it is where we will write our dependencies. If we are doing a Ruby project, we can add the ruby interpreter as a dependency like so:
```nix
pkgs.mkShell {
packages = with pkgs; [ ruby_3_4 ];
};
```
Run `nix develop` anew, and your ruby interpreter and associated tools (`gem`, `bundle`, …) will be available.
Sometimes you need some build dependencies, not only the binary packages, that I usually put in `buildInputs`. They may work in `packages` but I find it a bit clearer. So, for Ruby projects, we often need the `libyaml` development files to build Psych, that we can add like so:
```nix
pkgs.mkShell {
buildInputs = with pkgs; [ libyaml.dev ];
packages = with pkgs; [ ruby_3_4 ];
};
```
Note that for `libyaml`, since I need the development output, I suffix it with `.dev`, otherwise it just gets me the runtime elements which do not allow to build against.
<Note>
You can install servers and other dependencies with Nix as well, like a PostgreSQL or Redis server. However, there are no way of starting a service like you would use `brew services` on macOS for example. You can find a way to run then through `nix run`, but personally, I leverage Podman/Docker for background servers additionally to Nix for local development tools. For example, I will put my PostgreSQL server in Podman, but also install PostgreSQL clients and libraries through Nix.
</Note>
A `flake.lock` file is also generated when running the shell for the first time. It will record at which commit of the input dependencies you were, and use that for future invocations, even on new machines. This way, another user that uses your repository will get the _exact_ same setup, package version included, all automatically through a single command.
If you need to update some packages, you can invoke `nix flake update` that will update the dependencies, including the package source `nixpkgs`. Next invocation of the shell should fetch the updated packages if there are any.
## Custom packages
Let us say that you have a custom package of scripts that come with your development environment. You can totally include them in your flake configuration as a custom package and make them be loaded as a package alongside other dependencies:
With the above setup, you need to invoke `nix develop` inside your directory to do stuff with the environment. If you prefer to have things a bit more automated, you can setup your shell to automatically load an environment when you enter the directory. Furthermore, it will modify your current shell instead of spawning a new one, keeping all your settings in.
This is done by the excellent `direnv` tool. Install it through your package manager (or Nix!) and see [the instructions](https://direnv.net/docs/hook.html) on how to hook it into your shell.
Now, in your work directory, add a `.envrc` file that tells `direnv` what to do:
```sh
use flake
```
And that is all. No, really. Now, direnv should be telling you that it refuses to load your file: as a safety measure, you have to explicitely allow it to load a file. You can do so with `direnv allow`.
Then direnv will work its magic and after a short loading time, will give you your shell back with modifications done. `cd` outside of the repository, and it will undo the changes, all automatically. Also, if you edit your `.envrc` or `flake.nix`, it will reload it automatically.
If you have a `.env` file with environment variables to load, `direnv` can do that for you too. Add to the `.envrc` file:
```sh
dotenv
```
Again, nothing more than that. You can also use `dotenv_if_exists` if you want to load a `.env` file while keeping the option of it not existing, and both commands can take an optional path for a different file. For example, if you want to have a permanent `.env` in your repository, and an optional `.env.local` file for a developper to put their overrides, you can use:
```sh
dotenv
dotenv_if_exists .env.local
```
## Working out of directory
You have an existing repository you want to work in that you cannot – or do not want to – add a Nix configuration. You can manage it in a different directory, and still have the entire benefits of Nix. It just requires some additional settings.
When running `nix develop`, you can give a path where to find your `flake.nix` file:
```sh
nix develop /Volumes/Work/flake
```
If you are using the `.envrc` technique, `use flake` works the same way:
```sh
use flake /Volumes/Work/flake
```
What if you have multiple projects? Well, you can also have multiple shell configurations in your flake. In our previous configuration, we defined a development shell named `default`, but you can define them with different names and then use that name in invocations using `#`:
```nix
devShells.foo = pkgs.mkShell {};
devShells.bar = pkgs.mkShell {};
```
Then:
```sh
nix develop /Volumes/Work/flake#foo
```
Or:
```sh
use flake /Volumes/Work/flake#bar
```
However, if you want direnv to work, you have to keep the `.envrc` file in the repository. You can add it (and the accompying `.direnv` folder) to your global git ignore or `.git/config/exclude` as needed.