Flakes

Flakes are why a Ternix config is reproducible. The same files build the same system on any machine, today or a year from now. This is the longest guide in the set because it is the concept most worth understanding, and because once it clicks you can run your whole system from a few files you commit to git. The technical claims here follow the official nix.dev flakes page.

Why flakes exist

Before flakes, nixpkgs came from a channel, a moving pointer that tracked whatever was latest. Two people building the same configuration at different times could get different package versions, and reproducing an old build was hard. A flake instead records the exact version of every dependency in a lock file. Reproducibility stops being something you remember to do and becomes the default. Flakes are also composable, so one flake can list another as an input and build on it.

In practice that means three things you can rely on.

  • Clone your config on a new laptop, run one command, and get the same system.
  • Roll back a bad update by restoring a single file.
  • Share a config that builds the same way for whoever runs it.

Anatomy of flake.nix

A flake is an attribute set with three attributes that matter. This is the flake Ternix generates for a NixOS host.

{
  description = "Ternix-generated flake";
 
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };
 
  outputs = { self, nixpkgs, ... }: {
    nixosConfigurations.nixos-host = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [ ./configuration.nix ];
    };
  };
}
  • description is a short string summarising the flake.
  • inputs declares dependencies by URL. Here nixpkgs is pulled from the nixos-unstable branch on GitHub. Each input is fetched and pinned.
  • outputs is a function. It receives the resolved inputs and returns what the flake provides. self is the flake itself, nixpkgs is the input above, and nixosConfigurations.nixos-host is the build target you pass to nixos-rebuild --flake .#nixos-host.

The shape worth remembering is that inputs are data you depend on and outputs are a function of that data.

flake.lock

The first time you run a nix command in the flake, Nix writes flake.lock, a record of the exact revision each input resolved to. Nix generates and updates this file automatically, and if your inputs have inputs of their own, it reads their lock files too, so the entire dependency graph is pinned. A lock entry looks roughly like this (flake.lock is JSON).

{
  "nixpkgs": {
    "locked": {
      "owner": "NixOS",
      "repo": "nixpkgs",
      "rev": "a1b2c3d4...",
      "narHash": "sha256-...",
      "type": "github"
    }
  }
}

The rev pins the exact git commit, and narHash is the content hash Nix uses to verify the fetched source has not changed. Together they turn nixos-unstable (a moving branch) into a fixed, verified point. Commit this file. It is the thing that makes a build reproducible. Anyone who clones the repository gets the versions you used, not whatever happens to be current.

Your first flake update, end to end

Here is the full loop you will run whenever you want newer packages. Each step is safe and reversible.

# 1. See what your inputs are currently pinned to
nix flake metadata
 
# 2. Move every input to the latest revision matching its URL
nix flake update
 
# 3. Inspect what changed in the lock file before committing to it
git diff flake.lock
 
# 4. Build and switch into the new system
sudo nixos-rebuild switch --flake .#nixos-host
 
# 5. If something broke, undo the update and rebuild the known-good system
git checkout flake.lock
sudo nixos-rebuild switch --flake .#nixos-host

Step 5 is the safety net that makes updating low risk. The previous flake.lock is the exact set of versions that worked, so restoring it returns you to a build you have already run.

Updating a single input

Pinned does not mean frozen, and you do not have to move everything at once.

# update only nixpkgs, leave other inputs where they are
nix flake update nixpkgs

Then rebuild (Using a config). Updating one input at a time makes it obvious which change caused a regression.

Common outputs

One flake can expose several things. Ternix generates whichever you selected, and each has a command you run against it.

  • nixosConfigurations.<host>, a full NixOS system. Build it with nixos-rebuild switch --flake .#<host>.
  • homeConfigurations.<user>, a Home-Manager configuration. Build it with home-manager switch --flake .#<user>.
  • devShells.<system>.default, an environment you enter with nix develop.

The part after # is the output name. nixos-rebuild --flake .#nixos-host means "build the nixos-host system defined in the flake in the current directory".

The output schema

nixosConfigurations, homeConfigurations, and devShells are three of a larger set of standard output names. Nix commands know these names by convention, so naming an output correctly is what lets a command find it. The common ones, with the command each maps to.

OutputWhat it isCommand
packages.<system>.<name>a buildable packagenix build .#<name>
apps.<system>.<name>a runnable programnix run .#<name>
devShells.<system>.<name>a development environmentnix develop .#<name>
nixosConfigurations.<host>a NixOS systemnixos-rebuild switch --flake .#<host>
homeConfigurations.<user>a Home-Manager confighome-manager switch --flake .#<user>
nixosModules.<name>a reusable module other flakes importimported, not run
overlays.<name>a package-set extensionadded to nixpkgs.overlays
formatter.<system>the code formatternix fmt
checks.<system>.<name>a test for CInix flake check
templates.<name>a starter projectnix flake init -t <flake>#<name>

