Flakes
Prerequisite: The Nix language.
Outcome: you can read and edit a
flake.nix; you know how the lock file works; you have usednix run,nix develop,nix build, andnix flake update.
A flake is a git repository with a flake.nix at its root. That
file declares the flake’s inputs (its dependencies – other
flakes) and its outputs (what this flake produces – packages,
NixOS configurations, dev shells, apps, and so on). A flake.lock
next to it pins every input to a specific commit and hash, so
everyone who builds from the same tree produces the same result.
That is the whole idea. Everything else is convention.
Why flakes exist
Before flakes, you pulled other Nix code by referring to a channel
name (<nixpkgs>), a URL, or an ambient environment variable. It
worked, but you could not tell from the source alone which version
you were pulling. Flakes require you to declare inputs by URL and
lock them, so a repo is a self-contained, byte-reproducible source.
Flakes are still labelled “experimental” upstream, but they are what NixOS-in-2026 is built on, and every project you will interact with (including this one) uses them.
A minimal flake
{
description = "example flake";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }: {
packages.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.hello;
};
}
Two things to notice:
inputs.nixpkgs.url = "github:...";declares a dependency.outputs = { self, nixpkgs }: { ... };is a function taking every input by name (plusself, meaning this flake), and returning an attribute set of outputs.
When you say nix build .#default, Nix:
- reads
flake.nix; - fetches every input at the revision pinned in
flake.lock(or, if there is no lock file yet, at whatever the URL currently points to, and writesflake.lock); - calls the
outputsfunction with those inputs; - looks up
packages.<current-system>.defaultin the result; - builds it.
Input URLs
Common forms:
github:OWNER/REPO– latest default branch.github:OWNER/REPO/BRANCH_OR_TAG_OR_COMMIT.git+https://gitea.example.com/foo/bar.path:/absolute/path– a local checkout. Handy while developing something you have not committed yet.tarball+https://example.com/foo.tar.gz.
The full syntax is documented at nix.dev – flake references.
follows – deduping shared inputs
Two of your inputs both depend on nixpkgs. Without any help you
end up with three copies (yours and each input’s) and their eval
does not agree. The fix is inputs.X.inputs.Y.follows = "Y", which
tells X to use your Y.
This repo’s flake.nix uses it:
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
deploy-rs = {
url = "github:serokell/deploy-rs";
inputs.nixpkgs.follows = "nixpkgs";
};
};
That means “sops-nix and deploy-rs both use the same nixpkgs I use”, avoiding three-way version skew.
The lock file
flake.lock is JSON. Each input is pinned by its Git commit hash
plus the NAR hash of its contents. nix flake update refreshes
every input to their latest URL-resolved revision and updates the
lock. nix flake update X refreshes just the input named X.
You commit flake.lock. It is the point of flakes.
Reference: Nix Reference Manual – flakes.
Outputs
There is no strict schema, but the community-standard outputs are:
packages.<system>.<name>– built packages.defaultis whatnix buildpicks up with no.#namegiven.apps.<system>.<name>– runnable vianix run. This repo’sapps.<system>.newis the fleet-scaffolding wizard.devShells.<system>.<name>– shells you enter withnix develop. This repo’s instance flake declares one here sonix developin a fresh clone drops you into the ops toolbox.nixosConfigurations.<hostname>– a NixOS system. In this repo thedeploycommand (thin wrapper overdeploy-rs) reads this and rolls it out to<hostname>; upstream NixOS’s ownnixos-rebuildwould read the same output for a local rebuild.nixosModules.<name>– reusable NixOS modules that other flakes import. This repo exportsnixosModules.default, which is the aggregator of all the base modules.lib.<name>– Nix-language helpers. This repo’slib.mkDevShellandlib.mkNixosConfigsare here.templates.<name>– source trees to copy withnix flake init -t. This repo’stemplates.defaultscaffolds an instance repo.checks.<system>.<name>– runnable vianix flake check. Instance flakes wiredeploy-rs’s deploy-checks in here.
<system> is one of x86_64-linux, aarch64-linux,
x86_64-darwin, aarch64-darwin. To publish an output for many
systems you use nixpkgs.lib.genAttrs [ ... ]; this repo does that
for apps.
Full reference: Nix Reference Manual – flake outputs.
Reading this repo’s flake.nix
Open flake.nix alongside this section.
{
description = "nixops -- generic NixOS fleet base: inventory-driven modules, deploy tooling, ops devShell";
nixConfig = {
extra-substituters = [ "https://nix-community.cachix.org" ];
...
};
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
sops-nix.url = "github:Mic92/sops-nix";
...
};
outputs = { self, nixpkgs, sops-nix, deploy-rs, ... }: let
version = self.shortRev or self.dirtyShortRev or "unknown";
in {
nixosModules.default = { imports = [ sops-nix.nixosModules.sops ./modules ]; };
lib = {
mkNixosConfigs = import ./lib/mkNixosConfigs.nix { ... };
mkDeploy = import ./lib/mkDeploy.nix { ... };
mkDevShell = import ./lib/mkDevShell.nix { ... };
};
templates.default = { path = ./templates/default; ... };
apps = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system: ...);
};
}
Read piece by piece:
description– freeform. Shown bynix flake metadata.nixConfig– extra Nix settings to apply when this flake is loaded. Here it advertises thenix-communitycachix as a substituter, so people runningnix run github:reflection-dev/nixopscan get pre-builtdeploy-rsbits.inputs– the three deps:nixpkgs,sops-nix,deploy-rs. The latter two follow this flake’snixpkgs(seefollowsabove).outputs = { self, nixpkgs, sops-nix, deploy-rs, ... }: let ... in { ... }– a function; thelet ... incomputesversionfrom git metadata and then returns the outputs record.nixosModules.default– an aggregator module that imports sops-nix’s own NixOS module plus every module under./modules/. Consumers get all base functionality in one line:imports = [ nixops.nixosModules.default ];.lib.mk*– the three factory functions exposed for instance flakes to call.templates.default– points attemplates/default/, sonix flake init -t github:reflection-dev/nixopscopies that tree into the current directory.apps– per-system apps generated bygenAttrs. Bothapps.<sys>.newandapps.<sys>.defaultmap to the samewriteShellApplicationwrappingscripts/new.sh.
The nix CLI commands you will use
You do not need every subcommand. The ones that matter for this tutorial:
nix run FLAKE#APP [-- ARGS...]
Fetch, build, and run an apps output. This is how you invoke the
new wizard:
$ nix run github:reflection-dev/nixops -- new my-fleet
The -- separates arguments to nix run itself from arguments to
the wrapped program.
nix develop [FLAKE]
Enter the flake’s devShells.<system>.default shell. Inside an
instance repo this drops you into the ops toolbox: ssh,
add-host, install-host, deploy, and friends are all on
PATH, with the right sops, age, nixos-anywhere, etc.
alongside.
nix build [FLAKE#OUTPUT]
Build an output. Puts a ./result symlink into CWD.
nix flake update
Refresh every input to its latest URL-resolved revision. Commit the
updated flake.lock.
nix flake update NAME
Refresh a single input by name. Handy when you want to bump
nixpkgs without touching sops-nix. (Older Nix used
nix flake lock --update-input NAME; both work but the positional
form is the modern one.)
nix flake metadata [FLAKE]
Print the description, resolved input URLs, and revision hashes.
nix flake check [FLAKE]
Evaluate every check output. Used by CI to verify a flake still evaluates and its deploys are well-formed.
nix eval [FLAKE#PATH]
Evaluate an expression to a value and print it. Ops tooling uses
this heavily – for example, scripts/install-host.sh uses
nix eval --raw --impure --expr "(import ./hosts.nix).${NAME}.ip"
to read the target’s IP out of hosts.nix.
nix repl [FLAKE]
Load a flake into an interactive REPL. Great for poking at what a config actually evaluates to:
$ nix repl .
nix-repl> :lf .
nix-repl> nixosConfigurations.web-1.config.networking.hostName
"web-1"
:lf = load a flake into the current namespace.
path: inputs and dirty trees
While hacking on this repo (or any flake), you often want another
flake to consume your local checkout instead of a remote git URL.
You point its input at path:/abs/path (or a relative path). Two
things to know:
- Nix will only “see” committed content in a
path:input by default. If you have uncommitted changes, they are invisible. This is intentional: flakes are supposed to be reproducible from git. - You can override with
--impureor by staging the tree, but during a course of hacking the friction is real. This is why the new-fleet wizard in this repo optionallygit inits the scaffold and makes a first commit.
You will not usually have to know this – but if you edit
~/Projects/ref/nixops while an instance repo references it as
nixops.url = "path:...";, and your changes seem invisible, this
is why.
What next
You now know how flakes are structured. The next chapter takes what
you already know about Nix expressions and adds the NixOS module
system on top: how a NixOS configuration is assembled from many
small modules, each declaring options and config.
Next: NixOS and the module system
References for this chapter
- nix.dev – Flakes tutorial
- Nix Reference Manual – flakes
- Nix Wiki (community) – Flakes
- Zero to Nix – Flakes
- xeiaso.net – Flake basics – opinionated intro with runnable examples.