DEV Community

Cover image for How I've Been Using Nix Flakes (And What You Can Do With Them)
Austin
Austin

Posted on

How I've Been Using Nix Flakes (And What You Can Do With Them)

I resisted Nix flakes for a while. The classic Nix channel-based workflow was familiar, and flakes felt like unnecessary complexity. Then I tried them on one project, and within a week I'd converted everything else. Here's what changed my mind and how you can get started.

What flakes actually solve

Before flakes, my Nix setups had a recurring problem: reproducibility was aspirational rather than guaranteed. Running nix-build on two different machines could produce different results depending on which channel revision each machine had pinned. Flakes fix this with a lockfile (flake.lock) that pins every input to an exact revision. Same inputs, same outputs, every time.

The other thing flakes fix is discoverability. A flake.nix file has a defined structure. You can run nix flake show on any flake and immediately see what it provides: packages, dev shells, NixOS modules, whatever. No more guessing what a random default.nix is supposed to do.

Enabling flakes

Flakes are still technically "experimental," but they've been stable in practice for years. Add this to your Nix configuration:

# /etc/nix/nix.conf (or ~/.config/nix/nix.conf)
experimental-features = nix-command flakes
Enter fullscreen mode Exit fullscreen mode

If you're on NixOS, add it to your system configuration instead:

nix.settings.experimental-features = [ "nix-command" "flakes" ];
Enter fullscreen mode Exit fullscreen mode

The anatomy of a flake

Every flake.nix has the same basic shape:

{
  description = "My project";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  outputs = { self, nixpkgs }: {
    # stuff goes here
  };
}
Enter fullscreen mode Exit fullscreen mode

Inputs are your dependencies, pinned in flake.lock. Outputs are what your flake provides to the world. That's it. Everything else is just filling in the outputs.

Thing 1: Dev shells that actually work

This is where flakes won me over. I got tired of maintaining project-specific Docker containers just to get consistent tooling across machines.

{
  description = "Web project";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      devShells.${system}.default = pkgs.mkShell {
        packages = with pkgs; [
          nodejs_20
          nodePackages.typescript
          nodePackages.prettier
          postgresql_16
          redis
        ];

        shellHook = ''
          echo "Dev environment loaded."
          export DATABASE_URL="postgresql://localhost:5432/myapp_dev"
        '';
      };
    };
}
Enter fullscreen mode Exit fullscreen mode

Run nix develop and you're in the shell. Every teammate gets the exact same versions of Node, PostgreSQL, and everything else, regardless of what's installed on their system.

For projects that need to support multiple architectures, use flake-utils to avoid repeating yourself:

{
  description = "Cross-platform project";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in {
        devShells.default = pkgs.mkShell {
          packages = with pkgs; [
            go_1_22
            gopls
            golangci-lint
            delve
          ];
        };
      }
    );
}
Enter fullscreen mode Exit fullscreen mode

Now it works on x86_64-linux, aarch64-linux, x86_64-darwin, and aarch64-darwin.

Auto-loading with direnv

Typing nix develop every time you cd into a project gets old. Pair flakes with nix-direnv and the shell loads automatically:

# .envrc
use flake
Enter fullscreen mode Exit fullscreen mode

That's the whole file. Walk into the directory, your tools appear. Walk out, they're gone. The shell is cached so it doesn't rebuild every time.

Thing 2: Packaging your own software

I maintain a few internal tools that need to be installable across machines. Here's what that looks like with flakes.

{
  description = "Internal CLI tool";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      packages.${system}.default = pkgs.buildGoModule {
        pname = "mytool";
        version = "0.3.1";
        src = ./.;
        vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
      };
    };
}
Enter fullscreen mode Exit fullscreen mode

Anyone can install it with:

nix profile install github:youruser/mytool
Enter fullscreen mode Exit fullscreen mode

Or run it without installing:

nix run github:youruser/mytool -- --help
Enter fullscreen mode Exit fullscreen mode

That nix run command is something I use constantly. Need a one-off tool without cluttering your system? Just nix run it.

Thing 3: Running random software without installing it

This is the nix run trick I probably use more than anything else. Need to format some JSON but don't have jq installed?

nix run nixpkgs#jq -- '.users[0].name' data.json
Enter fullscreen mode Exit fullscreen mode

Need to spin up a quick HTTP server?

nix run nixpkgs#python3 -- -m http.server 8080
Enter fullscreen mode Exit fullscreen mode

Want to test something in a specific version of Node?

nix run nixpkgs#nodejs_18 -- --version
nix run nixpkgs#nodejs_20 -- --version
Enter fullscreen mode Exit fullscreen mode

Nothing gets installed to your profile. It's downloaded, cached, run, and that's it.

Thing 4: NixOS system configuration

This is the big one if you run NixOS. I manage my entire system config as a flake. The structure looks like this:

