This document shows how to get started managing a software project using the following in conjunction:
- the Nix package manager
- an experimental feature of Nix called flakes
- this project, Nix-project.
Nix-project provides library support that helps ease the ergonomics of using Nix with flakes.
NOTE: To understand more about why to use an experimental feature such as flakes, as well as known trade-offs, please read the provided supplemental documentation on Nix.
Nix-project uses all the features it provides itself, so you can look through this repository for concrete examples of how to do what's discussed in this document.
If you're new to Nix consider reading the provided introduction.
If you don't have Nix set up yet, see the provided installation and configuration guide. You will need Nix's experimental flakes feature enabled to continue following this development guide.
If you don't know how to use basic Nix commands, see the provided usage guide.
If you're new to the Nix programming language, see the provided language tutorial.
Nix-project provides a template you can use to scaffold a new project. This scaffold captures recommended conventions also used by Nix-project itself.
If you want to scaffold a new project set up similarly, you can go into a writable directory and invoke the following nix
call:
nix --extra-experimental-features nix-command \
--refresh run github:shajra/nix-project/main#nix-scaffold \
-- --target-dir /tmp/my-project # or whereever you like
SUCCESS: Scaffolded Nix project at /tmp/my-project
The --refresh
assures that you get the latest version of the scaffolding script from the internet if you've made this nix run
invocation before.
In the freshly scaffolded project, you'll see the following files:
my-project
├── default.nix
├── flake.nix
├── nix
│ ├── compat.nix
│ ├── my-app.nix
│ └── overlay.nix
├── README.org
└── support
└── docs-generate
README.org
and support/docs-generate
are used to document your project and are discussed in another document.
default.nix
and nix/compat.nix
provide non-flakes support for users of your project who have decided not to enable flakes in their Nix installation. These are static files that you shouldn't typically have to modify. They expose the flake defined in flake.nix
as a Nix expression of packages. The provided guide on using Nix without flakes illustrates how end users without flakes can use the default.nix
and nix/compat.nix
files in the scaffolded project. The guide on using Nix with flakes will match other user's experience.
You'll modify the top-level flake.nix
file and the Nix files under the nix
directory (with exception of compat.nix
) to customize the scaffolded project to your needs.
As documented in the provided introduction to Nix, we're using the experimental flakes feature of Nix for a few reasons:
- more assurances of a deterministic and reproducible build
- faster evaluation from improved caching
- an improved ergonomic experience for authors of Nix projects
- an improved ergonomic experience for end users consuming Nix projects
The best way to start understanding a flakes-based project is to read the flake.nix
file at the project's root. This file is written in the Nix programming language. See the provided introduction to the Nix language if you're new to the language.
Every flake.nix
file must conform to a standard structure of an attribute set with three attributes, description
, inputs
, and outputs
. Here are some highlights from the flake.nix
of our scaffolded project:
{
description = "A foundation to build Nix-based projects from.";
inputs = {
flake-compat = { url = "github:edolstra/flake-compat"; flake = false; };
flake-parts.url = "github:hercules-ci/flake-parts";
nix-project.url = "github:shajra/nix-project";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
};
outputs = inputs@{ flake-parts, nix-project, ... }:
# …
;
}
The description
is just a simple textual string.
inputs
specifies all the dependencies needed by our project. Many of these dependencies could well be flakes themselves. Others might be pointers to online files. Most of these inputs are specified in a URI syntax. See the official documentation on flake references for more details.
The scaffolded project has four dependencies:
flake-compat
, to support users not using flakes (nix/compat.nix
)flake-parts
, for an improved ergonomic experience when defining flake outputs.nix-project
(this project), for document generation (support/docs-generations
)nixpkgs
, a central library for Nix almost all packages build off of
The outputs
attribute is where we specify what our flake provides. We define it as a function. The head of this function is an attribute set mapping input names to their respective outputs. The function's body returns an attribute set of everything our flake provides.
Note that what is specified in inputs
describes how to get our dependencies, but this is different from the inputs supplied to the outputs
function, which are the dependencies themselves.
Nix does not strictly enforce the schema of the outputs returned by outputs
function. However, some attributes are special with respect to defaults searched by various nix
subcommands. You can run nix flake check
to see that your flake passes basic checks, including warnings for any outputs not recognized as standard.
The official Nix documentation doesn't yet have a specification of all standard outputs in one place. The Nix wiki, though, has a useful section on outputs.
Flakes, by design, disallow the build platform from being queried, even to see which chipset we will be building on. This restriction ensures some portability and reproducibility, but introduces some complexity when defining and accessing flake outputs. For every package, a flake must provide a different output for each system supported.
Flake outputs end up with attribute paths like
packages.<system>.<name>
(how most flakes output packages)legacyPackages.<system>.<attribute path>
(how Nixpkgs outputs packages)
You can see all the systems used by Nix with the following command:
nix eval nixpkgs#lib.platforms.all
Raw flakes are an improvement over not using them, but using flakes without library assistance requires some requisite boilerplate. The scaffold pulls in flake-parts
reduce this boilerplate with
- an improved DSL for defining the
outputs
attribute - a flake modules framework to allow users to extend the DSL.
Below is the fully general way of entering the flake-parts
DSL:
You should eventually read the official flake-parts
documentation, but we'll cover an introduction here.
{
# …
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } (topLevelModArgs: {
# …
});
}
Note that bindings we have in scope include our dependencies (bound to inputs
above) and flake-parts
top-level module arguments (bound to the topLevelModArgs
attribute set above). These are documented fully in the flake-parts
documentation.
If you don't need the top-level arguments when defining your outputs, you can call flake-parts
with a more straightforward form (though our scaffolded project uses top-level arguments):
{
# …
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
# …
};
}
As you can see from the scaffolded flake.nix
file, flake-parts
has us populate three attributes:
systems
, a list of all the systems to build forperSystem
, a function evaluated for each system fromsystems
that returns system-specific output.flake
, for outputs that pass through as raw flakes outputs
In practice, this looks something like the following:
{
# …
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } (topLevelModArgs: {
systems = [
# …
];
perSystem = perSystemModArgs: {
# …
};
flake = {
# …
};
});
}
In flake-parts
, the flake
attribute is used for flake outputs that are the same for all target systems. The flake
component of the attribute path is dropped. For example, in our scaffolded project, flake.overlays.build
ends up being just overlays.build
in our project's final output.
Otherwise, all our system-specific outputs are returned by the function we set for the perSystem
attribute. Notice the perSystem
function has the following three bindings in scope:
- the original flake inputs (
inputs
above) - top-level module arguments (
topLevelModArgs
above) - per-system module arguments (
perSystemModArgs
above)
The per-system module arguments are also documented fully in the flake-parts documentation. These arguments help us avoid dealing with the system we're targeting.
For example, one of the per-system arguments is pkgs
. If you use this argument, you are expected to have Nixpkgs bound as an input to the specially treated nixpkgs
name.
For example, let's say we wanted to pass through GNU Hello as a package provided by our project. Without flake-parts
we could do the following:
{
description = "Without flake-parts";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
};
outputs = inputs: {
packages.x86_64-linux.my-hello =
inputs.nixpkgs.legacyPackages.x86_64-linux.hello;
packages.aarch64-darwin.my-hello =
inputs.nixpkgs.legacyPackages.aarch64-darwin.hello;
};
}
This example not using flake-parts
may not seem that bad when just passing through a package. But we end up with a lot of boilerplate if we wanted to factor out code common code for building a package. It could look as obtuse as the following:
{
description = "Motivating flake-parts";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
};
outputs = inputs:
let lib = inputs.nixpkgs.lib;
systems = [ "x86_64-linux" "aarch64-darwin" ];
build = system: {
${system}.my-hello =
inputs.nixpkgs.legacyPackages.${system}.hello;
};
packages =
lib.foldr lib.recursiveUpdate {} (map build systems);
in { inherit packages; };
}
Notice how annoying it is to deal with the system
parameter. Here's what the same flake looks like with flake-parts
:
{
description = "Illustrating flake-parts";
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
};
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-darwin" ];
perSystem = { pkgs, ... }: {
packages.my-hello = pkgs.hello;
};
};
}
Hopefully, the improvement in this DSL is appreciable upon inspection. We don't have to mess with a parameterization for the system. And merging everything for each system is done for us.
In the example above, rather than accessing the hello
package from the inputs.nixpkgs
parameter, we get it from the per-system module argument pkgs
, which looks for inputs.nixpkgs
by name, and selects out the appropriate legacyPackages
for whatever system we're targeting (one of those listed in systems
).
Additionally, in our perSystem
we set a packages.my-hello
attribute, and the final outputs we get in our flake are:
packages.x86_64-linux.my-hello
packages.aarch64-darwin.my-hello
See the official flake-parts documentation for more per-system module arguments available beyond pkgs
. Particularly useful is inputs'
, which can be traversed just like inputs
, but ignoring the system component of the attribute path. So, though a little more verbose, we could use inputs'
instead of pkgs
as follows:
{
description = "Illustrating flake-parts";
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
};
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-darwin" ];
perSystem = { inputs', ... }: {
packages.my-hello = inputs'.nixpkgs.legacyPackages.hello;
};
};
}
The scaffolded project provides a perspective on structuring the Nix expressions to build your flake. Nixpkgs is a giant tree that is mainly made of packages. Instances of Nixpkgs can be extended with special functions called overlays.
An overlay has the following form:
final: prev: {
# new attributes to be merged into Nixpkgs
}
We start with an instance of Nixpkgs, and can chain overlays:
(nixpkgs.extend overlay1).extend overlay2
The prev
parameter of the overlay allows us to access the version of Nixpkgs the overlay is directly extending. The final
parameter enables us to access the version of Nixpkgs we'll get when all overlays have been applied.
For those familiar, final
is the kind of open recursion we get with the self
or this
parameter in object-oriented (OO) languages. Similarly prev
is like super
. Similar to OO languages, we can use overlays to override packages referenced from final
in any other overlay. References from prev
can only be overridden by previous overlays. Overriding is powerful because we can override all references to a dependency. Overriding all references helps keep everything in our Nixpkgs instance on the same version for consistency and compatibility.
A typical pattern is to start with Nixpkgs, and extend it with more packages via overlays, until we finally have packages we want to distribute.
The scaffolded project has a nix/overlay.nix
file that defines the overlay distributed by its flake:
withSystem:
final: prev:
let inherit (prev.stdenv.hostPlatform) system;
in withSystem system ({ inputs', ... }: {
nix-project-org2gfm = inputs'.nix-project.packages.org2gfm;
my-app = final.callPackage ./my-app.nix {};
})
This generator of an overlay takes a withSystem
function as an argument. We get withSystem
as a top-level module argument from flake-parts
. withSystem
allows access to the inputs'
attribute set specialized to a specific platform. Within our overlay, we assume that Nixpkgs has already been specialized to a specific platform, which we can get from prev.stdenv.hostPlatform.system
.
Additionally, the overlay defines a small application using the callPackage
function from Nixpkgs. This is discussed in the next section.
Remember, another overlay can override anything in Nixpkgs or provided by a previous overlay. If building a package involves building some intermediate artifacts, you might want to expose those artifacts in the distributed overlay. Distributing overlays enables others to use the overlay and override these intermediate artifacts to get a different build.
The scaffolded project contains the definition of an application in nix/my-app.nix
, which has been written in a conventional style:
{ writeShellApplication
, curl
, w3m
}:
writeShellApplication {
name = "my-app";
runtimeInputs = [ curl w3m ];
text = ''
curl -s 'https://nixos.org' | w3m -dump -T text/html
'';
}
The attribute set input of this function expects dependencies passed in and returns the derivation of a package. All these dependencies happen to exist in a default instance of Nixpkgs:
writeShellApplication
is a Nixpkgs utility to make a package providing a simple shell script.curl
is the standard cURL program that can download HTTP content.w3m
is a text-based web browser.
We could pass in these dependencies explicitly, but if there are many dependencies this can get tedious. Nixpkgs comes with a utility function callPackage
that will
- inspect (using reflection) the attribute names of the set input of a function
- call the function passing in values retrieved from Nixpkgs with these names.
Some people recognize this kind of utility as dependency injection. The callPackage
utility accepts two arguments:
- the function (or path to a Nix file evaluating to it) with dependencies to be injected
- an attribute set to overlay on top of Nixpkgs when looking up dependencies.
Using final.callPackage
, various packages that depend on one another can be defined in the same overlay. Here's an example:
final: prev: {
my-app-a = final.callPackage ({writeShellApplication}: …) {};
my-app-b = final.callPackage ({writeShellApplication, my-app-a}: …) {};
my-app-c = final.callPackage ({writeShellApplication, my-app-a, my-app-b}: …) {};
}
Here my-app-a
is a simple shell script that doesn't rely on anything more than shell. my-app-b
is a script that uses my-app-a
. And my-app-c
is a script that uses both my-app-a
and my-app-b
. Notice that for these second two callPackage
calls, we can not use prev.callPackage
, because dependencies required wouldn't exist until this overlay was applied to Nixpkgs.
You'll notice the first time you run a nix
command against your newly scaffolded project, a flake.lock
lock file will be generated. Flake inputs can point to mutable references like Git repository branches. The lock file pins references to a specific snapshot retrieved.
This lock file should be checked in with your source. This file is what makes your build repeatable.
When we want to update all the dependencies in our lock file, we can run:
nix flake update
This guide and the scaffolded project used as an example, show how to make packages providing a simple shell script. You will likely want to start making packages for other languages.
Nix has an official learning starting point, that is a good next step. In particular, you will find yourself reading the Nixpkgs manual.
The guides included in this project cover more of the language-agnostic aspects of Nix and Nixpkgs. Each programming language ecosystem has its set of unique requirements and idiosyncrasies. Nixpkgs provide functions to assist with each language, which can lead to some divergent experiences when packaging and developing with Nix. The Nixpkgs manual has dedicated sections for each language. Eventually, you will find yourself diving into Nixpkgs source code.
The Nix ecosystem is vast. This project and documentation illustrate just a small sample of what Nix can do.