How we use
Nix with
Flakes
❄❄❄❄

2025-03-07 published | 2025-03-22 edited | 11610 words

If you have to, you have to. (Deal with Nix and flakes.)

This is a summary of how we use Nix flakes for creating environments with all dependencies necessary for development, for distributing packages and applications, and for NixOS deployments.

We can’t avoid short summary of Nix language and an overview of Nix ecosystem. These are more like prerequisities, but help with the understanding of what to expect (from Nix ecosystem) and how to read (Nix language).

We expect the reader to be open-minded – be ready for things that do not make sense when read for the first time, like “the result of the function evaluation is the input to that function.”

Overview of Nix ecosystem

When we talk about Nix, we should now what we are talking about. Nix can be a lot of things but let’s stop calling everything “Nix” for now and distinguish between Nix store, Nix language, Nix build tool, Nixpkgs, and NixOS.

At the bottom, Nix store contains so called derivations – recipes with “how to” create products – and products. It does not matter what product is; it is probably a Python package, executable application, NixOS configuration, or just some directory with text files.

We do not manipulate Nix store directly. There are plumbing commands for that, but we don’t use them neither. We focus on source code in .nix files written in Nix language and related porcelain commands.

Nix language source code can also be written in a flake.nix file. Flakes can be a lot of things, but let’s be strict: A Nix flake is a directory that contains flake.nix. And there are restrictions on the anatomy of flake.nix file.

Nix build tool, the porcelain commands, are used to manipulate Nix store to satisfy what is written in Nix language. In other words, these commands instantiate source code in .nix files by creating .drv derivations and then realize products.