nixos-config/
├── flake.nix
├── flake.lock
├── hosts/
│   ├── desktop/
│   │   ├── configuration.nix
│   │   └── hardware-configuration.nix
│   └── laptop/
│       ├── configuration.nix
│       └── hardware-configuration.nix
└── modules/
    ├── common.nix
    ├── dev-tools.nix
    └── desktop-environment.nix
Enter fullscreen mode Exit fullscreen mode

And the flake.nix:

{
  description = "My machines";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, home-manager }: {
    nixosConfigurations = {
      desktop = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./hosts/desktop/configuration.nix
          ./modules/common.nix
          ./modules/dev-tools.nix
          ./modules/desktop-environment.nix
          home-manager.nixosModules.home-manager
          {
            home-manager.useGlobalPkgs = true;
            home-manager.useUserPackages = true;
            home-manager.users.me = import ./home.nix;
          }
        ];
      };

      laptop = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./hosts/laptop/configuration.nix
          ./modules/common.nix
          ./modules/dev-tools.nix
        ];
      };
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

Rebuild with:

sudo nixos-rebuild switch --flake .#desktop
Enter fullscreen mode Exit fullscreen mode

The inputs.nixpkgs.follows line on home-manager is worth calling out. It tells home-manager to use the same nixpkgs revision as the rest of your config instead of pulling its own. This avoids downloading two copies of nixpkgs and prevents version mismatches.

Thing 5: Composing flakes as inputs

Flakes can depend on other flakes. This is how I pull in third-party NixOS modules, overlays, and tools.

inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  hyprland.url = "github:hyprwm/Hyprland";
  nixvim.url = "github:nix-community/nixvim";
  agenix.url = "github:ryantm/agenix";
};
Enter fullscreen mode Exit fullscreen mode

Each input gets locked to a specific commit. Update one with:

nix flake update agenix
Enter fullscreen mode Exit fullscreen mode

Or update everything at once:

nix flake update
Enter fullscreen mode Exit fullscreen mode

You can review what changed before rebuilding:

nix flake metadata
Enter fullscreen mode Exit fullscreen mode

Thing 6: Templates

Flakes can export project templates. I keep one with starter configs for languages I use often:

{
  description = "Project templates";

  outputs = { self }: {
    templates = {
      go = {
        path = ./templates/go;
        description = "Go project with dev shell";
      };
      rust = {
        path = ./templates/rust;
        description = "Rust project with dev shell";
      };
      python = {
        path = ./templates/python;
        description = "Python project with venv and dev shell";
      };
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

Start a new project from a template:

nix flake init -t github:youruser/templates#go
Enter fullscreen mode Exit fullscreen mode

The directory gets populated with a working flake.nix and whatever other boilerplate the template includes.

Thing 7: Checks and CI

Flakes have a checks output meant for CI. I use it to run tests, linting, and formatting.

checks.${system} = {
  fmt = pkgs.runCommand "check-fmt" {
    buildInputs = [ pkgs.nixfmt-rfc-style ];
  } ''
    nixfmt --check ${./.}
    touch $out
  '';

  lint = pkgs.runCommand "check-lint" {
    buildInputs = [ pkgs.statix ];
  } ''
    statix check ${./.}
    touch $out
  '';
};
Enter fullscreen mode Exit fullscreen mode

Run all checks with:

nix flake check
Enter fullscreen mode Exit fullscreen mode

In CI, that one command covers everything.

Practical tips I wish I'd known earlier

Pin nixpkgs to a specific branch. github:NixOS/nixpkgs/nixos-unstable is fine for dev machines. For production or CI, pin to a release branch like nixos-24.11 so you get security patches without surprise breakage.

Use follows aggressively. If three of your inputs all pull in their own nixpkgs, you're evaluating three copies. I wasted an embarrassing amount of time on slow rebuilds before I figured this out. Make them all follow your top-level nixpkgs:

inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  foo = {
    url = "github:someone/foo";
    inputs.nixpkgs.follows = "nixpkgs";
  };
  bar = {
    url = "github:someone/bar";
    inputs.nixpkgs.follows = "nixpkgs";
  };
};
Enter fullscreen mode Exit fullscreen mode

Commit your flake.lock. The whole point of the lockfile is reproducibility. It belongs in version control. I've seen people .gitignore it and then wonder why their CI builds are nondeterministic.

Don't fight the eval cache. If nix flake check or nix build seems to skip evaluation, it's because the inputs haven't changed. Run nix flake update if you actually want fresh inputs, or pass --recreate-lock-file in rare cases.

Where I've landed

Flakes are how I manage everything now. Dev environments, system configs, CI, personal tools. I still have complaints -- the Nix language itself remains confusing in spots, the error messages can be awful, and the documentation assumes you already know things you're reading the documentation to learn. But the lockfile means my builds actually reproduce, the standard structure means I can open any flake.nix and orient myself quickly, and nix run / nix develop removed enough daily friction that I stopped noticing it.

If you want to try it, start with a dev shell for one project. That's the lowest commitment and you'll know within a day whether it clicks for you.

Top comments (0)