Building reproducible development environments

Snowflake

© Photo by Marc Newberry on Unsplash

© Photo by Marc Newberry on Unsplash

Article from Issue 299/2025
Author(s):

Nix flakes modernize the Nix package manager's promise of reproducible builds with structured project definitions and built-in dependency locking, making Nix code more shareable across projects.

Nix [1] is a purely functional package manager and environment manager known for its reproducible builds and ability to install multiple versions of software side-by-side. Unlike traditional package managers (e.g., APT or Yum), Nix builds packages in isolation and stores them in the Nix store (/nix/store) with unique hash identifiers, ensuring that each build is immutable and isolated. This approach eliminates dependency interference (no more "DLL hell") and enables atomic upgrades and rollbacks of software environments. Advanced users leverage Nix to create development shells, manage system configurations, and even build Docker images – all in a declarative, reproducible manner.

Nix flakes [2], a newer feature (introduced experimentally in Nix 2.4), address several shortcomings of traditional Nix workflows. Flakes introduce a standard project structure and a lock file mechanism to pin dependencies, making the evaluation of Nix expressions as reproducible as the builds themselves. In classic Nix, evaluation could be influenced by external factors (like the NIX_PATH, environment variables, or impure file accesses), meaning two users could get different results from the same Nix code if their environments differed. Flakes enforce hermetic, self-contained Nix projects by requiring explicit declarations of all inputs and locking them to exact versions. Flakes ensure that any machine using a given flake sees the same dependency versions, thus guaranteeing reproducible development environments.

Improved Reproducibility and Dependency Locking

Traditional Nix workflows often relied on channels or the NIX_PATH to obtain Nix expressions (e.g., import <nixpkgs> {}). Channels are essentially pointers to a Nixpkgs version that update over time. This led to a major reproducibility problem: Two machines could evaluate the same import <nixpkgs> expression at different times and end up pulling different commits of Nixpkgs. Channels undermined reproducibility, because there was no guarantee that each developer had the exact same Nixpkgs snapshot unless they manually pinned it. Tools like niv provided manual pinning, but Nix lacked a built-in, convenient way to lock dependencies until the arrival of flakes.

Flakes fix this by design. A flake project explicitly declares its dependencies (called inputs), each identified by a content-addressed or version-controlled reference (e.g., a specific Git commit of Nixpkgs or another flake). When you run any flake-enabled command (such as nix build, nix develop, or nix run) on a flake, Nix will ensure that all those inputs are resolved to the exact versions recorded in a flake.lock file. The flake.lock file is automatically generated and updated by Nix; it pins the flake's recursive dependency graph so that every evaluation uses the same exact versions of Nixpkgs and other inputs. This means no more drifting dependencies. For instance, if one developer updates a library version, the change is captured in the lock file and shared via version control.

Flakes also enforce pure evaluation of Nix code. Under the flake model, you can no longer implicitly depend on external context like environment variables or config files in ~/.config during evaluation. For example, you cannot use an implicit import <nixpkgs> expression (which would depend on NIX_PATH); instead you declare inputs.nixpkgs in your flake.nix file. By eliminating impure inputs to evaluation, flakes ensure that if two people have the same flake (including its lock file), they get the same derivation outputs.

It's worth highlighting how flake-based development differs from the older (pre-version 2.4) style of Nix in several key ways, starting with project structure. Flake projects impose a more standardized layout: Every flake-based project has a flake.nix file at its root (often accompanied by a flake.lock file) defining a set of outputs such as packages, development shells, applications, or NixOS modules. In contrast, legacy Nix projects had no single enforced structure, which is why a repository might have a default.nix or shell.nix with ad-hoc definitions, and conventions varied widely. The flake model makes Nix projects more discoverable and consistent across the ecosystem. For example, if you find a Git repository with a flake.nix file, you can immediately run nix flake show to see what it provides. This standardization was introduced intentionally to formalize common use cases that were previously handled by convention.

Another notable difference is the reproducibility of evaluation. Flakes require explicit pinning of inputs and do not allow implicit dependencies on the environment or global configuration, ensuring that builds are isolated from machine-specific settings. In legacy Nix, it was common to rely on environment variables like $NIX_PATH or system-wide channels (e.g., using import <nixpkgs>), which could vary from one machine to another. With flakes, commands such as nix build .#packageName or nix develop always evaluate in the context of the flake's declared inputs, ignoring any ambient Nix channels. This consistency makes it much easier to share Nix code between developers and get identical results on different systems.

Flakes also bring a new approach to dependency locking and updates. In older workflows, updating Nixpkgs was an informal process: One might run nix-channel update or manually fetch a specific commit whenever they felt the need. This often led to "works on my machine" problems when team members had different channel states. Flakes replace that approach with a deterministic flake.lock file that pinpoints exact versions of all inputs. Updating dependencies becomes an explicit action (using nix flake update), which fetches newer commits and updates the lock file accordingly. This mechanism encourages treating dependency updates as a deliberate, version-controlled change (e.g, committing the updated flake.lock file to your repository), much like how Node projects handle changes to their package-lock.json file. In this way, flake-based workflows align with mainstream practices for dependency management while still preserving Nix's pure functional benefits.

Flakes greatly improve the composability of Nix configurations across different projects. In the past, sharing or reusing Nix code between projects often required setting up Nix channels or using functions like fetchGit in Nix expressions to pull in external repositories. Those approaches could be clunky and not always reliably reproducible. Flakes offer a more declarative model for composition: One project can directly include another project as an input and then use its outputs, simply by adding a line to flake.nix. This approach fosters a richer ecosystem of shareable Nix code.