*.nix -------------> /nix/store/*.drv -----------> /nix/store/*
      instantiation                   realization
       (porcelain)                     (plumbing)

(The bottom is on right.)

The biggest known collection of .nix files is called Nixpkgs – a single Git repository that tends to contain all the packages. A package is what will eventually became a product. The defining feature of Nixpkgs is overlays and here is the manual.

Finally, NixOS is a Linux distribution based on Nixpkgs. It provides operating system environments and services. The defining feature of NixOS is modules and here is the manual.

To complement the related work, there is a good starting place for new Nix users, although outdated (2022) and unfinished, and the famous classic introduction to Nix from 2015.

Nix language

Nix language is functional, pure, and lazy, but most importantly – declarative. It means that:

To perform side effects, there are Nix build tool commands that – based on the source code in .nix files – manipulate Nix store.

Beware of Nix language syntax:

The biggest confusion when reading the Nix language, in my opinion, is made by mis-indentation. It is too easy to make nested function call over multiple lines with no first-sight-clear demarkation where arguments start and end. Take for example slightly rewritten part of the above example:

in {
  packages = getAttrs (attrValues
    pypi-name-to-nix-name)
  nixpkgs.python3Packages;
}

Even the example passes the nix fmt . sanity check, it makes me insane.

For details about the Nix language, consult the Nix language tutorial and Nix language manual.

Nix flakes for development

Let’s make a Nix flake for development, i.e. a directory containing flake.nix file where we can call nix develop and get all the dependencies we need. This is about dependencies for a Python3 project.

$ nix develop
path '/my/repo' does not contain a 'flake.nix', searching up
error: could not find a flake.nix file
$ cat flake.nix ; nix develop
error: syntax error, unexpected end of file
       at /nix/store/37cvj3lklwi0m1ljxs0q8a8v7cc73rcm-source/flake.nix:1:1:
$ cat flake.nix ; nix develop
{}
error: flake 'path:/my/repo' lacks attribute 'outputs'
$ cat flake.nix ; nix develop
{
  outputs = {};
}
error: expected a function but got a set at /nix/store/7nhv5laaqxpwp5ajplyb0dxn3j6wwdyl-source/flake.nix:2:3
$ cat flake.nix ; nix develop
{
  outputs = _: {};
}
error: flake 'path:/my/repo' does not provide attribute 'devShells.x86_64-linux.default', 'devShell.x86_64-linux', 'packages.x86_64-linux.default' or 'defaultPackage.x86_64-linux'
$ cat flake.nix ; nix develop
{
  outputs = _: {
    devShells.x86_64-linux.default = {};
  };
}
error: expected flake output attribute 'devShells.x86_64-linux.default' to be a derivation or path but found a set: { }
$ cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
  }: {
    devShells.x86_64-linux.default =
      nixpkgs.legacyPackages.x86_64-linux.mkShell
      {};
  };
}
❄ $

Tadaa! Now, we have a dev shell.

To create a dev shell, the mkShell function available from the Nixpkgs is used.

But there are no dependencies yet.

❄ $ python3 -c 'import sqlalchemy'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'sqlalchemy'

mkShell is a function accepting a single attribute set. In that attribute set, it is expected that the attribute with the name packages is a list of derivations to be added to the dev shell. We add ruff executable and mypy and SQLAlchemy Python3 packages dependencies.

   }: {
     devShells.x86_64-linux.default =
       nixpkgs.legacyPackages.x86_64-linux.mkShell
-      {};
+      {
+        packages = [
+          nixpkgs.legacyPackages.x86_64-linux.ruff
+          nixpkgs.legacyPackages.x86_64-linux.python3Packages.mypy
+          nixpkgs.legacyPackages.x86_64-linux.python3Packages.sqlalchemy
+        ];
+      };
   };
 }
+❄ $ python3 -c 'import sqlalchemy'
 ❄ $
$ cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
  }: {
    devShells.x86_64-linux.default =
      nixpkgs.legacyPackages.x86_64-linux.mkShell
      {
        packages = [
          nixpkgs.legacyPackages.x86_64-linux.ruff
          nixpkgs.legacyPackages.x86_64-linux.python3Packages.mypy
          nixpkgs.legacyPackages.x86_64-linux.python3Packages.sqlalchemy
        ];
      };
  };
}
❄ $ python3 -c 'import sqlalchemy'
❄ $

The above is platform-dependent solution for creating a dev shell using Nix flake. We use flake-utils, particularly eachDefaultSystem and filterPackages, to support multiple platforms, but you may not need flake-utils for that.

eachDefaultSystem is a function that accepts a single argument. That argument is a function (dev-shell-for in the following code) that accepts a single argument that is a string representing a platform and returns an attribute set.

That attribute set is special in that its attributes do not need x86_64-linux, because eachDefaultSystem will put each default system at its place instead. Moreover, system represents the string with the actual each platform and because dev-shell-for returns attribute set, we can happily use ${system} where platform is needed.

   outputs = {
     self,
     nixpkgs,
-  }: {
-    devShells.x86_64-linux.default =
-      nixpkgs.legacyPackages.x86_64-linux.mkShell
+    flake-utils,
+  }: let
+    inherit (flake-utils.lib) eachDefaultSystem;
+    dev-shell-for = system:
       {
-        packages = [
-          nixpkgs.legacyPackages.x86_64-linux.ruff
-          nixpkgs.legacyPackages.x86_64-linux.python3Packages.mypy
-          nixpkgs.legacyPackages.x86_64-linux.python3Packages.sqlalchemy
-        ];
+        devShells.default =
+          nixpkgs.legacyPackages.${system}.mkShell
+          {
+            packages = [
+              nixpkgs.legacyPackages.${system}.ruff
+              nixpkgs.legacyPackages.${system}.python3Packages.mypy
+              nixpkgs.legacyPackages.${system}.python3Packages.sqlalchemy
+            ];
+          };
       };
-  };
+  in
+    eachDefaultSystem dev-shell-for;
 }
 ❄ $
$ cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (flake-utils.lib) eachDefaultSystem;
    dev-shell-for = system:
      {
        devShells.default =
          nixpkgs.legacyPackages.${system}.mkShell
          {
            packages = [
              nixpkgs.legacyPackages.${system}.ruff
              nixpkgs.legacyPackages.${system}.python3Packages.mypy
              nixpkgs.legacyPackages.${system}.python3Packages.sqlalchemy
            ];
          };
      };
  in
    eachDefaultSystem dev-shell-for;
}
❄ $

Why ${system} is sometimes used and sometimes not? Well… eachDefaultSystem function takes the output of its argument (which is a function returning an attribute set) and in that output attribute set automatically adds ${system} to its arguments. It means that

let
  dev-shell-for = system:
    {
      devShells.default = ...
    };
in
  eachDefaultSystem dev-shell-for

Adding new dev-shell-for function makes the code more readable but takes more lines. Experienced Nix buddies may write something like this instead:

eachDefaultSystem (system: {
  devShells.default = ...
})

evaluates to

{
  devShells.aarch64-linux.default = ...
  devShells.aarch64-darwin.default = ...
  devShells.x86_64-darwin.default = ...
  devShells.x86_64-linux.default = ...
}

because "aarch64-linux", "aarch64-darwin", "x86_64-darwin", and "x86_64-linux" are default systems by default.

On the other hand, eachDefaultSystem does not manipulate the values of the attributes. In other words, it keeps what is after = and before ; and the ${system} is needed there to make

let
  dev-shell-for = system:
    {
      devShells.default = ... ${system} ...
    };
in
  eachDefaultSystem dev-shell-for

be evaluated to

{
  devShells.aarch64-linux.default = ... aarch64-linux ...
  devShells.aarch64-darwin.default = ... aarch64-darwin ...
  devShells.x86_64-darwin.default = ... x86_64-darwin ...
  devShells.x86_64-linux.default = ... x86_64-linux ...
}

filterPackages is a function that accepts two arguments, a string representing a platform and an attribute set with packages to by filtered out based on the platform. It returns the filtered attribute set.

     nixpkgs,
     flake-utils,
   }: let
-    inherit (flake-utils.lib) eachDefaultSystem;
+    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
     dev-shell-for = system:
       {
-        devShells.default =
-          nixpkgs.legacyPackages.${system}.mkShell
+        devShells =
+          filterPackages
+          system
           {
-            packages = [
-              nixpkgs.legacyPackages.${system}.ruff
-              nixpkgs.legacyPackages.${system}.python3Packages.mypy
-              nixpkgs.legacyPackages.${system}.python3Packages.sqlalchemy
-            ];
+            default =
+              nixpkgs.legacyPackages.${system}.mkShell
+              {
+                packages = [
+                  nixpkgs.legacyPackages.${system}.ruff
+                  nixpkgs.legacyPackages.${system}.python3Packages.mypy
+                  nixpkgs.legacyPackages.${system}.python3Packages.sqlalchemy
+                ];
+              };
           };
       };
   in
$ cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-for = system:
      {
        devShells =
          filterPackages
          system
          {
            default =
              nixpkgs.legacyPackages.${system}.mkShell
              {
                packages = [
                  nixpkgs.legacyPackages.${system}.ruff
                  nixpkgs.legacyPackages.${system}.python3Packages.mypy
                  nixpkgs.legacyPackages.${system}.python3Packages.sqlalchemy
                ];
              };
          };
      };
  in
    eachDefaultSystem dev-shell-for;
}
❄ $

When everything is evaluated on x86_64-linux platform, there is devShells.x86_64-linux.default dev shell that includes ruff, mypy, and sqlalchemy packages if those are available for x86_64-linux platform.

Recall that the two-arguments function is in fact a single-argument function that returns another single-argument function because currying. Also, the attribute set with packages is an attribute set where attribute names are strings representing package names and the values are derivations.

(A small question for reader: When ; is used solely to finish = or with or inherit, what is finished by the ; just after eachDefaultSystem dev-shell-for? “+ see all” shows the whole code.)

We are finally happy to have the Nix flake for development. Now it’s time for conventions and refactoring.

By convention, nixpkgs.legacyPackages.${system} is let to be pkgs and we can shorten code by using with. This is why we have nixpkgs, packages, and pkgs with different meanings in one place. This is confusing.

Also, there are formatting conventions; we use alejandra and all code snippets here conform to the nix fmt . command. However, we restrain from shortening the code just to save some lines.

     flake-utils,
   }: let
     inherit (flake-utils.lib) eachDefaultSystem filterPackages;
-    dev-shell-for = system:
-      {
-        devShells =
-          filterPackages
-          system
-          {
-            default =
-              nixpkgs.legacyPackages.${system}.mkShell
-              {
-                packages = [
-                  nixpkgs.legacyPackages.${system}.ruff
-                  nixpkgs.legacyPackages.${system}.python3Packages.mypy
-                  nixpkgs.legacyPackages.${system}.python3Packages.sqlalchemy
-                ];
-              };
-          };
-      };
+    dev-shell-for = system: let
+      pkgs = nixpkgs.legacyPackages.${system};
+    in {
+      formatter = pkgs.alejandra;
+      devShells =
+        filterPackages
+        system
+        {
+          default =
+            pkgs.mkShell
+            {
+              packages = with pkgs; [
+                ruff
+                python3Packages.mypy
+                python3Packages.sqlalchemy
+              ];
+            };
+        };
+    };
   in
     eachDefaultSystem dev-shell-for;
 }
$ cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-for = system: let
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                python3Packages.mypy
                python3Packages.sqlalchemy
              ];
            };
        };
    };
  in
    eachDefaultSystem dev-shell-for;
}
❄ $

Instead of enumerating Python3 packages with python3Packages. prefix, we use python3.withPackages function. python3.withPackages function accepts a single argument that is a function (wanted-py-pkgs in the following code) that is given the attribute set with all Python3 packages available and is expected to return the list of wanted packages or – to be more precise – the list of wanted derivations.

     inherit (flake-utils.lib) eachDefaultSystem filterPackages;
     dev-shell-for = system: let
       pkgs = nixpkgs.legacyPackages.${system};
+      wanted-py-pkgs = available-pkgs:
+        with available-pkgs; [
+          mypy
+          sqlalchemy
+        ];
     in {
       formatter = pkgs.alejandra;
       devShells =
@@ -19,8 +24,7 @@
             {
               packages = with pkgs; [
                 ruff
-                python3Packages.mypy
-                python3Packages.sqlalchemy
+                (python3.withPackages wanted-py-pkgs)
               ];
             };
         };
$ cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-for = system: let
      pkgs = nixpkgs.legacyPackages.${system};
      wanted-py-pkgs = available-pkgs:
        with available-pkgs; [
          mypy
          sqlalchemy
        ];
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages wanted-py-pkgs)
              ];
            };
        };
    };
  in
    eachDefaultSystem dev-shell-for;
}
❄ $

By introducing wanted-py-pkgs we moved the list of wanted Python3 packages apart from other wanted packages, which is bad practice. This problem is usually solved by defining function in place instead:

             {
               packages = with pkgs; [
                 ruff
-                python3Packages.mypy
-                python3Packages.sqlalchemy
+                (python3.withPackages (ps: with ps; [
+                  mypy
+                  sqlalchemy
+                ]))
               ];
             };
         };
$ cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-for = system: let
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages (ps: with ps; [
                  mypy
                  sqlalchemy
                ]))
              ];
            };
        };
    };
  in
    eachDefaultSystem dev-shell-for;
}
❄ $

which looks nice and clean when it’s written but not when read.

To keep wanted-py-pkgs function but also keep wanted Python3 packages with others, we define wanted-py-pkgs to accept two arguments and partially apply the function, i.e., apply the two-argument function to single argument only.

Recall that ( ) is only used to specify precedence.

     nixpkgs,
     flake-utils,
   }: let
+    inherit (nixpkgs.lib) attrValues getAttrs;
     inherit (flake-utils.lib) eachDefaultSystem filterPackages;
     dev-shell-for = system: let
       pkgs = nixpkgs.legacyPackages.${system};
+      wanted-py-pkgs = wanted-list: available-pkgs:
+        attrValues (getAttrs wanted-list available-pkgs);
     in {
       formatter = pkgs.alejandra;
       devShells =
@@ -19,8 +22,12 @@
             {
               packages = with pkgs; [
                 ruff
-                python3Packages.mypy
-                python3Packages.sqlalchemy
+                (python3.withPackages
+                  (wanted-py-pkgs
+                    [
+                      "mypy"
+                      "sqlalchemy"
+                    ]))
               ];
             };
         };
$ cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (nixpkgs.lib) attrValues getAttrs;
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-for = system: let
      pkgs = nixpkgs.legacyPackages.${system};
      wanted-py-pkgs = wanted-list: available-pkgs:
        attrValues (getAttrs wanted-list available-pkgs);
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages
                  (wanted-py-pkgs
                    [
                      "mypy"
                      "sqlalchemy"
                    ]))
              ];
            };
        };
    };
  in
    eachDefaultSystem dev-shell-for;
}
❄ $

Probably the last thing is that the dependencies of a Python3 project are used to be specified in the pyproject.toml file. (At least we use pyproject.toml.) Relevant part of the pyproject.toml may look like:

dependencies = [
  "SQLAlchemy>=2.0",
  "alembic",
]

which represents a list of package names available from PyPI.

The summary of dependencies for the development is currently: ruff executable from the Nixpkgs, mypy Python3 package that is not a dependency of the Python3 project but it’s needed for the development, alembic that is the dependency of the project, and SQLAlchemy that is also the dependency and moreover its PyPI name mismatch its name in the Nixpkgs.

Along with the project’s dependencies, there are also optional dependencies for test and docs.

So:

In the following code, we define translate-py-names function to translate the list of PyPI names to Nix names for the given list of Python3 package names and then define and use dependencies from the pyproject.toml file.

     nixpkgs,
     flake-utils,
   }: let
+    inherit (builtins) match head;
+    inherit (nixpkgs.lib) trivial;
+    proj-toml = trivial.importTOML ./pyproject.toml;
+    translate-py-names = wanted-list: let
+      pypi-name-to-nix-name = {
+        # "pypi-name" = "nix-name";
+        SQLAlchemy = "sqlalchemy";
+        types-PyYAML = "types-pyyaml";
+      };
+      to-nix-name = pypi-name: let
+        pkg-name = head (match "([^ =<>;~]*).*" pypi-name);
+      in
+        pypi-name-to-nix-name.${pkg-name} or pkg-name;
+    in
+      map to-nix-name wanted-list;
+    proj-deps = translate-py-names proj-toml.project.dependencies;
+    test-deps = translate-py-names proj-toml.project.optional-dependencies.test;
+    docs-deps = translate-py-names proj-toml.project.optional-dependencies.docs;
+
     inherit (nixpkgs.lib) attrValues getAttrs;
     inherit (flake-utils.lib) eachDefaultSystem filterPackages;
     dev-shell-for = system: let
@@ -24,10 +43,14 @@
                 ruff
                 (python3.withPackages
                   (wanted-py-pkgs
-                    [
-                      "mypy"
-                      "sqlalchemy"
-                    ]))
+                    (
+                      [
+                        "mypy"
+                      ]
+                      ++ proj-deps
+                      ++ test-deps
+                      ++ docs-deps
+                    )))
               ];
             };
         };
$ cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (builtins) match head;
    inherit (nixpkgs.lib) trivial;
    proj-toml = trivial.importTOML ./pyproject.toml;
    translate-py-names = wanted-list: let
      pypi-name-to-nix-name = {
        # "pypi-name" = "nix-name";
        SQLAlchemy = "sqlalchemy";
        types-PyYAML = "types-pyyaml";
      };
      to-nix-name = pypi-name: let
        pkg-name = head (match "([^ =<>;~]*).*" pypi-name);
      in
        pypi-name-to-nix-name.${pkg-name} or pkg-name;
    in
      map to-nix-name wanted-list;
    proj-deps = translate-py-names proj-toml.project.dependencies;
    test-deps = translate-py-names proj-toml.project.optional-dependencies.test;
    docs-deps = translate-py-names proj-toml.project.optional-dependencies.docs;

    inherit (nixpkgs.lib) attrValues getAttrs;
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-for = system: let
      pkgs = nixpkgs.legacyPackages.${system};
      wanted-py-pkgs = wanted-list: available-pkgs:
        attrValues (getAttrs wanted-list available-pkgs);
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages
                  (wanted-py-pkgs
                    (
                      [
                        "mypy"
                      ]
                      ++ proj-deps
                      ++ test-deps
                      ++ docs-deps
                    )))
              ];
            };
        };
    };
  in
    eachDefaultSystem dev-shell-for;
}
❄ $

This is (the idea behind) our Nix flake for platform-independent develepment environment.

We want all dependencies for development ready when we type

nix develop

and this is how we achieve that:

Nix flakes for distributing

There are things we want to share between our projects, like Python3 packages. We also want to run applications we develop.

We want building of packages and running applications as easy as making dev shells – just nix build or nix run, respectively. This is about building and running a Python3 project. We start with the flake from the previous section, dropping test and docs dependencies.

-$ cat flake.nix ; nix develop
+$ cat flake.nix ; nix build
 {
   outputs = {
     self,
@@ -21,8 +21,6 @@
     in
       map to-nix-name wanted-list;
     proj-deps = translate-py-names proj-toml.project.dependencies;
-    test-deps = translate-py-names proj-toml.project.optional-dependencies.test;
-    docs-deps = translate-py-names proj-toml.project.optional-dependencies.docs;

     inherit (nixpkgs.lib) attrValues getAttrs;
     inherit (flake-utils.lib) eachDefaultSystem filterPackages;
@@ -48,8 +46,6 @@
                         "mypy"
                       ]
                       ++ proj-deps
-                      ++ test-deps
-                      ++ docs-deps
                     )))
               ];
             };
@@ -58,4 +54,4 @@
   in
     eachDefaultSystem dev-shell-for;
 }
-❄ $
+error: flake 'path:/my/repo' does not provide attribute 'packages.x86_64-linux.default' or 'defaultPackage.x86_64-linux'
$ cat flake.nix ; nix build
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (builtins) match head;
    inherit (nixpkgs.lib) trivial;
    proj-toml = trivial.importTOML ./pyproject.toml;
    translate-py-names = wanted-list: let
      pypi-name-to-nix-name = {
        # "pypi-name" = "nix-name";
        SQLAlchemy = "sqlalchemy";
        types-PyYAML = "types-pyyaml";
      };
      to-nix-name = pypi-name: let
        pkg-name = head (match "([^ =<>;~]*).*" pypi-name);
      in
        pypi-name-to-nix-name.${pkg-name} or pkg-name;
    in
      map to-nix-name wanted-list;
    proj-deps = translate-py-names proj-toml.project.dependencies;

    inherit (nixpkgs.lib) attrValues getAttrs;
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-for = system: let
      pkgs = nixpkgs.legacyPackages.${system};
      wanted-py-pkgs = wanted-list: available-pkgs:
        attrValues (getAttrs wanted-list available-pkgs);
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages
                  (wanted-py-pkgs
                    (
                      [
                        "mypy"
                      ]
                      ++ proj-deps
                    )))
              ];
            };
        };
    };
  in
    eachDefaultSystem dev-shell-for;
}
error: flake 'path:/my/repo' does not provide attribute 'packages.x86_64-linux.default' or 'defaultPackage.x86_64-linux'

To build a package, the attribute set returned by the outputs function of the flake.nix needs to contain packages.x86_64-linux.default. We already know how to bypass platform-dependency – using dev-shell-for and eachDefaultSystem functions.

     inherit (nixpkgs.lib) attrValues getAttrs;
     inherit (flake-utils.lib) eachDefaultSystem filterPackages;
-    dev-shell-for = system: let
+    dev-shell-and-packages-for = system: let
       pkgs = nixpkgs.legacyPackages.${system};
       wanted-py-pkgs = wanted-list: available-pkgs:
         attrValues (getAttrs wanted-list available-pkgs);
@@ -50,8 +50,9 @@
               ];
             };
         };
+      packages.default = {};
     };
   in
-    eachDefaultSystem dev-shell-for;
+    eachDefaultSystem dev-shell-and-packages-for;
 }
-error: flake 'path:/my/repo' does not provide attribute 'packages.x86_64-linux.default' or 'defaultPackage.x86_64-linux'
+error: expected flake output attribute 'packages.x86_64-linux.default' to be a derivation or path but found a set: { }
$ cat flake.nix ; nix build
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (builtins) match head;
    inherit (nixpkgs.lib) trivial;
    proj-toml = trivial.importTOML ./pyproject.toml;
    translate-py-names = wanted-list: let
      pypi-name-to-nix-name = {
        # "pypi-name" = "nix-name";
        SQLAlchemy = "sqlalchemy";
        types-PyYAML = "types-pyyaml";
      };
      to-nix-name = pypi-name: let
        pkg-name = head (match "([^ =<>;~]*).*" pypi-name);
      in
        pypi-name-to-nix-name.${pkg-name} or pkg-name;
    in
      map to-nix-name wanted-list;
    proj-deps = translate-py-names proj-toml.project.dependencies;

    inherit (nixpkgs.lib) attrValues getAttrs;
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-and-packages-for = system: let
      pkgs = nixpkgs.legacyPackages.${system};
      wanted-py-pkgs = wanted-list: available-pkgs:
        attrValues (getAttrs wanted-list available-pkgs);
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages
                  (wanted-py-pkgs
                    (
                      [
                        "mypy"
                      ]
                      ++ proj-deps
                    )))
              ];
            };
        };
      packages.default = {};
    };
  in
    eachDefaultSystem dev-shell-and-packages-for;
}
error: expected flake output attribute 'packages.x86_64-linux.default' to be a derivation or path but found a set: { }

To build a package, we use callPackage function; see the origin of callPackage.

The callPackage function accepts two arguments – a function and an attribute set. The function (build-pp in the following code) is given a single attribute set by the callPackage – the attribute set with filtered Nixpkgs packages that are needed by the build-pp function. callPackage knows what packages from Nixpkgs build-pp needs by investigating the names of the attributes of the build-pp’s parameter.

The second argument for the callPackage is used to override Nixpkgs packages and is usually { } in our case.

The build-pp function is in fact just wrapper that sets up the information about the package to be built for python3Packages’s buildPythonPackage function.

The documentation doesn’t look convincing, but if interested, see buildPythonPackage function, developing with Python, and callPackage tutorial.

       map to-nix-name wanted-list;
     proj-deps = translate-py-names proj-toml.project.dependencies;

+    build-pp = {python3Packages}:
+      python3Packages.buildPythonPackage
+      {
+        pname = proj-toml.project.name;
+        inherit (proj-toml.project) version;
+        src = ./.;
+        pyproject = true;
+        build-system = [python3Packages.setuptools];
+        dependencies = attrValues (getAttrs proj-deps python3Packages);
+      };
+
     inherit (nixpkgs.lib) attrValues getAttrs;
     inherit (flake-utils.lib) eachDefaultSystem filterPackages;
     dev-shell-and-packages-for = system: let
@@ -50,9 +61,11 @@
               ];
             };
         };
-      packages.default = {};
+      packages.default = pkgs.callPackage build-pp {};
     };
   in
     eachDefaultSystem dev-shell-and-packages-for;
 }
-error: expected flake output attribute 'packages.x86_64-linux.default' to be a derivation or path but found a set: { }
+$ ./result/bin/run
+foo
+$
$ cat flake.nix ; nix build
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (builtins) match head;
    inherit (nixpkgs.lib) trivial;
    proj-toml = trivial.importTOML ./pyproject.toml;
    translate-py-names = wanted-list: let
      pypi-name-to-nix-name = {
        # "pypi-name" = "nix-name";
        SQLAlchemy = "sqlalchemy";
        types-PyYAML = "types-pyyaml";
      };
      to-nix-name = pypi-name: let
        pkg-name = head (match "([^ =<>;~]*).*" pypi-name);
      in
        pypi-name-to-nix-name.${pkg-name} or pkg-name;
    in
      map to-nix-name wanted-list;
    proj-deps = translate-py-names proj-toml.project.dependencies;

    build-pp = {python3Packages}:
      python3Packages.buildPythonPackage
      {
        pname = proj-toml.project.name;
        inherit (proj-toml.project) version;
        src = ./.;
        pyproject = true;
        build-system = [python3Packages.setuptools];
        dependencies = attrValues (getAttrs proj-deps python3Packages);
      };

    inherit (nixpkgs.lib) attrValues getAttrs;
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-and-packages-for = system: let
      pkgs = nixpkgs.legacyPackages.${system};
      wanted-py-pkgs = wanted-list: available-pkgs:
        attrValues (getAttrs wanted-list available-pkgs);
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages
                  (wanted-py-pkgs
                    (
                      [
                        "mypy"
                      ]
                      ++ proj-deps
                    )))
              ];
            };
        };
      packages.default = pkgs.callPackage build-pp {};
    };
  in
    eachDefaultSystem dev-shell-and-packages-for;
}
$ ./result/bin/run
foo
$

Needed to say that for successful build yet we need:

$ cat pyproject.toml
[project]
name = "pp"
version = "0.0.0"
dependencies = [
  "SQLAlchemy>=2.0",
  "alembic",
]

[project.scripts]
run = "pp.__init__:main"

along with

$ cat pp/__init__.py
def main():
    print("foo")

Possible is a little change to use nix run instead of nix build:

-$ cat flake.nix ; nix build
+$ cat flake.nix ; nix run
 {
   outputs = {
     self,
@@ -62,10 +62,13 @@
             };
         };
       packages.default = pkgs.callPackage build-pp {};
+      apps.default = {
+        type = "app";
+        program = "${self.packages.${system}.default}/bin/run";
+      };
     };
   in
     eachDefaultSystem dev-shell-and-packages-for;
 }
-$ ./result/bin/run
 foo
 $
$ cat flake.nix ; nix run
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (builtins) match head;
    inherit (nixpkgs.lib) trivial;
    proj-toml = trivial.importTOML ./pyproject.toml;
    translate-py-names = wanted-list: let
      pypi-name-to-nix-name = {
        # "pypi-name" = "nix-name";
        SQLAlchemy = "sqlalchemy";
        types-PyYAML = "types-pyyaml";
      };
      to-nix-name = pypi-name: let
        pkg-name = head (match "([^ =<>;~]*).*" pypi-name);
      in
        pypi-name-to-nix-name.${pkg-name} or pkg-name;
    in
      map to-nix-name wanted-list;
    proj-deps = translate-py-names proj-toml.project.dependencies;

    build-pp = {python3Packages}:
      python3Packages.buildPythonPackage
      {
        pname = proj-toml.project.name;
        inherit (proj-toml.project) version;
        src = ./.;
        pyproject = true;
        build-system = [python3Packages.setuptools];
        dependencies = attrValues (getAttrs proj-deps python3Packages);
      };

    inherit (nixpkgs.lib) attrValues getAttrs;
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-and-packages-for = system: let
      pkgs = nixpkgs.legacyPackages.${system};
      wanted-py-pkgs = wanted-list: available-pkgs:
        attrValues (getAttrs wanted-list available-pkgs);
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages
                  (wanted-py-pkgs
                    (
                      [
                        "mypy"
                      ]
                      ++ proj-deps
                    )))
              ];
            };
        };
      packages.default = pkgs.callPackage build-pp {};
      apps.default = {
        type = "app";
        program = "${self.packages.${system}.default}/bin/run";
      };
    };
  in
    eachDefaultSystem dev-shell-and-packages-for;
}
foo
$

We had to add apps into the attribute set, the output of the outputs function, because the executable (run) has different name than the package (pp).

In the apps.default attribute set, the value of the program attribute says something like: “Take the result of the evaluation of the application of the outputs, self, check its default package of packages for the system – it should produce a /bin/run executable – and that’s what should be executed on nix run.

The following is small example of how to use the /my/repo Nix flake somewhere else, e.g., in the /my/dep-rep Nix flake:

$ cd /my/dep-rep/ ; cat flake.nix ; nix run
{
  inputs = {
    repo.url = "path:///my/repo/";
  };
  outputs = {
    self,
    repo,
  }: {
    apps.x86_64-linux.default = {
      type = "app";
      program = "${repo.packages.x86_64-linux.default}/bin/run";
    };
  };
}
foo
$

In the code above, there is a new flake /my/dep-rep. In its flake.nix there is the inputs attribute set along with the outputs.

inputs specifies where Nix build tool commands (like nix run) should look for unknown inputs to the outputs function. self is known but /my/repo is not.

outputs returns an platform-specific attribute set. apps is used by the nix run command the same way the packages is used by the nix build and devShells by the nix develop.

The default app is run by nix run. The program being run is what is returned by the /my/repo Nix flake. Particularly, there is packages attribute in the /my/repo Nix flake, which contains the "x86_64-linux" platform with the default attribute. That default attribute is something that, when evaluated, creates bin/run executable.

We can’t be happy yet. Sure we can nix build and nix run things but what if we want to develop?

-$ cat flake.nix ; nix run
+$ cd /my/repo/ ; cat flake.nix ; nix develop
 {
   outputs = {
     self,
@@ -70,5 +70,4 @@
   in
     eachDefaultSystem dev-shell-and-packages-for;
 }
-foo
-$
+❄ $ python3 -c 'import pp'
+❄ $
$ cd /my/repo/ ; cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (builtins) match head;
    inherit (nixpkgs.lib) trivial;
    proj-toml = trivial.importTOML ./pyproject.toml;
    translate-py-names = wanted-list: let
      pypi-name-to-nix-name = {
        # "pypi-name" = "nix-name";
        SQLAlchemy = "sqlalchemy";
        types-PyYAML = "types-pyyaml";
      };
      to-nix-name = pypi-name: let
        pkg-name = head (match "([^ =<>;~]*).*" pypi-name);
      in
        pypi-name-to-nix-name.${pkg-name} or pkg-name;
    in
      map to-nix-name wanted-list;
    proj-deps = translate-py-names proj-toml.project.dependencies;

    build-pp = {python3Packages}:
      python3Packages.buildPythonPackage
      {
        pname = proj-toml.project.name;
        inherit (proj-toml.project) version;
        src = ./.;
        pyproject = true;
        build-system = [python3Packages.setuptools];
        dependencies = attrValues (getAttrs proj-deps python3Packages);
      };

    inherit (nixpkgs.lib) attrValues getAttrs;
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-and-packages-for = system: let
      pkgs = nixpkgs.legacyPackages.${system};
      wanted-py-pkgs = wanted-list: available-pkgs:
        attrValues (getAttrs wanted-list available-pkgs);
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages
                  (wanted-py-pkgs
                    (
                      [
                        "mypy"
                      ]
                      ++ proj-deps
                    )))
              ];
            };
        };
      packages.default = pkgs.callPackage build-pp {};
      apps.default = {
        type = "app";
        program = "${self.packages.${system}.default}/bin/run";
      };
    };
  in
    eachDefaultSystem dev-shell-and-packages-for;
}
❄ $ python3 -c 'import pp'
❄ $

However,

-$ cd /my/dep-rep/ ; cat flake.nix ; nix run
+$ cd /my/dep-rep/ ; cat flake.nix ; nix develop
 {
   inputs = {
     repo.url = "path:///my/repo/";
   };
   outputs = {
     self,
+    nixpkgs,
     repo,
   }: {
     apps.x86_64-linux.default = {
       type = "app";
       program = "${repo.packages.x86_64-linux.default}/bin/run";
     };
+    devShells.x86_64-linux.default =
+      nixpkgs.legacyPackages.x86_64-linux.mkShell
+      {
+        packages = with nixpkgs.legacyPackages.x86_64-linux; [
+          ruff
+          python3Packages.mypy
+        ];
+      };
   };
 }
-$
+❄ $ python3 -c 'import pp'
+Traceback (most recent call last):
+  File "<string>", line 1, in <module>
+ModuleNotFoundError: No module named 'pp'
+❄ $
$ cd /my/dep-rep/ ; cat flake.nix ; nix develop
{
  inputs = {
    repo.url = "path:///my/repo/";
  };
  outputs = {
    self,
    nixpkgs,
    repo,
  }: {
    apps.x86_64-linux.default = {
      type = "app";
      program = "${repo.packages.x86_64-linux.default}/bin/run";
    };
    devShells.x86_64-linux.default =
      nixpkgs.legacyPackages.x86_64-linux.mkShell
      {
        packages = with nixpkgs.legacyPackages.x86_64-linux; [
          ruff
          python3Packages.mypy
        ];
      };
  };
}
❄ $ python3 -c 'import pp'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'pp'
❄ $

We already know how to create dev shell and install packages there. Both, the packages directly from Nixpkgs and Python3 packages. But where to put pp, the Python3 package from /my/repo Nix flake?

         packages = with nixpkgs.legacyPackages.x86_64-linux; [
           ruff
           python3Packages.mypy
+          python3Packages.pp
         ];
       };
   };
 }
-❄ $ python3 -c 'import pp'
-Traceback (most recent call last):
-  File "<string>", line 1, in <module>
-ModuleNotFoundError: No module named 'pp'
-❄ $
+error:
+       … while calling the 'derivationStrict' builtin
+         at <nix/derivation-internal.nix>:34:12:
+           33|
+           34|   strict = derivationStrict drvAttrs;
+             |            ^
+           35|
+
+       … while evaluating derivation 'nix-shell'
+         whose name attribute is located at /nix/store/5bshdizjcs9agk532fy4373kl7gbn9cr-source/pkgs/stdenv/generic/make-derivation.nix:375:7
+
+       … while evaluating attribute 'nativeBuildInputs' of derivation 'nix-shell'
+         at /nix/store/5bshdizjcs9agk532fy4373kl7gbn9cr-source/pkgs/stdenv/generic/make-derivation.nix:419:7:
+          418|       depsBuildBuild              = elemAt (elemAt dependencies 0) 0;
+          419|       nativeBuildInputs           = elemAt (elemAt dependencies 0) 1;
+             |       ^
+          420|       depsBuildTarget             = elemAt (elemAt dependencies 0) 2;
+
+       (stack trace truncated; use '--show-trace' to show the full, detailed trace)
+
+       error: attribute 'pp' missing
+       at /nix/store/hrslhnqzaikznlkyzvx6c98y8kp1k4il-source/flake.nix:20:11:
+           19|           python3Packages.mypy
+           20|           python3Packages.pp
+             |           ^
+           21|         ];
+       Did you mean one of pip, pq, py, zpp or av?
+$
$ cd /my/dep-rep/ ; cat flake.nix ; nix develop
{
  inputs = {
    repo.url = "path:///my/repo/";
  };
  outputs = {
    self,
    nixpkgs,
    repo,
  }: {
    apps.x86_64-linux.default = {
      type = "app";
      program = "${repo.packages.x86_64-linux.default}/bin/run";
    };
    devShells.x86_64-linux.default =
      nixpkgs.legacyPackages.x86_64-linux.mkShell
      {
        packages = with nixpkgs.legacyPackages.x86_64-linux; [
          ruff
          python3Packages.mypy
          python3Packages.pp
        ];
      };
  };
}
error:
       … while calling the 'derivationStrict' builtin
         at <nix/derivation-internal.nix>:34:12:
           33|
           34|   strict = derivationStrict drvAttrs;
             |            ^
           35|

       … while evaluating derivation 'nix-shell'
         whose name attribute is located at /nix/store/5bshdizjcs9agk532fy4373kl7gbn9cr-source/pkgs/stdenv/generic/make-derivation.nix:375:7

       … while evaluating attribute 'nativeBuildInputs' of derivation 'nix-shell'
         at /nix/store/5bshdizjcs9agk532fy4373kl7gbn9cr-source/pkgs/stdenv/generic/make-derivation.nix:419:7:
          418|       depsBuildBuild              = elemAt (elemAt dependencies 0) 0;
          419|       nativeBuildInputs           = elemAt (elemAt dependencies 0) 1;
             |       ^
          420|       depsBuildTarget             = elemAt (elemAt dependencies 0) 2;

       (stack trace truncated; use '--show-trace' to show the full, detailed trace)

       error: attribute 'pp' missing
       at /nix/store/hrslhnqzaikznlkyzvx6c98y8kp1k4il-source/flake.nix:20:11:
           19|           python3Packages.mypy
           20|           python3Packages.pp
             |           ^
           21|         ];
       Did you mean one of pip, pq, py, zpp or av?
$

Conceptually, we want /my/repo Nix flake to improve Nixpkgs by adding new Python3 package. Nixpkg’s overlays are used for this purpose.

An overlay is a function accepting two arguments, final and prev, both being attribute set containing packages (names and derivations). Overlays are put on top of each other starting at nixpkgs. prev denotes packages (attribute set) the actual overlay is applied on (i.e., the result of the application of the previous overlay) and final is the result attribute set of all overlays applied.

This is confusing. How could the input parameter to the function, final, be the result of the evaluation of this and every other overlay function? Recall that the Nix language is declarative. We can’t say how, we only say what. How is the problem of Nix build tool.

By Nix flakes convention, the result of the outputs function from flake.nix file, the attribute set, may contain overlays, which is an attribute set containing overlays. overlays is similar to devShells and packages, but it is platform-independent – while devShells.x86_64-linux.default is necessary and we use eachDefaultSystem to support multiple platforms, overlays.default is perfectly fine and it is an error to put overlays in the (attribute set) result of the dev-shell-and-packages-for function.

In the following code, platform-independent-outputs is the name used for the attribute set with platform-independent outputs like overlays. Then, the // operator is used to augment platform-independent outputs by the platform-dependent ones and produce the end result of the evaluation of the outputs function from flake.nix.

Overlays are used to modify the whole nixpkgs attribute set. However, addition of new Python3 package only affects the python3Packages attribute of that set – the use case for the pythonPackagesExtensions.

pythonPackagesExtensions is an attribute of the attribute set returned by an overlay (overlay is function). It is a list with overlays that are applied to the “all Python3 package set” python3Packages at once.

So, to augment nixpkgs with new Python3 package, i.e., to add pp into the nixpkgs’s python3Packages attribute set, the following is needed: (1) Create new overlay with new package (new-py-pkgs in the following code) to augment python3Packages, (2) create new overlay with the pythonPackagesExtensions attribute (extend-py-extensions in the following code) to augment nixpkgs and append our new-py-pkgs to that attribute, (3) set the default of the overlays. It could be that default = extend-py-extensions but we get ready for potential need to compose additional overlays from inputs.

Feel free to study overlays and pythonPackagesExtensions.

The code for the /my/repo Nix flake stored in its flake.nix is now:

@@ -36,7 +36,7 @@
     inherit (nixpkgs.lib) attrValues getAttrs;
     inherit (flake-utils.lib) eachDefaultSystem filterPackages;
     dev-shell-and-packages-for = system: let
-      pkgs = nixpkgs.legacyPackages.${system};
+      pkgs = nixpkgs.legacyPackages.${system}.extend self.overlays.default;
       wanted-py-pkgs = wanted-list: available-pkgs:
         attrValues (getAttrs wanted-list available-pkgs);
     in {
@@ -61,13 +61,35 @@
               ];
             };
         };
-      packages.default = pkgs.callPackage build-pp {};
+      packages.default = pkgs.python3Packages.pp;
+      legacyPackages = pkgs;
       apps.default = {
         type = "app";
         program = "${self.packages.${system}.default}/bin/run";
       };
     };
+
+    inherit (nixpkgs.lib) composeManyExtensions;
+    platform-independent-outputs = {
+      overlays = {
+        new-py-pkgs = final: prev: {
+          pp = final.callPackage build-pp {};
+        };
+        extend-py-extensions = final: prev: {
+          pythonPackagesExtensions =
+            prev.pythonPackagesExtensions
+            ++ [self.overlays.new-py-pkgs];
+        };
+        default =
+          composeManyExtensions
+          [
+            # Put here overlays from the inputs if appropriate.
+            self.overlays.extend-py-extensions
+          ];
+      };
+    };
   in
-    eachDefaultSystem dev-shell-and-packages-for;
+    platform-independent-outputs
+    // eachDefaultSystem dev-shell-and-packages-for;
 }
 ❄ $
$ cd /my/repo/ ; cat flake.nix ; nix develop
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (builtins) match head;
    inherit (nixpkgs.lib) trivial;
    proj-toml = trivial.importTOML ./pyproject.toml;
    translate-py-names = wanted-list: let
      pypi-name-to-nix-name = {
        # "pypi-name" = "nix-name";
        SQLAlchemy = "sqlalchemy";
        types-PyYAML = "types-pyyaml";
      };
      to-nix-name = pypi-name: let
        pkg-name = head (match "([^ =<>;~]*).*" pypi-name);
      in
        pypi-name-to-nix-name.${pkg-name} or pkg-name;
    in
      map to-nix-name wanted-list;
    proj-deps = translate-py-names proj-toml.project.dependencies;

    build-pp = {python3Packages}:
      python3Packages.buildPythonPackage
      {
        pname = proj-toml.project.name;
        inherit (proj-toml.project) version;
        src = ./.;
        pyproject = true;
        build-system = [python3Packages.setuptools];
        dependencies = attrValues (getAttrs proj-deps python3Packages);
      };

    inherit (nixpkgs.lib) attrValues getAttrs;
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-and-packages-for = system: let
      pkgs = nixpkgs.legacyPackages.${system}.extend self.overlays.default;
      wanted-py-pkgs = wanted-list: available-pkgs:
        attrValues (getAttrs wanted-list available-pkgs);
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages
                  (wanted-py-pkgs
                    (
                      [
                        "mypy"
                      ]
                      ++ proj-deps
                    )))
              ];
            };
        };
      packages.default = pkgs.python3Packages.pp;
      legacyPackages = pkgs;
      apps.default = {
        type = "app";
        program = "${self.packages.${system}.default}/bin/run";
      };
    };

    inherit (nixpkgs.lib) composeManyExtensions;
    platform-independent-outputs = {
      overlays = {
        new-py-pkgs = final: prev: {
          pp = final.callPackage build-pp {};
        };
        extend-py-extensions = final: prev: {
          pythonPackagesExtensions =
            prev.pythonPackagesExtensions
            ++ [self.overlays.new-py-pkgs];
        };
        default =
          composeManyExtensions
          [
            # Put here overlays from the inputs if appropriate.
            self.overlays.extend-py-extensions
          ];
      };
    };
  in
    platform-independent-outputs
    // eachDefaultSystem dev-shell-and-packages-for;
}
❄ $

The overlays are functions. We named new-py-pkgs an overlay we are going to apply to the python3Packages.

There is another overlay, extend-py-extensions, that is going to be applied to the nixpkgs.

The extend-py-extensions overlay contains pythonPackagesExtensions – the list of overlays to be applied to the python3Packages – that we extend by our new-py-pkgs overlay.

The last overlay we name is default. The default overlay is composed of many other overlays, currently a single one, extend-py-extensions. (Not too many, indeed.)

The default overlay, composed of the extend-py-extensions overlay, is to be applied to the nixpkgs, which results to the extension of the pythonPackagesExtensions by the new-py-pkgs overlay, and consequentely to the application of the new-py-pkgs overlay to the python3Packages with all the other pythonPackagesExtensions overlays at once.

If lost in overlays, please, keep in mind that there are overlays for nixpkgs and overlays for python3Packages and those are different.

We extend the pkgs, convention name for the nixpkgs for the given system platform, with our default overlay. Therefore, it is possible to write pkgs.python3Packages.pp instead of calling the callPackage function again – pp is already in the python3Packages.

By convention, we name the legacyPackages. It’s used in /my/dep-rep Nix flake.

Interesting is how we reference overlays in the overlays attribute set. { } is a data structure. Writing new-py-pkgs = and then extend-by-extensions = do not bring them in the scope of each other. It’s like JSON. It’s not possible to reference some JSON attribute in another JSON attribute. Neither it’s possible with Nix language’s attribute sets. However, we have self! The result of the evaluation of the call to the outputs function that is given as part of the input to that function. And self contains overlays that contains new-py-pkgs, extend-by-extensions, and default.

What about /my/dep-rep Nix flake?

@@ -18,9 +18,10 @@
         packages = with nixpkgs.legacyPackages.x86_64-linux; [
           ruff
           python3Packages.mypy
-          python3Packages.pp
+          repo.legacyPackages.x86_64-linux.python3Packages.pp
         ];
       };
   };
 }
+❄ $ python3 -c 'import pp'
 ❄ $
$ cd /my/dep-rep/ ; cat flake.nix ; nix develop
{
  inputs = {
    repo.url = "path:///my/repo/";
  };
  outputs = {
    self,
    nixpkgs,
    repo,
  }: {
    apps.x86_64-linux.default = {
      type = "app";
      program = "${repo.packages.x86_64-linux.default}/bin/run";
    };
    devShells.x86_64-linux.default =
      nixpkgs.legacyPackages.x86_64-linux.mkShell
      {
        packages = with nixpkgs.legacyPackages.x86_64-linux; [
          ruff
          python3Packages.mypy
          repo.legacyPackages.x86_64-linux.python3Packages.pp
        ];
      };
  };
}
❄ $ python3 -c 'import pp'
❄ $

Looks like the pp Python3 package is there now.

We may use the overlays.default from the /my/repo input along with the pkgs convention – we extend the platform-dependent pkgs by the repo.overlays.default:

@@ -12,13 +12,15 @@
       type = "app";
       program = "${repo.packages.x86_64-linux.default}/bin/run";
     };
-    devShells.x86_64-linux.default =
+    devShells.x86_64-linux.default = let
+      pkgs = nixpkgs.legacyPackages.x86_64-linux.extend repo.overlays.default;
+    in
       nixpkgs.legacyPackages.x86_64-linux.mkShell
       {
-        packages = with nixpkgs.legacyPackages.x86_64-linux; [
+        packages = with pkgs; [
           ruff
           python3Packages.mypy
-          repo.legacyPackages.x86_64-linux.python3Packages.pp
+          python3Packages.pp
         ];
       };
   };
$
$ cd /my/dep-rep/ ; cat flake.nix ; nix develop
{
  inputs = {
    repo.url = "path:///my/repo/";
  };
  outputs = {
    self,
    nixpkgs,
    repo,
  }: {
    apps.x86_64-linux.default = {
      type = "app";
      program = "${repo.packages.x86_64-linux.default}/bin/run";
    };
    devShells.x86_64-linux.default = let
      pkgs = nixpkgs.legacyPackages.x86_64-linux.extend repo.overlays.default;
    in
      nixpkgs.legacyPackages.x86_64-linux.mkShell
      {
        packages = with pkgs; [
          ruff
          python3Packages.mypy
          python3Packages.pp
        ];
      };
  };
}
❄ $ python3 -c 'import pp'
❄ $

And in addition, we would use the overlays in the /my/dep-rep Nix flake, too, because some day /my/dep-rep could become dependency for some other Nix flake.

@@ -7,13 +7,32 @@
     self,
     nixpkgs,
     repo,
-  }: {
+  }: let
+    inherit (nixpkgs.lib) composeManyExtensions;
+  in {
+    overlays = {
+      new-py-pkgs = final: prev: {
+        # Currently no new Python3 packages by this Nix flake.
+      };
+      extend-py-extensions = final: prev: {
+        pythonPackagesExtensions =
+          prev.pythonPackagesExtensions
+          ++ [self.overlays.new-py-pkgs];
+      };
+      default = composeManyExtensions
+        [
+          # Put here overlays from the inputs if appropriate.
+          repo.overlays.default
+          self.overlays.extend-py-extensions
+        ];
+    };
+
     apps.x86_64-linux.default = {
       type = "app";
       program = "${repo.packages.x86_64-linux.default}/bin/run";
     };
     devShells.x86_64-linux.default = let
-      pkgs = nixpkgs.legacyPackages.x86_64-linux.extend repo.overlays.default;
+      pkgs = nixpkgs.legacyPackages.x86_64-linux.extend self.overlays.default;
     in
       nixpkgs.legacyPackages.x86_64-linux.mkShell
       {
$ cd /my/dep-rep/ ; cat flake.nix ; nix develop
{
  inputs = {
    repo.url = "path:///my/repo/";
  };
  outputs = {
    self,
    nixpkgs,
    repo,
  }: let
    inherit (nixpkgs.lib) composeManyExtensions;
  in {
    overlays = {
      new-py-pkgs = final: prev: {
        # Currently no new Python3 packages by this Nix flake.
      };
      extend-py-extensions = final: prev: {
        pythonPackagesExtensions =
          prev.pythonPackagesExtensions
          ++ [self.overlays.new-py-pkgs];
      };
      default = composeManyExtensions
        [
          # Put here overlays from the inputs if appropriate.
          repo.overlays.default
          self.overlays.extend-py-extensions
        ];
    };

    apps.x86_64-linux.default = {
      type = "app";
      program = "${repo.packages.x86_64-linux.default}/bin/run";
    };
    devShells.x86_64-linux.default = let
      pkgs = nixpkgs.legacyPackages.x86_64-linux.extend self.overlays.default;
    in
      nixpkgs.legacyPackages.x86_64-linux.mkShell
      {
        packages = with pkgs; [
          ruff
          python3Packages.mypy
          python3Packages.pp
        ];
      };
  };
}
❄ $ python3 -c 'import pp'
❄ $

This is (the idea behind) our Nix flake for distributing packages and applications.

We want to build an application when we type

nix build

we want to run an application when we type

nix run

we want our packages to be distributed somewhere where we type

nix develop

and this is how we achieve that:

Nix flakes for deployment

This is about how we deploy our Python3 application to make it run as a service in NixOS in QEMU. If we build.toplevel instead of build.vm, then the results directory contains NixOS system we would deploy with nix copy wrapped in some Bash.

This is not about how to install NixOS.

We start with

$ cd /my/dep-os/ ; cat flake.nix ; nix build .#nixosConfigurations.S.config.system.build.vm
{
  outputs = {
    self,
    nixpkgs,
  }: let
    inherit (nixpkgs.lib) nixosSystem;
  in {
    nixosConfigurations.S =
      nixosSystem
      {
        system = "x86_64-linux";
        modules = [];
      };
  };
}
evaluation warning: system.stateVersion is not set, defaulting to 25.05. Read why this matters on https://nixos.org/manual/nixos/stable/options.html#opt-system.stateVersion.
$ tree result
result

0 directories, 0 files
$

By convention, nixosConfigurations attribute of the attribute set returned by the outputs function of the flake.nix is an attribute set, whose attribute names are names of the systems (like S in our code) and their corresponding values are the results of nixosSystem function applications.

system and modules attributes needs to be present in the attribute set argument when applying nixosSystem function. Though, it is not enough to build a runnable system – even the build succeeds the results directory is empty.

Therefore, we add the basic-system module with the minimal configuration:

@@ -5,16 +5,28 @@
     nixpkgs,
   }: let
     inherit (nixpkgs.lib) nixosSystem;
+    basic-system = _: {
+      boot.loader.grub.device = "/dev/sda";
+      fileSystems."/".device = "/dev/sda1";
+      services.sshd.enable = true;
+      users.users.iam.initialPassword = "itsme";
+      users.users.iam.isNormalUser = true;
+    };
   in {
     nixosConfigurations.S =
       nixosSystem
       {
         system = "x86_64-linux";
-        modules = [];
+        modules = [
+          basic-system
+        ];
       };
   };
 }
 evaluation warning: system.stateVersion is not set, defaulting to 25.05. Read why this matters on https://nixos.org/manual/nixos/stable/options.html#opt-system.stateVersion.
 $ tree result
 result
+├── bin
+│   └── run-nixos-vm -> /nix/store/n0gzj07vpk7fcdfga6dq964gbx000nvk-run-nixos-vm
+└── system -> /nix/store/zl8jrwn78amip4nrbcldqhn1q60j8m00-nixos-system-nixos-25.05.20250318.3549532

-0 directories, 0 files
-$
+3 directories, 1 file
+$ QEMU_NET_OPTS='hostfwd=tcp::2222-:22' ./result/bin/run-nixos-vm
$ cd /my/dep-os/ ; cat flake.nix ; nix build .#nixosConfigurations.S.config.system.build.vm
{
  outputs = {
    self,
    nixpkgs,
  }: let
    inherit (nixpkgs.lib) nixosSystem;
    basic-system = _: {
      boot.loader.grub.device = "/dev/sda";
      fileSystems."/".device = "/dev/sda1";
      services.sshd.enable = true;
      users.users.iam.initialPassword = "itsme";
      users.users.iam.isNormalUser = true;
    };
  in {
    nixosConfigurations.S =
      nixosSystem
      {
        system = "x86_64-linux";
        modules = [
          basic-system
        ];
      };
  };
}
evaluation warning: system.stateVersion is not set, defaulting to 25.05. Read why this matters on https://nixos.org/manual/nixos/stable/options.html#opt-system.stateVersion.
$ tree result
result
├── bin
│   └── run-nixos-vm -> /nix/store/n0gzj07vpk7fcdfga6dq964gbx000nvk-run-nixos-vm
└── system -> /nix/store/zl8jrwn78amip4nrbcldqhn1q60j8m00-nixos-system-nixos-25.05.20250318.3549532

3 directories, 1 file
$ QEMU_NET_OPTS='hostfwd=tcp::2222-:22' ./result/bin/run-nixos-vm

And we are happy to see new system loading in QEMU. It is possible to log in with iam username and itsme password. It also is possible to connect to new system via ssh.

$ ssh -p 2222 iam@localhost
(iam@localhost) Password:
Last login: Thu Mar 20 08:28:45 2025

[iam@nixos:~]$

Configuration is given by the basic-system module in the code above.

A module is a function expecting a single attribute set argument. The output of the module function is an attribute set with some part of the final configuration. The final configuration is composed from all modules.

All modules from the modules list of the nixosSystem’s argument are automatically applied to the automatically generated attribute set containing the following attributes:

If additional arguments are needed, it is possible to put them into the specialArgs attribute (next to the system and modules) of the nixosSystem’s input. Then, nixosSystem will pass the additional arguments to all modules automatically, too.

A module may looks like:

{
  pkgs,
  config,
  ...
}: {
  config.boot.loader.grub.device = "/dev/sda";
  config.fileSystems."/".device = "/dev/sda1";
  config.services.sshd.enable = true;
  config.users.users = {
    iam = {
      initialPassword = "itsme";
      isNormalUser = true;
    };
  };
}

The config in the input attribute set is the final overall configuration. The config in the output attribute set is partial configuration provided by this module. These two configs are different.

This is confusing, because it looks like the config is somehow modified. However, recall that the Nix language is purely functional. Things can’t be changed.

The same works for options that is not used in the example above.

... is used because more arguments are passed to the module function.

If only config is used in the module, it is optional. The module above is the same as the basic-system module in previous example.

Along with config and options, the result attribute set of a module function may contain imports – a list of modules that will be merged into that module, i.e., will be part of the output attribute set of the module (module is function.)

This is in nutshell how NixOS module system works.

Now to the serve-pp service; we need a module for it and then use that module. The question is to which Nix flake put the configuration to? Both are possible – /my/repo and /my/dep-os.

In the following code, we: (1) Create module for new systemd service serve-pp to run executable of pp Python3 package that is part of /my/repo Nix flake, (2) enable serve-pp service in the /my/dep-os Nix flake and (3) configure the new system to run serve-pp periodically.

The update to the /my/repo Nix flake is more about systemd.service and how to map it to the NixOS options:

@@ -87,6 +87,26 @@
             self.overlays.extend-py-extensions
           ];
       };
+
+      nixosModules = {
+        serve-pp = {lib, config, pkgs, ...}: let
+          inherit (lib) mkEnableOption mkIf;
+        in {
+          options.services.serve-pp.enable = mkEnableOption "serve-pp";
+          config =
+            mkIf
+            config.services.serve-pp.enable
+            {
+              systemd.services.serve-pp = {
+                wantedBy = ["multi-user.target"];
+                script = "${pkgs.python3Packages.pp}/bin/run";
+                serviceConfig = {
+                  Type = "oneshot";
+                };
+              };
+            };
+        };
+      };
     };
   in
     platform-independent-outputs
$ cd /my/repo/ ; cat flake.nix ; nix build
{
  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }: let
    inherit (builtins) match head;
    inherit (nixpkgs.lib) trivial;
    proj-toml = trivial.importTOML ./pyproject.toml;
    translate-py-names = wanted-list: let
      pypi-name-to-nix-name = {
        # "pypi-name" = "nix-name";
        SQLAlchemy = "sqlalchemy";
        types-PyYAML = "types-pyyaml";
      };
      to-nix-name = pypi-name: let
        pkg-name = head (match "([^ =<>;~]*).*" pypi-name);
      in
        pypi-name-to-nix-name.${pkg-name} or pkg-name;
    in
      map to-nix-name wanted-list;
    proj-deps = translate-py-names proj-toml.project.dependencies;

    build-pp = {python3Packages}:
      python3Packages.buildPythonPackage
      {
        pname = proj-toml.project.name;
        inherit (proj-toml.project) version;
        src = ./.;
        pyproject = true;
        build-system = [python3Packages.setuptools];
        dependencies = attrValues (getAttrs proj-deps python3Packages);
      };

    inherit (nixpkgs.lib) attrValues getAttrs;
    inherit (flake-utils.lib) eachDefaultSystem filterPackages;
    dev-shell-and-packages-for = system: let
      pkgs = nixpkgs.legacyPackages.${system}.extend self.overlays.default;
      wanted-py-pkgs = wanted-list: available-pkgs:
        attrValues (getAttrs wanted-list available-pkgs);
    in {
      formatter = pkgs.alejandra;
      devShells =
        filterPackages
        system
        {
          default =
            pkgs.mkShell
            {
              packages = with pkgs; [
                ruff
                (python3.withPackages
                  (wanted-py-pkgs
                    (
                      [
                        "mypy"
                      ]
                      ++ proj-deps
                    )))
              ];
            };
        };
      packages.default = pkgs.python3Packages.pp;
      legacyPackages = pkgs;
      apps.default = {
        type = "app";
        program = "${self.packages.${system}.default}/bin/run";
      };
    };

    inherit (nixpkgs.lib) composeManyExtensions;
    platform-independent-outputs = {
      overlays = {
        new-py-pkgs = final: prev: {
          pp = final.callPackage build-pp {};
        };
        extend-py-extensions = final: prev: {
          pythonPackagesExtensions =
            prev.pythonPackagesExtensions
            ++ [self.overlays.new-py-pkgs];
        };
        default =
          composeManyExtensions
          [
            # Put here overlays from the inputs if appropriate.
            self.overlays.extend-py-extensions
          ];
      };

      nixosModules = {
        serve-pp = {lib, config, pkgs, ...}: let
          inherit (lib) mkEnableOption mkIf;
        in {
          options.services.serve-pp.enable = mkEnableOption "serve-pp";
          config =
            mkIf
            config.services.serve-pp.enable
            {
              systemd.services.serve-pp = {
                wantedBy = ["multi-user.target"];
                script = "${pkgs.python3Packages.pp}/bin/run";
                serviceConfig = {
                  Type = "oneshot";
                };
              };
            };
        };
      };
    };
  in
    platform-independent-outputs
    // eachDefaultSystem dev-shell-and-packages-for;
}
$

There is new platform-independent output in /my/repo Nix flake named nixosModules by convention. nixosModules is an attribute set with single attribute named serve-pp; serve-pp is a module.

The output attribute set of the serve-pp module contains options and config, partial configuration provided by the serve-pp module.

First

+      options.services.serve-pp.enable = mkEnableOption "serve-pp";

says there is an option services.serve-pp.enable that can be configured either to be true or false. mkEnableOption is a shortcut to mkOption.

Then

+      config =
+        mkIf
+        config.services.serve-pp.enable
+        {

says that if the final configuration (config) contains services.serve-pp.enable with value true, it also contains:

+          systemd.services.serve-pp = {
+            wantedBy = ["multi-user.target"];
+            script = "${pkgs.python3Packages.pp}/bin/run";
+            serviceConfig = {
+              Type = "oneshot";
+            };
+          };
+        };

(Nix language is declarative, so we specify relations and say it also contains rather than then add to it.)

Recall that pp is a Python3 package and it’s run script is specified in its pyproject.toml file. This is the reason why script = ... looks like it looks – run is an executable of pp Python3 package.

That was how systemd configuration is made available for other Nix flakes. The update to the /my/dep-os Nix flake shows how it can be used:

@@ -1,8 +1,12 @@
 $ cd /my/dep-os/ ; cat flake.nix ; nix build .#nixosConfigurations.S.config.system.build.vm
 {
+  inputs = {
+    my-repo.url = "path:///my/repo/";
+  };
   outputs = {
     self,
     nixpkgs,
+    my-repo,
   }: let
     inherit (nixpkgs.lib) nixosSystem;
     basic-system = _: {
@@ -11,6 +15,9 @@
       services.sshd.enable = true;
       users.users.iam.initialPassword = "itsme";
       users.users.iam.isNormalUser = true;
+      users.users.iam.group = "wheel";  # for sudo
+      services.serve-pp.enable = true;
+      nixpkgs.overlays = [my-repo.overlays.default];
     };
   in {
     nixosConfigurations.S =
@@ -19,6 +26,7 @@
         system = "x86_64-linux";
         modules = [
           basic-system
+          my-repo.nixosModules.serve-pp
         ];
       };
   };
$ cd /my/dep-os/ ; cat flake.nix ; nix build .#nixosConfigurations.S.config.system.build.vm
{
  inputs = {
    my-repo.url = "path:///my/repo/";
  };
  outputs = {
    self,
    nixpkgs,
    my-repo,
  }: let
    inherit (nixpkgs.lib) nixosSystem;
    basic-system = _: {
      boot.loader.grub.device = "/dev/sda";
      fileSystems."/".device = "/dev/sda1";
      services.sshd.enable = true;
      users.users.iam.initialPassword = "itsme";
      users.users.iam.isNormalUser = true;
      users.users.iam.group = "wheel";  # for sudo
      services.serve-pp.enable = true;
      nixpkgs.overlays = [my-repo.overlays.default];
    };
  in {
    nixosConfigurations.S =
      nixosSystem
      {
        system = "x86_64-linux";
        modules = [
          basic-system
          my-repo.nixosModules.serve-pp
        ];
      };
  };
}
evaluation warning: system.stateVersion is not set, defaulting to 25.05. Read why this matters on https://nixos.org/manual/nixos/stable/options.html#opt-system.stateVersion.
$ QEMU_NET_OPTS='hostfwd=tcp::2222-:22' ./result/bin/run-nixos-vm

There, my-repo is new input representing /my/repo Nix flake. Moreover

+      services.serve-pp.enable = true;

says that serve-pp service should be enabled,

+      nixpkgs.overlays = [my-repo.overlays.default];

says that nixpkgs should be overlayed by the overlay from the my-repo (where pp Python3 package is) and

+          my-repo.nixosModules.serve-pp

is there to make new system aware of serve-pp module that is responsible for making the services.serve-pp.enable option and services.serve-pp configuration available.

Now it is possible to connect to the system running in QEMU and start the monitoring of the serve-pp service:

$ ssh -p 2222 iam@localhost
(iam@localhost) Password:
Last login: Thu Mar 20 12:47:56 2025 from 10.0.2.2

[iam@nixos:~]$ journalctl -u serve-pp.service -f
Mar 20 13:53:13 nixos systemd[1]: Starting serve-pp.service...
Mar 20 13:53:17 nixos serve-pp-start[682]: foo
Mar 20 13:53:17 nixos systemd[1]: serve-pp.service: Deactivated successfully.
Mar 20 13:53:17 nixos systemd[1]: Finished serve-pp.service.

Then, connect to the system in other terminal and run the serve-pp service:

$ ssh -p 2222 iam@localhost
(iam@localhost) Password:
Last login: Thu Mar 20 13:54:34 2025 from 10.0.2.2

[iam@nixos:~]$ sudo systemctl start serve-pp.service
[sudo] password for iam:

[iam@nixos:~]$

which results in the additional output in the log:

Mar 20 13:57:48 nixos systemd[1]: Starting serve-pp.service...
Mar 20 13:57:48 nixos serve-pp-start[929]: foo
Mar 20 13:57:48 nixos systemd[1]: serve-pp.service: Deactivated successfully.
Mar 20 13:57:48 nixos systemd[1]: Finished serve-pp.service.

We like to keep our source code reasonably divided

@@ -16,6 +16,8 @@
       users.users.iam.initialPassword = "itsme";
       users.users.iam.isNormalUser = true;
       users.users.iam.group = "wheel";  # for sudo
+    };
+    enable-serve-pp = _: {
       services.serve-pp.enable = true;
       nixpkgs.overlays = [my-repo.overlays.default];
     };
@@ -27,6 +29,7 @@
         modules = [
           basic-system
           my-repo.nixosModules.serve-pp
+          enable-serve-pp
         ];
       };
   };
$ cd /my/dep-os/ ; cat flake.nix ; nix build .#nixosConfigurations.S.config.system.build.vm
{
  inputs = {
    my-repo.url = "path:///my/repo/";
  };
  outputs = {
    self,
    nixpkgs,
    my-repo,
  }: let
    inherit (nixpkgs.lib) nixosSystem;
    basic-system = _: {
      boot.loader.grub.device = "/dev/sda";
      fileSystems."/".device = "/dev/sda1";
      services.sshd.enable = true;
      users.users.iam.initialPassword = "itsme";
      users.users.iam.isNormalUser = true;
      users.users.iam.group = "wheel";  # for sudo
    };
    enable-serve-pp = _: {
      services.serve-pp.enable = true;
      nixpkgs.overlays = [my-repo.overlays.default];
    };
  in {
    nixosConfigurations.S =
      nixosSystem
      {
        system = "x86_64-linux";
        modules = [
          basic-system
          my-repo.nixosModules.serve-pp
          enable-serve-pp
        ];
      };
  };
}
evaluation warning: system.stateVersion is not set, defaulting to 25.05. Read why this matters on https://nixos.org/manual/nixos/stable/options.html#opt-system.stateVersion.
$ QEMU_NET_OPTS='hostfwd=tcp::2222-:22' ./result/bin/run-nixos-vm

and we promised to run the serve-pp service periodically

@@ -19,6 +19,7 @@
     };
     enable-serve-pp = _: {
       services.serve-pp.enable = true;
+      systemd.services.serve-pp.startAt = "*:*:0/5";
       nixpkgs.overlays = [my-repo.overlays.default];
     };
   in {
$ cd /my/dep-os/ ; cat flake.nix ; nix build .#nixosConfigurations.S.config.system.build.vm
{
  inputs = {
    my-repo.url = "path:///my/repo/";
  };
  outputs = {
    self,
    nixpkgs,
    my-repo,
  }: let
    inherit (nixpkgs.lib) nixosSystem;
    basic-system = _: {
      boot.loader.grub.device = "/dev/sda";
      fileSystems."/".device = "/dev/sda1";
      services.sshd.enable = true;
      users.users.iam.initialPassword = "itsme";
      users.users.iam.isNormalUser = true;
      users.users.iam.group = "wheel";  # for sudo
    };
    enable-serve-pp = _: {
      services.serve-pp.enable = true;
      systemd.services.serve-pp.startAt = "*:*:0/5";
      nixpkgs.overlays = [my-repo.overlays.default];
    };
  in {
    nixosConfigurations.S =
      nixosSystem
      {
        system = "x86_64-linux";
        modules = [
          basic-system
          my-repo.nixosModules.serve-pp
          enable-serve-pp
        ];
      };
  };
}
evaluation warning: system.stateVersion is not set, defaulting to 25.05. Read why this matters on https://nixos.org/manual/nixos/stable/options.html#opt-system.stateVersion.
$ QEMU_NET_OPTS='hostfwd=tcp::2222-:22' ./result/bin/run-nixos-vm

The systemd.services.serve-pp.startAt extends the configuration. It is not a part of the original module.

Beware of services and systemd services, becase systemd is a service in NixOS that manage other systemd services but not NixOS services.

Also, it looks like the combination of systemd, NixOS and QEMU is not much time-precise.

$ ssh -p 2222 iam@localhost
(iam@localhost) Password:
Last login: Thu Mar 20 14:53:32 2025 from 10.0.2.2

[iam@nixos:~]$ journalctl -u serve-pp -f
Mar 20 14:56:53 nixos systemd[1]: Starting serve-pp.service...
Mar 20 14:56:55 nixos serve-pp-start[683]: foo
Mar 20 14:56:55 nixos systemd[1]: serve-pp.service: Deactivated successfully.
Mar 20 14:56:55 nixos systemd[1]: Finished serve-pp.service.
Mar 20 14:57:01 nixos systemd[1]: Starting serve-pp.service...
Mar 20 14:57:01 nixos serve-pp-start[896]: foo
Mar 20 14:57:01 nixos systemd[1]: serve-pp.service: Deactivated successfully.
Mar 20 14:57:01 nixos systemd[1]: Finished serve-pp.service.
Mar 20 14:57:31 nixos systemd[1]: Starting serve-pp.service...
Mar 20 14:57:31 nixos serve-pp-start[926]: foo
Mar 20 14:57:31 nixos systemd[1]: serve-pp.service: Deactivated successfully.
Mar 20 14:57:31 nixos systemd[1]: Finished serve-pp.service.
Mar 20 14:57:36 nixos systemd[1]: Starting serve-pp.service...
Mar 20 14:57:36 nixos serve-pp-start[929]: foo
Mar 20 14:57:36 nixos systemd[1]: serve-pp.service: Deactivated successfully.
Mar 20 14:57:36 nixos systemd[1]: Finished serve-pp.service.
Mar 20 14:58:23 nixos systemd[1]: Starting serve-pp.service...
Mar 20 14:58:23 nixos serve-pp-start[932]: foo
Mar 20 14:58:23 nixos systemd[1]: serve-pp.service: Deactivated successfully.
Mar 20 14:58:23 nixos systemd[1]: Finished serve-pp.service.
Mar 20 14:58:31 nixos systemd[1]: Starting serve-pp.service...
Mar 20 14:58:31 nixos serve-pp-start[935]: foo
Mar 20 14:58:31 nixos systemd[1]: serve-pp.service: Deactivated successfully.
Mar 20 14:58:31 nixos systemd[1]: Finished serve-pp.service.

This is (the idea behind) our Nix flake for services deployment.

We want to build NixOS system with

nix build .#nixosConfigurations.S.config.system.build.vm

where S is a name of new system. Then we want to run it in QEMU like

QEMU_NET_OPTS='hostfwd=tcp::2222-:22' ./result/bin/run-nixos-vm

When we build .toplevel instead

nix build .#nixosConfigurations.S.config.system.build.toplevel

we deploy to running NixOS with

nix copy

and this is how we achieve that:

Conclusion

We use Nix with flakes for many things. I don’t say it’s good or bad – it’s a state of things in a company I work.

Nix flake is a directory with flake.nix file. In such a directory, we run

nix run

to execute an application, which is possible due to

apps.x86_64-linux.default = ...

in flake.nix file of Nix flake (directory.)

x86_64-linux specifies platform and when reading flake.nix, there is usually ${system} instead, because we write flake.nix in such way it supports multiple platforms.

Nix flakes help us with keeping development environment consistent; we can

nix develop

while keeping

devShells.x86_64-linux.default = ...

in flake.nix and be free of “I don’t have this error. What version of the library you used?” nevermind the programming language.

But dependencies for development is not all. When the dependencies are assured by Nix flake, why not whole build? We build our application with

nix build

which depends on

packages.x86_64-linux.default = ...

being set in flake.nix.

What does it mean to build an application, anyway? Make it executable, I guess.

Applications have different kinds of dependencies. Some are needed just to build an application, to make the application executable. However, there are dependencies applications need while being executed – runtime dependencies.

We build applications to be executed and it’s good to ask how to execute these applications? What are their runtime dependencies? How to satisfy these dependencies?

Well… Nix knows about all dependencies since the beginning so executing an application built with Nix in NixOS is obvious solution. We keep

nixosConfigurations.our-system = ...

in flake.nix and we can build that system the same way we build applications, with nix build. Although building a system is more about creating files to be later nix copy in order to update/replace another system running NixOS.

Last piece of puzzle is how to share applications and configurations between Nix flakes; we keep

overlays.default = ...
legacyPackages.x86_64-linux = ...

and

nixosModules.our-service = ...

respectively in flake.nix file.

We use import ./path/to/file.nix often but not in examples above. import evaluates the content of the file, which may be a function declaration. Therefore, if

$ cat ./file.nix
first: second: first + second

then

import ./file.nix 3 4 == 7

is true.

It looks like we use Nix with flakes since cradle to the grave.

Considering Nix flakes being experimental, that’s scary.

And Nix laungage itself does not help, neither so many conventions and confusions one needs to keep in mind.

A little when nix develop is by:

$ cat ~/.config/nix/nix.conf
bash-prompt-prefix = ❄\040
go back | CC BY-NC-SA 4.0 Jiri Vlasak