Every project have to have some 3rd party dependencies, even if you are masochist and you are not using 3rd party packages/libraries, you are using compilers and tooling for the language you are developing in. If you wrote your own language, then this article is not for you. For everyone else, I want to show you why Nix is the best package manager for you projects and possible for your operating system.

What is nix ?

Nix is both package manager and configuration language that was designed to be used with Nixos, the declarative operating system, but about that some other time. Let’s focus on Nix.
While it was designed to be used with Nixos it works on any linux distribution, mac and even on windows. However I have not tested how viable it is no windows. It comes as set of command tools used for different usecases and there’s many tools in the nix ecosystem designed to enhance the developer experience, I will show currently my favourite one later. Even tho nix is not that well know, it’s package repository is one of the biggest with more then 80000 packages and it’s always growing. It’s also super easy to contribute new package, or bundle then just for your own use. Nix is configured using the declarative Nix Expression language, which is usually scary for people new to Nix, but I will try to explain as much as I know about it and show you most used things so you don’t have to struggle. I will not cover installation here, you can follow official documentation . Let’s start with simple things.

Nix temporary shell

One of the best features of nix is the ability to create temporary interactive shell with some program installed inside. This is super useful when you want to test some cli, or program. Or even when you need just some one of thing to use and you don’t want to install it on your system. To create a shell with a package you can use, let’s create a shell with great git tui tool lazygit.

nix-shell -p lazygit

As you can see can see from the logs, nix downloaded the package and created new shell instance. Now you can type lazygit to access it, this should work with basically any package that’s available in nix. You can exit the shell and lazygit should no longer be available. Of course unless you already have it installed on your system. Very powerful and useful, but it gets better.
Let’s create a shell.nix file. It will hold our declaration for our temporary shell with ruby installed.

{ pkgs ? import <nixpkgs> {} }:
  pkgs.mkShell {
    # nativeBuildInputs is usually what you want -- tools you need to run
    nativeBuildInputs = with pkgs.buildPackages; [ ruby_3_2 ];
}

This can be places in your project and serve as declarative dependency manager. You can read more about it here . As you probably saw nix-shell always spawns bash shell, which can be annoying if you are using any other shell. Thankfully there is solution to it.

Direnv and devenv

Direnv is tool that can be used for automatic loading of environment variables, but can also be used to automatically install dependencies with nix when you ‘cd’ into a directory. If you are heavy terminal user like me, you will love this tool. Using only this direnv and nix shell is already possible to achieve amazing developer experience. But there’s more to it. With flakes which are extension of the base nix modules, you can create development environments for anything. Let’s see how you can set it up and then I will show you some examples To enable direnv you will need .envrc file with options for flakes like:

use flake . --impure

Direnv should detect this file and then you have to allow direnv for this directory with direnv allow .. Besides this you will need the flake.nix file containing the definition for your env. The base template I’m usually using looks like this.

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
    devenv.url = "github:cachix/devenv";
  };

  nixConfig = {
    extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
    extra-substituters = "https://devenv.cachix.org";
  };

  outputs = { self, nixpkgs, devenv, ... } @ inputs:
    let
      pkgs = nixpkgs.legacyPackages."x86_64-linux";
    in
    {
      devShell.x86_64-linux = devenv.lib.mkShell {
        inherit inputs pkgs;

        modules = [
          ({ pkgs, lib, ... }: {
            # This is your devenv configuration
            packages = [
            ];

            enterShell = ''
            '';
          })
        ];
      };
    };
}

This is using devenv which is super powerful tool for all your development environment needs. It can do a lot, but I’m mainly using it for the convenience of fast builds and simple package declaration. But in can replace your docker compose or make files. Let’s break down the snippet. First we have inputs, these are basically your nix dependencies, and in here for now we need only nixpkgs which are the set of package definitions we can used and then we have the devenv by itself. nixConfig configures nix to use community binary cache Cachix this will mostly remove the need of building the packages. Last part is the outputs, this is basically the result of evaluating the flake.nix file, but in here we will be defining our environment. The variables between let and in are user defined variables that can be reused inside the in clause. Most important part of the snippet is the ‘packages’ array inside of the modules. This is where we will specify packages nix will download for us into our environment. So let’s create basic env for developing ‘go’. For my projects I’m usually using few packages:

  • go
  • gopls (lsp server for go)
  • gotests (Test generation tool)
  • gosec (Code scanner for security vulnerabilities)
  • golangci-lint (Linter) Defining these dependencies is very easy. You just need to list them in the package array.
packages = [
  pkgs.gopls
  pkgs.go
  pkgs.gotests
  pkgs.gosec
  pkgs.golangci-lint
];

After changing the flake.nix your shell should automatically reload and download all of the dependencies, if not you can use direnv reload command. You can find all available packages from nix here .

Mac

For newer macbooks you cannot use the hardcoded x86_64-linux system and you will need to use something like aarch64-darwin but this creates conflict between you and your linux teammates. This is solved by multiple approaches but easiest is using flake-parts . This allows you to just define all the systems you want to support. Let’s see the same example of go project but this time with support for both mac and linux:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05";
    devenv.url = "github:cachix/devenv";
  };

  outputs = inputs@{ nixpkgs, flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      imports = [
        inputs.devenv.flakeModule
      ];
      systems = [
        "x86_64-linux"
        "aarch64-darwin"
      ];

      perSystem = { config, self', inputs', pkgs, system, ... }: {
        devenv.shells.default = {
          packages = [
            pkgs.flyctl
            pkgs.hugo
          ];

          enterShell = ''
          '';
        };
      };
    };
}

