Once, I used Flakes. Then, I realized how much they actually complicate things. Which is to say, this week I decided to rip out all the Flakes stuff from my dotfiles. This is a big event, I suppose, because Flakes were the first thing I had an opinion about when I started using NixOS in 2022, and I figured they were the way of the future and that they’d not be experimental eventually and that I might as well learn that now while I’m getting started. It is now 2025, and I am unemployed and trying to find some way into tech pretty unsuccessfully so far, having blown off grad school; as you can imagine, I have lots of free time.
Flakes are good for finding a place to get your footing when starting to learn the Nix language—there are in fact, too many places to start from in Nix—and they acclimate you to a purely declarative way of building your machine very quickly, with a prescribed notion of how to organize it. There are a lot of good and interesting features involved in the overarching thing that is Flakes.
They’re also bad for learning the Nix language, because they add a layer on top of an already complex language to learn.1 The reasons that we have Flakes make lots of sense, but the implementation of it, as it stands now, is kinda already a form of technical debt.
nixosModules
, nixosConfigurations
,
packages
, etc.), whereas it feels like every module
structures everything differently in lieu of a schema,nix flake update --commit-lock-file
),flake.nix
, then you could just
expect it somewhere like project.nixosModules.default
,
which sounds a lot better.We can get a lot of the good parts of Flakes without the bad parts.
For dependency pinning, there’s tools like niv
, npins
, lon
(and probably
many others) that do dependency pinning just fine. I use
npins
, mostly because it seems like the most widely used
alternative to niv
, and also because of this post by one
of Lix’s maintainers that uses it as well.
Regarding schema, I’m not sure why we don’t think of
default.nix
more creatively (or really, more boringly); why
not just imitate Flake schema, but without all the pain points?
Nothing stops you from structuring default.nix
like
Flake outputs
, putting your host’s files at
./hosts/ilo
, and running
nixos-rebuild -f . -A nixosConfigurations.ilo
.
To a past version of myself, I am certain this sounds really opaque; but now that I feel confident in my understanding of the language, it’s kinda hilarious to me just how much complication Flakes introduced to the language for me, with its relative ease of use, compared to, well, trying to grasp the metaphysics of functional programming when I really have never been much of a programmer in the first place.
Our solution of having default.nix
just imitate the
Flakes schema is nice for our own uses, and for anyone who might want to
use something from our Nix project. That said, the real benefit of the
schema imposed by Flakes is that other people use it too, so it
helps those unfamiliar to get a feeling for how a Nix project is laid
out, if they have the work of others to refer to. So it’s not really a
complete replacement there. But if you can live with the inconsistency
of how modules get found in your system configurations’s
imports
list5 it’s pretty nice.
It all comes together something like this:
$ npins init
[INFO ] Welcome to npins!
[INFO ] Creating `npins` directory
[INFO ] Writing default.nix
[INFO ] Writing initial lock file with nixpkgs entry (need to fetch latest commit first)
[INFO ] Successfully written initial files to 'npins/sources.json'.
$ mkdir -p hosts/ilo/ users/somasis/
$ npins add github --name nixos-unstable --branch nixos-unstable nixos nixpkgs
[INFO ] Adding 'nixos-unstable' …
repository: https://github.com/nixos/nixpkgs.git
branch: nixos-unstable
submodules: false
revision: 544961dfcce86422ba200ed9a0b00dd4b1486ec5
url: https://github.com/nixos/nixpkgs/archive/544961dfcce86422ba200ed9a0b00dd4b1486ec5.tar.gz
hash: 0k4w73fddkvbcaxshm5mbr6b6k11hm7nz94jxsfmj14bswx2ll0i
frozen: false
$ npins add github --name home-manager --branch master nix-community home-manager
[INFO ] Adding 'home-manager' …
repository: https://github.com/nix-community/home-manager.git
branch: master
submodules: false
revision: 722792af097dff5790f1a66d271a47759f477755
url: https://github.com/nix-community/home-manager/archive/722792af097dff5790f1a66d271a47759f477755.tar.gz
hash: 0h33b93cr2riwd987ii5xl28mac590fm2041c5pcz0kdad3yll4s
frozen: false
$ npins add github --branch master nix-community impermanence
[INFO ] Adding 'impermanence' …
repository: https://github.com/nix-community/impermanence.git
branch: master
submodules: false
revision: 4b3e914cdf97a5b536a889e939fb2fd2b043a170
url: https://github.com/nix-community/impermanence/archive/4b3e914cdf97a5b536a889e939fb2fd2b043a170.tar.gz
hash: 04l16szln2x0ajq2x799krb53ykvc6vm44x86ppy1jg9fr82161c
frozen: false
$ vi default.nix ./hosts/ilo/default.nix ./users/somasis/default.nix
Now, we’ll set up the main entry-point to the project and its “stuff”:
# default.nix
{
self ? (import ./. { }),
sources ? (import ./npins),
nixpkgs ? sources.nixos,
...
}:
let
nixos =
nixpkgs: configuration:
import "${nixpkgs}/nixos/lib/eval-config.nix" {
modules = [ configuration ];
specialArgs = {
inherit nixpkgs self sources;
};
};
in
{
inherit self sources;
# Setting outPath means that you can do things like
# "${self}/modules/my-cool-module/thing.nix"
outPath = ./.;
nixosConfigurations.ilo = nixos nixpkgs ./hosts/ilo;
nixosModules.my-cool-module = import ./modules/nixos/my-cool-module;
}
# ./hosts/ilo/default.nix
{
sources,
self,
config,
pkgs,
...
}:
{
imports = with sources; [
.nixosModules.my-cool-module
self"${home-manager}/nixos"
"${impermanence}/nixos.nix"
];
environment.systemPackages = [
.npins
pkgs];
users.users.somasis = { };
home-manager.users.somasis = import "${self}/users/somasis";
system.stateVersion = "25.05";
}
# ./users/somasis/default.nix
{
sources,
config,
pkgs,
...
}:
{
imports = with sources; [
"${home-manager}/home-manager.nix"
"${impermanence}/home-manager.nix"
];
home.stateVersion = "25.05";
}
I imagine some reading may have objections to passing
sources
and self
to things via
specialArgs
, but I think this is an exception that makes
sense. We’re integrating npins
into the whole structure of
our project6, in a way that is basically
equivalent to how people use self
in Flakes land to the top
level of their Flake and inputs
to get its input
soruces.
As to why not use import "${nixpkgs}/nixos"
, following
the channels-utilizing convention of
import <nixpkgs/nixos>
? Well, that was what I thought
would look nicer as well, but that
interface doesn’t allow for setting specialArgs
, for some
reason; the only arguments it accepts are configuration
and modules
. I might submit a pull request, but I can
imagine there’s also just some reason for this choice that I don’t
know.
If you want to use this workflow for your system, you might also want
to check out this
little module I wrote that ensures usage of npins
sources in NIX_PATH
7 and in the system’s
Flakes registry, disabling channels as well. If you have something like
that in your configuration, everything else works great:
nix run nixpkgs#firefox
works fine,
nix-shell '<nixpkgs>' -p firefox --run firefox
too.
Like with Flakes, you can explore what’s going on in your project using
nix repl -f .
.
It’s very nice and it feels much simpler in terms of project structure.
Feel free to dig around in my configuration for more.
Plus, if you’ve oriented your understanding of Nix around flakes, some parts of the language will look kinda weird comparatively, and you’ll probably avoid learning how to properly “hold” the tools that Nix gives you, usually trying to do everything through the mechanisms that Flakes provide, when they could be done much more simply.↩︎
And when I say "channels", to be precise, I’m referring
to the model of dependency management implemented with
nix-channel
, where a channel (like nixpkgs
, or
home-manager
) is configured by the system environment,
and not the Nix stuff being built, to point to the contents of a
URL like
https://nixos.org/channels/nixos-25.05/nixexprs.tar.xz
or
https://github.com/nix-community/home-manager/archive/master.tar.gz
,
then being propagated to tools like nixos-rebuild
and
nix-build
and so on at the command line via the environment
variable $NIX_PATH
, which is then looked up when the code
says import <nixpkgs> {}
… See
here for a better explanation.↩︎
default.nix
could be an attribute set, or
one single stdenv.mkDerivation
. Or maybe the project
doesn’t even provide a default.nix
and you need to
import "${project}/nixos-module.nix"
instead…↩︎
The Flake schema is not, as it might appear, a normal
attribute set, and also you have to deal with nixpkgs’ systems
(x86_64-linux
etc.) the moment you step out from the
nixosConfigurations.
comfort zone, because you want to put
a package you made under packages
and follow the schema,
but actually it’s packages.x86_64-linux.hello
, because
Flakes don’t assume your system like that due to pure evaluation, and
you don’t have a list of systems built in to Nix, it’s in nixpkgs, so
you need to get it from nixpkgs.lib
in some
let ... in
statement boilerplate nonsense first if you want
to avoid repeating yourself a lot. But (to imagine myself a few years
ago) as a new Nix user I don’t really understand functional programming
yet, I think, because I’ve just never really tried functional
programming before, but there’s all these projects on GitHub I keep
hearing about like flake-utils-plus
and
flake-parts
and all these people coming up with these
designs and methods for Designing your Flake to be the most ergonomic it
can be, and there’s these functions people keep writing to make
packages
not require repetition, and, and, plus, I was
reading this blog post about one million nixpkgs taking up space on my
hard drive and I guess just need to use one nixpkgs
input
and it’s gonna be a pain to juggle all these nixpkgs versions if I want
something like pkgs.unstable.firefox
, and there’s the whole
inputs.*.follows.*
thing… It is a real disaster.↩︎
To show a few examples of all the different ways you
might import a module: there’s
self.nixosModules.my-cool-module
,
"${agenix}/modules/age.nix"
,
"${agenix}/modules/age-home.nix"
,
"${nixpkgs}/nixos/modules/profiles/hardened.nix"
(nixpkgs’
profiles are also accessed like this in Flakes, to be fair),
"${home-manager}/nixos"
,
"${impermanence}/nixos.nix"
,
"${impermanence}/home-manager.nix"
,
(imports sources.nixos-cli { inherit pkgs; }).module
(honestly this one might be my fault),
self.homeManagerModules.my-cool-module
, and
"${plasma-manager}/modules"
. That’s just what’s in my own
config. I dunno, I guess it just bugs me to see so much variation in how
you just find the module you’re trying to import, compared to Flakes
pretty much always using some variation on
project.nixosModules.my-module
; that said, projects still
don’t really agree on where to put home-manager modules in the Flake
schema, for example.↩︎
or directory, or repository, whatever you want to call it, whatever it is↩︎
Using a layer of indirection in
/etc/npins/<source>
which points to
sources.<source>
.↩︎
2025-10-17