A pattern I've seen happen many times is where people (often new to Nix) overuse Nix in a manner that makes their lives harder, in order to make their systems ostensibly more reproducible and often get the bad side of the tradeoffs inherent in using it. The type of way that this becomes frustrating is, for instance, by inserting Nix evaluations and builds in the iteration loop of changing plaintext files owned by a normal user that don't undergo any transformation by Nix. Generally this is a symptom of a broader problem of having reached for too big of a hammer, but the tooling itself does not suggest scaling down or using it in a less monolithic way, so it feels like the big hammer is the only tool in the toolbox.

Here, by Nix, I am referring to Nix-the-technology combined with the Nix ecosystem that includes the Nix implementations (Lix, CppNix, Snix), nixpkgs, NixOS, nix-darwin, home-manager, system-manager, the deployment tools like Colmena, and more.

This post is about how NixOS and home-manager allow a spectrum of how much to do with Nix, and that there are happy balances of using the minimum necessary amount of Nix that can produce a nicer experience depending on what you're doing.

The strengths of the Nix ecosystem

The various Nix tools are great if:

The weaknesses of the Nix ecosystem

Put away the sledgehammer

There's a few categories of things that often need to be set up on a system:

NixOS or home-manager can set up all of these categories, but the more that you add to the monolithic configuration, the more they will become slow to evaluate and build the configurations. It can be a very suboptimal trade-off to use NixOS for user application configuration as it ties the iteration loop and update schedule of e.g. editor plugins and editor configurations to the slowest parts of building the NixOS system or home-manager configuration.

This is probably also true for application deployments! Using a monolithic configuration management system to do app deployments means, among other things, eating the evaluation time of the application itself in addition to the system configuration and often doing so multiple times (if by accident), even if the rest of the configuration doesn't change often.

NixOS has a reputation for being hard to use not-Nixy tools on; to some extent this is deserved since nix-ld is not set up by default so random binaries from the Internet don't work by default. However, it's absolutely not the case that other configuration management strategies, particularly for dotfiles, are inapplicable.

It's also not the case that NixOS forbids mutable application deployments. nix-store --realise and nix-env --set exist and can be used to separately build and deploy an app from the rest of the system, for instance. You can do mutable application deployments in the non-Nix ways as well; it's just a Linux box.

If the Nix-based DSL of home-manager isn't helpful for some tools, it can be much nicer if user-specific configuration that could be in your home directory is just a pile of symlinks into a git repo in your home directory.

Nominally, home-manager has a feature called mkOutOfStoreSymlink that is used by passing it a Nix language path literal and which will create a symlink to a file outside the Nix store, to allow for simply putting config files in a git repo. This sounds ideal!

Unfortunately, due to funny Nix language semantics, this function fundamentally can't work as intended with flakes, since toString ./. is a string containing a Nix store path to the flake (without any string context to create a dependency on it, even!) rather than an absolute path when flakes are involved. That means that in practice, mkOutOfStoreSymlink will point into the copy of the flake in the Nix store such that the file is only updated when the configuration is rebuilt.

That is to say, the intended escape hatch for home-manager to make a symlink into a git repo requires either:

  1. Not using flakes or
  2. Using a "creative" interpretation of flakes like Lix flake-compat with the copySourceTreeToStore option set to false such that flake.nix is evaluated without copying the source to the store as if it is not a flake and thus making toString work as in non-flakes.

Neither of these is particularly on the happy path for most people, so I suggest using a trivial second dotfiles manager in addition to home-manager (or scripting the creation of the symlinks during home-manager switch rather than trying to do it at evaluation/build time).

The jade-branded dotfiles solution

The simplest solution for dotfiles, assuming that you're fine with omitting the nicety of having the configuration files translated to Nix language and just writing them in their native form, is to symlink a bunch of files into a Git repo.

There's a whole bunch of ways to do this that work totally fine, and implementing this is very easy in any language that has usable ways of interacting with the filesystem.

I don't use the following sample myself, but I use a practically equivalent and much more complicated Python script called polkadots which I don't think is worth it for anyone else to use and which contains bugs. You can equally use a dotfiles manager like rcm(1) or any other one, but a lot of those impose opinions on structure of dotfiles repos that are fine but not necessary, as well as additional bootstrapping dependencies and complexity.

Consider the following 19-line bash script as an example (CC-0, have fun!):

#!/usr/bin/env bash

scriptdir=$(cd "$(dirname -- "$0")" ; pwd -P)