You can see that we effectively wrapped the previous outputs clause into flake-parts.perSystem definition. One thing to note here is that not all packages are available for all systems. But sometimes you need more then just cli tools, let’s see how the definition would look like for flutter projects.

Flutter

For flutter on Linux we will need few things, If we want to develop android app we will need android studio, if we will want to develop web app we will need chrome. We will also need flutter itself. I’ve spend couple hours joining all of the necessary parts together, but this was the result.

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
    devenv.url = "github:cachix/devenv";
    flutter-nix = {
      url = "github:maximoffua/flutter.nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    android-nixpkgs = {
      url = "github:tadfisher/android-nixpkgs";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  nixConfig = {
    extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
    extra-substituters = "https://devenv.cachix.org";
  };

  outputs = { self, nixpkgs, devenv, flutter-nix, android-nixpkgs, ... } @ inputs:
    let
      pkgs = import nixpkgs {
        system = "x86_64-linux";
        config = {
          allowUnfree = true;
          android_sdk.accept_license = true;
        };
      };

      flutter-sdk = flutter-nix.packages.${pkgs.stdenv.system};
      android-sdk = android-nixpkgs.sdk.${"x86_64-linux"} (sdkPkgs:
        with sdkPkgs; [
          build-tools-30-0-3
          build-tools-34-0-0
          cmdline-tools-latest
          emulator
          platform-tools
          platforms-android-33
          platforms-android-34
          sources-android-34
          sources-android-33
          system-images-android-34-google-apis-playstore-x86-64
          system-images-android-33-google-apis-playstore-x86-64
        ]
      );
    in
    {
      devShell.x86_64-linux = devenv.lib.mkShell {
        inherit inputs pkgs;
        modules = [
          ({ pkgs, config, lib, ... }: {
            env.ANDROID_AVD_HOME = "${config.env.DEVENV_ROOT}/.android/avd";
            env.ANDROID_SDK_ROOT = "${android-sdk}/share/android-sdk";
            env.ANDROID_HOME = config.env.ANDROID_SDK_ROOT;
            env.CHROME_EXECUTABLE = "chromium";
            env.FLUTTER_SDK = "${pkgs.flutter}";
            env.JAVA_21 = "${pkgs.jdk21}";
            env.GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${android-sdk}/share/android-sdk/build-tools/34.0.0/aapt2";

            packages = [
              flutter-sdk.flutter
              flutter-sdk.dart
              pkgs.chromium
              pkgs.cmake
              pkgs.gradle_7
              android-sdk
              pkgs.android-tools
            ];

            scripts.create-avd.exec = "JAVA_HOME=$JAVA_21 avdmanager create avd --force --name phone --package 'system-images;android-33;google_apis_playstore;x86_64'";
            scripts.emulator.exec = "emulator -avd phone -skin 720x1280";

            enterShell = ''
              mkdir -p $ANDROID_AVD_HOME
              export PATH="${android-sdk}/bin:$PATH"
              export FLUTTER_GRADLE_PLUGIN_BUILDDIR="''${XDG_CACHE_HOME:-$HOME/.cache}/flutter/gradle-plugin";
            '';
          })
        ];
      };
    };
}

It can be scary on first sight, but it’s actually quite simple. The hardest part was figuring out all of the environment variables so that flutter sees both android studio, the android toolchain and chrome. As you can see there’s more inputs, which are helper flakes for setting up flutter and android studio. Storing flutter sdk invariable so we can then pull both flutter and dart from it, and defining android sdk with all of the required and wanted toolchains, android versions and images for simulators. Most of the other parts is boilerplate for exposing all env variables and defining last few packages like chromium.

Python

Python package ecosystem is quite frankly a mess, but there’s spark of bright future in terms of poetry . Even tho there’s project to bring poetry and nix together in form of poetry2nix I wasn’t able to get it working. But devenv comes with full configuration of some languages, python is one of them and the integration is really sweat. For poetry python the full flake.nix looks like this.

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
    devenv.url = "github:cachix/devenv";
  };

  nixConfig = {
    extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
    extra-substituters = "https://devenv.cachix.org";
  };

  outputs = { self, nixpkgs, devenv, poetry2nix, ... } @ inputs:
    let
      pkgs = nixpkgs.legacyPackages."x86_64-linux";
    in
    {
      devShell.x86_64-linux = devenv.lib.mkShell {
        inherit inputs pkgs;

        modules = [
          ({ pkgs, lib, ... }: {

            packages = [
            ];

            enterShell = ''
            '';

            languages.python = {
              enable = true;
              poetry = {
                enable = true;
                activate.enable = true;
                install.enable = true;
              };
            };
          })
        ];
      };
    };
}

Simple and self explanatory. You enable support for python with poetry and chooses if devenv should automatically install dependencies and active the poetry env. There are more preconfigured languages in devenv, full list is here .

Recap

Nix is powerful package manager that can simplify your development environments for the whole team. It can simplify the whole onboarding process by normalizing the dev environment in declarative matter. You can use it in many ways but so far combination of direnv and devenv worked the best for me. The ecosystem is ready for everything from simple cli tools to complex flutter environment definitions and more. This article didn’t cover probably even 10% of what nix can do for you and your team. And even tho it can be seen as difficult to configure don’t worry it’s not that hard, so give it a try.