Installing Nix on Ubuntu

Setting up Nix on Ubuntu is straightforward, with two installation modes available [3]:

  • Multi-user installation (recommended): Nix is installed system-wide with a daemon (nix-daemon) running as root. Multiple users can share the Nix store, and build processes run under isolated nixbld accounts for security.
  • Single-user installation: Nix is installed for your user only (no daemon). The Nix store (/nix) is owned by your user, and builds run under your user account.

The multi-user mode is generally recommended on Linux for better isolation and security. With multi-user Nix, builds are sandboxed so they cannot write to your home directory or other users' environments, and any package built by one user can be reused by others from the shared store. The downside is it requires root privileges to set up (creating /nix and user groups) and is a bit more involved to uninstall if needed. The single-user mode, by contrast, is slightly easier to install and doesn't keep a daemon running, but it's less isolated (builds run as your user) and can't be easily shared across users. In practice, both modes will let you use flakes; the choice depends on your security needs and whether you have root access.

Building Docker Containers

Instead of writing a Dockerfile that might produce different results over time (e.g., from Ubuntu 20.04 today vs. a year from now), you can use Nix to generate a container image in a fully declarative and reproducible way. Nix's dockerTools let you construct images from Nix packages and even your own applications, with all dependencies precisely pinned.

There are a couple of approaches to using Nix with Docker:

  1. Use Nix inside a Dockerfile (not the focus here): This is where you use the Nix package manager within a Docker build to install packages. While useful in some contexts, it still involves Docker's build process and layering.
  2. Use Nix to produce a Docker image tarball directly (the approach in this article): Nix can create a Docker image tarball that you can load into Docker or publish, without using a Dockerfile at all. The Nix derivation describes the entire contents of the image, giving you fine control over versions and contents.

Using flakes, you can integrate Docker image outputs into your project. For instance, imagine you want to containerize the Python app/environment from the previous section. You can extend your flake.nix file to have a packages output that builds a Docker image containing your application and all its dependencies.

To do this, you need to create a Docker image build in your flake example. For this example, assume your Python project has an application you want to run in a container (perhaps a web service). For simplicity, a trivial example will suffice: a Python script that prints "Hello Nix". In a real scenario, this could be a Flask app or any entrypoint.

First, you might package your Python app using Nix (so that it's in the Nix store). But to keep things straightforward, you can also let the Docker image include your source code by copying it in. Listing 1 shows a simple approach using dockerTools.buildImage.

Listing 1

Packaging with dockerTools.buildImage

01 # ... inside outputs, alongside devShells ...
02 packages.${system}.myApp = pkgs.python310Packages.buildPythonApplication {
03   pname = "myapp";
04   version = "1.0";
05   src = ./.;
06   # We suppose our project has a setup.py or pyproject.toml that tells how to install
07   # If not - we could use 'src = ./my_script.py' with a wrapper
08   propagateBuildInputs = [ pkgs.python310Packages.numpy ];  # example dependency
09 };
10
11 packages.${system}.dockerImage = pkgs.dockerTools.buildImage {
12   name = "myapp-image";
13   tag = "1.0";
14   contents = packages.${system}.myApp;
15   config = {
16     Cmd = [ "${packages.${system}.myApp}/bin/myapp" ];
17   };
18 };

With the code in place, you can build the Docker image tarball by running

$ nix build .#dockerImage

This will result in a file in the Nix store representing the Docker image (e.g., /nix/store/<hash>-docker-image-myapp-image.tar.gz). Nix will output a symlink called result pointing to that tarball.

Now, load the image into Docker:

$ docker load < result
Loaded image: myapp-image:1.0

You can verify the image is listed with docker images. Run it to test:

$ docker run --rm myapp-image:1.0
 Hello Nix!

Congratulations, you built a Docker container image without a Dockerfile! The image is fully specified by Nix, meaning every layer and every file in it came from your pinned Nix dependencies or your source code. For example, the Python interpreter inside the container is exactly the one from nixpkgs commit nixos-24.05, and NumPy is the exact version from that commit. If someone rebuilds this flake in the future, they get a bit-for-bit identical image (barring updates to your flake inputs). This is a huge win for repeatable deployments.

Buy this article as PDF

Express-Checkout as PDF
Price $2.95
(incl. VAT)

Buy Linux Magazine

SINGLE ISSUES
 
SUBSCRIPTIONS
 
TABLET & SMARTPHONE APPS
Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content

  • NixOS 22.5 Is Now Available

    The latest release of NixOS with a much-improved package manager and a user-friendly graphical installer.

  • News

    HP and System76 Announce the Dev One Laptop; NixOS 22.5 Is Now Available; Titan Linux Is a New KDE Linux Based onDebian Stable; Next-Generation HTTP/3 Protocol Arrives as a Standard; The Next Linux Kernel Could Be a Big Deal and Millions of MySQL Servers Exposed

  • Docker Open Source Developer Tools

    Docker provides the open source tools and resources for compiling, building, and testing containerized applications.

  • Tutorials – Docker

    You might think Docker is a tool reserved for gnarly sys admins, useful only to service companies that run complicated SaaS applications, but that is not true: Docker is useful for everybody.

  • Static Code Analyzers

    Admins daily use scripts to automate tasks, generate web content, collect and parse data, and perform many other tasks. A few sophisticated tools can tell admins where script problems lurk.

comments powered by Disqus
Subscribe to our Linux Newsletters
Find Linux and Open Source Jobs
Subscribe to our ADMIN Newsletters

Support Our Work

Linux Magazine content is made possible with support from readers like you. Please consider contributing when you’ve found an article to be beneficial.

Learn More

News