function symlink() {
    if [[ -e "$2" && ! -L "$2" ]] ; then 
        echo "$2 exists and is not a symlink. Ignoring it." >&2
        return 1
    fi

    # "ln -sf source link" follows symlinks by default on ancient BSD (thus on macOS)
    # the fix on ancient BSD is to add -h, which would be incompatible with GNU
    # which doesn't do any of this nonsense and doesn't accept -h either
    #
    # Thus we get to -f ourselves because shell scripting is jank.
    [[ -L "$2" ]] && rm "$2"

    ln -sv "${scriptdir}/$1" "$2"
}

mkdir -p ~/.config
symlink nvim ~/.config/nvim
symlink git ~/.config/git

This will create a symlink at ~/.config/nvim which leads to nvim in the directory the script is in. Thus, your Neovim configuration is now in git and you can simply edit it in either the dotfiles repo or its normal location and then git commit it.

It gets synced between machines basically for free when you run git pull. There's zero evaluation time when making changes to configurations because they're just files and running the script on the rare occasion that a new program is added takes milliseconds.

This script applies no opinions about how your dotfiles repo is structured, and you can modify it to apply any desired opinions or write it in a different language if desired.

My set of opinions is that each tool should have a directory for it and that the place it's actually linked to make it work should be abstracted away because I want to pretend that every tool actually implements the XDG Base Directories spec.

But that's two commands!

It's simple to do the symlinking by running something else at configuration switch time, either with a NixOS/home-manager mechanism or with a wrapper shell script. Either an activation script or a systemd service would do for direct integration; the former is probably the right choice on home-manager due to macOS.

A wrapper around nixos-rebuild works as well. I keep one of these per machine so I just run ./machine-name.sh.

#!/usr/bin/env bash

cmd=${1:-switch}
shift
# Run the dotfiles script to make the symlinks
./dotfiles.sh
nixos-rebuild "$cmd" --sudo --no-build-nix --flake '.#myHost'

But pinning!

For things like editor plugins or any other dependencies, it's rather helpful to have pinning. Unfortunately and rather ironically, getting editor plugins from nixpkgs technically means they're pinned but in practice probably means they are whatever the latest version is and get updated with all the other packages on the system, which is distinctly not pinning if you are just considering the editor setup in isolation.

There are good plugin managers like lazy.nvim which do pinning in the editor itself. For the trouble of setting up the editor configuration to manage its own plugins, you get instant edits to your editor configuration and never have to wait for home-manager/NixOS to rebuild again.

Another pinning approach is git-submodule or just putting the entire history into your git repo with git-subtree or git-subrepo. Vendoring like this means that things are very much pinned and don't even need to each out to the internet on their own, but it's a bit more inconvenient to update.

But what of packages?

These are totally reasonable to put in NixOS or home-manager and there's nothing wrong with using those systems for them, since the list to install rarely changes so the iteration time is rarely a significant bother.

If you want something that's not NixOS or home-manager for these, consider flakey-profile, which allows installing a list of packages from a file into the system and user profiles and contains approximately 10 lines of code total.

But what of deploys?

I don't have a clear answer for deploys of software that changes frequently. It's often nicest if they are managed out-of-band and mutably with respect to the system configuration, but the options for it are often less ergonomic than putting it in the system configuration.

A friend of mine has been working on making a mutable nixos-container setup that works nicely to deploy containers defined in flakes, but from my limited code review, nixos-container could likely use a from-scratch out-of-tree competitor that's less tangled with NixOS, or a nixos-rebuild-ng genre of project to rewrite it in Rust.

It's pretty nice to build Docker/OCI images with nix2container or dockerTools.streamLayeredImage, but then you have to deploy Docker containers for which I have no nice answer. Kubernetes or k3s exists, but it puts the complexity of NixOS to shame.

Conclusion

The Nix ecosystem has a lot of really powerful big hammers that can be used to do very impressive things and make configuration management easy and fun, but the monolithic nature of the tools involved can make them too big of hammers. It's very tempting, especially for newcomers, to Nixify their entire setup, but this is a hazardous temptation that results in more fighting Nix than necessary, reduces flexibility, and increases iteration times, for dubious benefit other than perhaps running fewer commands.

For the cost of copy pasting 20 lines of relatively self-explanatory bash, it's possible to have extremely simple dotfiles that are not tied to Nix at all and which work fine on NixOS.

For a lot of purposes, the simplest and best configuration management is a miserable pile of symlinks rather than the infamous compiler from "Haskell" to Bash known as Nix. Nix is good for a whole lot, but you often just don't need it!