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
If you're on NixOS, add it to your system configuration instead:
nix.settings.experimental-features = [ "nix-command" "flakes" ];
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
};
}
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"
'';
};
};
}
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
];
};
}
);
}
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
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=";
};
};
}
Anyone can install it with:
nix profile install github:youruser/mytool
Or run it without installing:
nix run github:youruser/mytool -- --help
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
Need to spin up a quick HTTP server?
nix run nixpkgs#python3 -- -m http.server 8080
Want to test something in a specific version of Node?
nix run nixpkgs#nodejs_18 -- --version
nix run nixpkgs#nodejs_20 -- --version
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
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
];
};
};
};
}
Rebuild with:
sudo nixos-rebuild switch --flake .#desktop
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";
};
Each input gets locked to a specific commit. Update one with:
nix flake update agenix
Or update everything at once:
nix flake update
You can review what changed before rebuilding:
nix flake metadata
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";
};
};
};
}
Start a new project from a template:
nix flake init -t github:youruser/templates#go
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
'';
};
Run all checks with:
nix flake check
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";
};
};
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)