<system> is a platform string such as x86_64-linux. The system-keyed outputs exist once per platform you support, which is why you see x86_64-linux repeated through a flake.

Input reference syntax

Every input is a flake reference, a short URL that tells Nix where to fetch from and which revision to take. Knowing the forms lets you pin an input exactly.

inputs = {
  # a GitHub repo, default branch
  nixpkgs.url = "github:NixOS/nixpkgs";
 
  # a specific branch, tag, or commit
  stable.url = "github:NixOS/nixpkgs/nixos-24.11";
  pinned.url = "github:NixOS/nixpkgs/a1b2c3d4e5f6";
 
  # any git remote over https or ssh, with an optional ref
  mine.url = "git+https://example.com/me/repo?ref=main";
 
  # a local path, handy while developing a flake
  local.url = "path:/home/you/projects/thing";
 
  # a plain tarball
  tar.url = "https://example.com/src.tar.gz";
};

Some sources are not flakes, meaning they have no flake.nix. Mark them with flake = false and Nix fetches them as plain source you reference through the input.

inputs.somePlugin = {
  url = "github:author/vim-plugin";
  flake = false;
};
# use it later as somePlugin.outPath, or as a derivation src

Query parameters refine a reference. ?ref= selects a branch or tag, ?rev= selects a commit, and ?dir= points at a flake in a subdirectory of a repo.

Inspecting a flake

Three commands tell you what a flake contains and whether it is healthy.

# list every output the flake exposes, as a tree
nix flake show
 
# show the inputs and their locked revisions
nix flake metadata
 
# evaluate all outputs and run checks, catching errors early
nix flake check

nix flake show is the fastest way to see which #name targets exist on a flake you did not write. The Commands and scripts page collects more of these.

Gotchas worth knowing early

  • Flakes only see files that git tracks. If a newly added file seems to have no effect, run git add and rebuild. This is the single most common surprise.
  • Flakes evaluate in pure mode by default, which blocks access to things like environment variables and the network during evaluation. The nix.dev docs note this "promotes a style of writing programs more likely to make them reproducible". --impure lifts the restriction, but reach for it only when you truly must, because it gives up the guarantee.
  • nix flake check evaluates your outputs and surfaces errors before you try to switch. Run it after editing flake.nix.
  • Flakes are still an experimental feature, so commands need flakes enabled. If nix flake reports an error, add this to /etc/nixos/configuration.nix (Ternix configs already include it) and rebuild.
nix.settings.experimental-features = [ "nix-command" "flakes" ];

Flakes compared to channels

ChannelsFlakes
Version sourcemutable, system widepinned in flake.lock
Reproducible by defaultnoyes
Update commandnix-channel --updatenix flake update
Depending on other codeawkwardfirst class via inputs

Advanced

inputs.<name>.follows deduplicates a transitive dependency so two inputs share one nixpkgs rather than pinning two copies. This keeps the closure (the full set of dependencies pulled in) smaller and avoids two slightly different package sets in one build.

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

A flake url can point at a branch, a tag, or a specific commit, which is how you pin hard when you need a build that never moves until you say so.

# track a branch (moves on nix flake update)
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
 
# pin to an exact commit (frozen until you change the rev)
nixpkgs.url = "github:NixOS/nixpkgs/a1b2c3d4e5f6";

You can also build a configuration without switching into it, which is useful in CI or when testing on another machine.

nix build .#nixosConfigurations.nixos-host.config.system.build.toplevel

The nix.dev page is candid that the flakes implementation still has rough edges and ongoing design debate, so expect some commands to feel experimental. That does not stop you using flakes today. The daily workflow above is stable and widely relied on.

Stamping a version with self

self is the flake itself, and it carries metadata you can read inside outputs. This is handy for baking a commit or build date into what you ship.

outputs = { self, nixpkgs, ... }: {
  # self.rev is the git commit, set only when the tree is clean
  # self.dirtyRev is set instead when there are uncommitted changes
  # self.lastModified is a timestamp you can format into a version string
};

Reducing per-system boilerplate

Writing x86_64-linux in every output gets repetitive once you support more than one platform. Two community inputs remove the repetition. flake-utils provides a helper that maps a function over each system, and flake-parts splits a flake into modules. Both are optional, and a single-system flake needs neither.

{
  inputs.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 = [ pkgs.jq ]; };
      });
}

Offering extra caches from the flake

A flake can request extra binary caches with nixConfig, so anyone who builds it is offered the prebuilt results instead of compiling. Nix asks for confirmation before trusting a new cache the first time.

{
  nixConfig = {
    extra-substituters = [ "https://nix-community.cachix.org" ];
    extra-trusted-public-keys = [ "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" ];
  };
}

The Updating safely page explains how caches speed up builds.

Go deeper