vereis ♪⁠~⁠(⁠´⁠ε⁠`⁠ ⁠) rss posts projs </>

The NixOS kool-aid

Published 2024-08-14 @ 20:09:30
Approx. 26 minutes

Context

I admittedly have a history of being a bit of a distro-hopper; which isn't unusual per se, but I've always been a bit "extreme" about it.

Generally, I'll switch between some flavour of windows and alpine, ubuntu, or arch every few months.

That being said, since discovering nixos   in June 2020, I've been hard stuck on it (though I do also use nix and nixos on macos and wsl respectively).

While this post probably won't convince anyone to jump ship from their current OS, I hope it does give you a bit of insight into why I've stuck with nixos for so long.

Initial Difficulties

One of the first things people might run into when they look into nix is that nix refers to one of the following things (at least):

  1. nix, the package manager.
  2. nix-lang, the programming language.
  3. nixos, the operating system.
  4. nixpkgs, the package repository.
  5. ... probably a couple of other things too...

I'm definitely not a nix-lang wizard, so this post will stop short of actually talking much about the particulars of the language. I hear this guide   is a great place to start if you're interested in this however.

In my experience, you definitely don't need to master nix-lang, using it in anger seemed more than enough in my case.

I will say the language feels a bit like json and haskell had a baby; maybe with a bit of prolog thrown in for good measure. That's definitely doing it a disservice, but the analogy probably holds.

I'll try to talk about the parts of nix I actually use in a pragmatic sense. To do that, we'll need to ELI5 the package manager and operating system, before delving into my configuration :)

ELI5: Nix the Package Manager

Nix is a functional package manager that happens to work on Linux, Mac, and Windows (via WSL)! I think you can also use it on at least OpenBSD but I'm not familiar with that.

You can use nix to install software just like you would via homebrew  , winget  , or any other package manager.

A "package manager" in the UNIX sense typically refers to a tool that manages OS applications, however, in this context, it refers to a tool that manages applications, or project/code dependencies.

For example, note the difference between the following three package managers:

  • brew is used for installing OS applications (tailscale, firefox, etc).
  • asdf is used for installing project dependencies (database, programming language, etc).
  • npm is used for installing code dependencies (libraries, gems, etc.)
There are fuzzy boundaries here, but they feel distinct to me. Nix can do all of these.

Basic Usage

For the most basic usage, you can rely on the following commands:

  1. nix search nipkgs $package, to find packages.
  2. nix-env --install $package, to install packages.
  3. nix-env --uninstall $package, to uninstall packages.

But wait, hol' up... Doing this is actually a bad idea.

Using nix-env to install packages is actually considered a bit of a "code smell" in the nix community. It side-steps a lot of the benefits of using nix in the first place.

Functional Package Management

Unlike most package managers, nix describes itself as being "functional."

In short, traditional package managers are imperative in nature. You install a package, and it mutates the global state of your system.

Contrastingly, nix is functional in nature in that it treats package management similarly to pure functions in functional programming. There is "no global state" to mutate.

This works by storing all packages in a special /nix/store/ directory, and having nix manage the PATH environment variable to point to the correct binaries in the /nix/store/ directory.

This gives us a few superpowers:

  1. Reproducibility: If you install a package, you can be sure that the package will always be the same version, with the same dependencies, and the same configuration.
  2. Isolation: Packages are installed in a way that they don't interfere with each other. You can have multiple versions of the same package installed at once.
  3. Rollbacks: If you install a package that breaks your system, you can roll back to a previous generation of your system configuration.

With some community tooling, you can even automatically change your PATH to point to different versions of different packages on a project-by-project basis! This allows you to completely replace asdf, rvm, pyenv, etc, with the added benefit of leveraging the largest software repository in the world.

Declarative Packagamenet Management

So, functional package management gives you some advantages over plain old imperative package management, but we can do one better!

nix also allows you to manage your packages in a declarative way.

This means you can define your system configuration in a file, and nix will ensure that your system is in the state you've defined.

Fundamentally, nixos is built on this idea. You define your system configuration in a file, and nixos will ensure that your system is in the state you've defined -- more on that soon!

You get the ability to manage your packages in a declarative way by using nixos, but can leverage this ability on other systems too via:

  • home-manager   for user-specific package management on nixos-- also usable on nix, especially useful for macos users.
  • nix-darwin   for managing macos configurations and declaratively managing brew packages and casks.
  • flakes   for managing nix configurations in a more modern, efficient, and user-friendly way, though still experimental at the time of writing.

A minimal example of a nixos style configuration.nix might look like this:

{ config, lib, pkgs, ... }:

{
  imports = [];
  environment.systemPackages = with pkgs; [ firefox steam zsh tmux ];
}

Every time you apply one of these configurations, nix will ensure that your system is in the state you've defined, and will make any changes necessary to get there. It stores the current state of your system in a "generation" so you can always roll back if something goes wrong.

Since everything is pure and declarative, you get the following superpowers basically for free:

  1. Reproducibility: You can share your configuration with others, and they can get the exact same system as you.
  2. Safety: You can make changes to your system configuration without fear of breaking anything. If something goes wrong, you can always roll back.
  3. Flexibility: You can define your system configuration in a way that makes sense to you, and reuse bits and pieces across different configurations.

ELI5: Nix the Operating System

So, as mentioned earlier, nixos is an operating system built on top of nix the package manager.

The simplest way to think about nixos is that it's what happens when you apply the principles of nix to the entire operating system, rather than solely packages: you define your system configuration in a file, and nixos will ensure that your system is in the state you've defined functionally & declaratively.

Where nix will manage your packages, nixos will manage your entire system configuration, including packages, services, users, and more, all via the same configuration mechanism described above.

The thing about nixos is that it's pretty unlike any other operating system, Linux based or otherwise, that you've probably used before.

Linux has the concept of the "filesystem hierarchy standard" (FHS) which defines where things should go on the filesystem. nixos doesn't really care about this standard, and instead manages literally everything via the /nix/store/ directory, symlinked into the filesystem as needed.

That means no /bin, no /lib, etc. The main downside is that you can't just install a package from the internet (i.e. curl | sh) and expect it to work -- stuff generally has to be managed via nix to function properly.

The upside is that you get all the superpowers of nix applied to your entire system, and you can manage your system configuration in a declarative way, just like you would with packages, including the ability to roll back to a previous generation if something goes wrong.

You edit your system configuration, which is typically defined in /etc/nixos/configuration.nix, and run nixos-rebuild switch to tell nix to build all the packages and configuration it needs. Your PATH, current nix-profile generation, and everything else is then updated accordingly.

This works so well that I personally manage all mine and my family's machines with nixos, and I've never been happier with my setup. If I need to fix or update something for them I just push a commit to my nix-config   repo and ask them to run nixos-rebuild switch on their machine.

Modules

Since nix-lang is used to configure your system, and because nix-lang is a fully-fledged, abet somewhat strange programming language, you can refactor your configuration into smaller modules.

If you really wanted to, you could even unit test and lint your configuration, though I've never felt the need to do so.

The following module is something I wrote that is part of my configuration.nix:

{ pkgs, lib, config, ... }:

with lib;
{
  options.modules.proxy = {
    enable = mkOption { type = types.bool; default = false; };
    openFirewall = mkOption { type = types.bool; default = false; };
    firewallPorts = mkOption { type = types.listOf types.port; default = [ 80 443 ]; };
    proxies = mkOption { type = types.attrsOf types.port; default = { }; };
  };

  config = mkIf config.modules.proxy.enable {
    networking.firewall.allowedTCPPorts = mkIf config.modules.proxy.openFirewall config.modules.proxy.firewallPorts;

    security.acme.acceptTerms = true;
    security.acme.defaults.email = "contact@vereis.com"

    services.nginx = {
      enable = true;
      virtualHosts =
        builtins.mapAttrs (host: port: {
          enableACME = true;
          forceSSL = true;
          locations."/".proxyPass = "http://127.0.0.1:${toString port}/";
        }) config.modules.proxy.proxies;
    };
  };
}

This module can be imported and enabled like so:

imports = [
  modules/services/tailscale.nix
  modules/services/proxy.nix
]

modules.tailscale.enable = true;
modules.proxy = {
  enable = true;
  openFirewall = true;
  proxies = {
    "sonarr.vereis.com" = 8989;
    "radarr.vereis.com" = 7878;
    "prowlarr.vereis.com" = 9696;
    "transmission.vereis.com" = 9091;
    "lidarr.vereis.com" = 8686;
    "readarr.vereis.com" = 8787;
    "printer.vereis.com" = 631;
  };
};

This short module plus configuration does the following things:

  1. Installs nginx.
  2. Opens any specified ports in my firewall.
  3. Automatically creates a nginx config based on the provided proxies map to automatically setup the specified virtual hosts.
  4. Configures a service to automatically start and monitor it via systemd.
  5. Starts the service if it isn't already started, otherwise it restarts it.

One important call out is that the nix-lang is lazily evaluated, and different modules ultimately have their configurations merged together.

This means multiple modules can all independently set the networking.firewall.allowedTCPPorts configuration, and nix will merge them together into a single system config; and the compiler generally validates that your config doesn't do anything strange like set the same single configuration multiple times.

This leads to modules which can utilize other modules in almost magical ways. It means that a module for fzf and a module for oh-my-zsh can both utilize the built in zsh module to enable shell hooks automagically, and suddenly, a direnv module can does the exact same thing, and you never have to manually touch your .zshrc to get it working.

ELI5: Nix Flakes

The weird thing about flakes is that they're marked an experimental feature and disabled by default.

I think this is a bit of a shame, because flakes are a superpower that I think everyone should be using, and they have generally been embraced by the wider nix community.

I didn't really use flakes until relatively recently, but if you're new to nix I think you're definitely better off doing so.

They "fix" one of the only imperative parts of nix that I've glossed over called channels.

Replacing channels

If you want to install firefox, say, nix will look up the package definition for firefox in nixpkgs  .

As it happens, nixpkgs is the largest software repository in the world. Even larger than arch linux's (including the AUR).

What actually happens under the hood is that nix will find the firefox package definition in nixpkgs, which is defined in your system at installation time unless you've manually changed it since.

This global state is effectively what a nix-channel is. You can add third party channels too, much like third party apt repositories. Pretty much the same concept.

These nix-channels are, by default, used whenever you need to interact with nix-env or nixos-rebuild. This means the exact packages you're installing are implicitly coupled to your system's state at the time of installation.

You can update channels the same way as running sudo apt-update && apt-upgrade by running nix-channel --update, but this will also update every package in your system.

The problem is, and it really is a problem: you probably aren't getting any red flags from this... because that's how practically every other OS works.

But this piece of implicit, global state directly goes against the ethos of nix and nixos in my opinion.

Simply: I lied when I said nix and nixos are "reproducible", they basically are, assuming your nix-channel is the same across rebuilds.

Thankfully, flakes are the solution to this!

You can add a flake.nix to the root of your projects, or use a flake.nix instead of a configuration.nix, which when evaluated, will create a flake.lock locking the exact versions of every package you've installed.

This means that you can upgrade individual packages without mutating the global state of your system, much like how npm package locking works.

More importantly, it means if you copy a flake.nix and flake.lock from one machine to another, you're more or less guaranteed the exact same behaviour on both machines!

Historically you could have achieved something similar with projects such as niv  , but flakes are built in and are touted as the future of nix package management.

Developer shells

While I didn't touch on this earlier, another superpower that nix gives you is the ability to enter a nix-shell.

You can think of a nix-shell as being a transient, short-lived nix-profile instance that you can enter to get access to a specific set of packages.

If you've ever tried developing entirely in a docker container, you'll know how much of a pain it can be to get your editor, shell, and other tools to work correctly. The idea is similar to that, but no contained involved. Just a sub-shell with a different PATH.

You can create nix-shell instances on the fly via nix-shell -p , or you can add a shell.nix to any directory to declaratively specify packages and shell configuration (environment variables and the like) and run nix-shell.

A common workflow in the community involved using a combination of the following:

  1. lorri   which uses a daemon to evaluate shells in the background and provides a caching mechanism.
  2. direnv   which automatically enters a shell whenever you cd into a directory with a shell.nix.

These tools, in tandem, basically replace your standard asdf-vm   based workflow.

Using flakes provides a similar advantage, but without the need for lorri(or direnv if you're willing to get rid of the automatic shell entering).

For example, this blog's flake.nix is pretty small, but installs sqlite, elixir and nodejs, a few dev dependencies, and sets some env variables:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (
      system:
        let
          pkgs = import nixpkgs { inherit system; };

          # `sha256` can be programmatically obtained via running the following:
          # `nix-prefetch-url --unpack https://github.com/elixir-lang/elixir/archive/v${version}.tar.gz`
          elixir_1_15_7 = (pkgs.beam.packagesWith pkgs.erlangR26).elixir_1_15.override {
            version = "1.15.7";
            sha256 = "0yfp16fm8v0796f1rf1m2r0m2nmgj3qr7478483yp1x5rk4xjrz8";
          };
        in
        with pkgs; {
          devShells.default = mkShell {
            buildInputs = [
              # API Deps ------------------------------------
              elixir_1_15_7 sqlite

              # Web Deps ------------------------------------
              nodePackages.prettier nodejs_20
            ]
              ++ lib.optionals stdenv.isLinux  ([ libnotify inotify-tools ])
              ++ lib.optionals stdenv.isDarwin ([ terminal-notifier
                                                  darwin.apple_sdk.frameworks.CoreFoundation
                                                  darwin.apple_sdk.frameworks.CoreServices
                                               ]);

            env = {
              ERL_AFLAGS = "-kernel shell_history enabled";
            };
          };
        }
    );
}

This blog also has a .envrc for direnv that enables automatic shell entering:

#!/usr/bin/env bash

if ! has nix_direnv_version || ! nix_direnv_version 2.4.0; then
  source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.4.0/direnvrc" "sha256-XQzUAvL6pysIJnRJyR7uVpmUSZfc7LSgWQwq/4mBr1U="
fi

use flake

However, you can always run nix develop manually to enter the shell, or use lorri if you prefer.

Running Programs

I don't do this very often outside of demoing nix to people, but you can also run programs via flakes.

What I mean by that is any project on GitHub or otherwise that has a flake.nix can be run via nix run .

Again, nix seems to have its eyes on replacing a bunch of language specific tooling... this time we effectively have npx for all languages!

See the docs for nix run $package   if you want more information!

My Configuration

I store my nix-config   on GitHub, and the only prerequisite to using it is having nix installed, and having git for initial bootstrapping.

I usually do my first install via a nix-shell -p git, but after that, my config will bootstrap itself and from then on I'll have access to all the tools I need to rebuild in future.

Installation & Top Level

If I'm using a MacOS or Linux machine, I can jump straight into the default terminal and install nix.

I strongly recommend using the Determinate Systems Nix Installer  , especially on MacOS, as it comes with a lot of niceties when compared to the vanilla nix installer.

On Windows 10 or 11, you can import the NixOS WSL   image directly and get a real nixos experience on Windows.

One of the nice things about using flakes is that it lets you define various nixosConfigurations as a first class feature of flakes, which wasn't always the case.

Before flakes, sharing your configuration and trying to reuse modules between multiple machines had to be done manually via importing different files to a given installation's base configuration.nix which could admittedly be error prone.

Using nix-darwin   on MacOS, you can define darwinConfigurations which are the same thing as nixosConfigurations, but for MacOS.

As a fun fact, all my machines named after characters in the Madoka Magica   universe.

For example, madoka, sayaka, kyubey, and iroha refer to my main workstation, XPS 15, homelab server, and MBP respectively.

I try to keep my top level flake.nix pretty light. It installs all the third party modules I want to use via inputs, and then it builds all my nixosConfigurations or darwinConfigurations as needed.

{
  description = "Vereis' NixOS configuration";

  inputs =
    {
      nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
      zjstatus.url = "github:dj95/zjstatus";
      nix-homebrew.url = "github:zhaofengli-wip/nix-homebrew";
      nix-darwin = {
        url = "github:LnL7/nix-darwin";
        inputs.nixpkgs.follows = "nixpkgs";
      };
      home-manager = {
        url = "github:nix-community/home-manager";
        inputs.nixpkgs.follows = "nixpkgs";
      };
      nixos-wsl = {
        url = "github:nix-community/NixOS-WSL";
        inputs.nixpkgs.follows = "nixpkgs";
      };
    };

  outputs = inputs @ { self, nixpkgs, nixos-wsl, zjstatus, home-manager, ... }:
    let
      username = "vereis";
    in
    {
      darwinConfigurations = (
        import ./machines/darwin { inherit (nixpkgs) lib; inherit inputs nixpkgs home-manager nix-darwin, nix-homebrew; }
      )
      nixosConfigurations = (
        import ./machines/linux { inherit (nixpkgs) lib; inherit inputs nixpkgs home-manager nixos-wsl username zjstatus; }
      );
    };
}

Because I use a lot of different machines, I delegate actually defining darwinConfigurations and nixosConfigurations to configuration files in machines/darwin/ and machines/linux/.

Note: you can import a directory rather than a single module via nix, which implicitly loads my/directory/default.nix for you.

I'll focus this post on nixosConfigurations, but the darwinConfigurations are pretty similar and I never felt the need to read the darwin-specific docs to get it working.

Machine Definitions

My top-level default.nix contains the boilerplate for defining a specific nixosSystem like so:

Note: I've omitted some of the boilerplate for brevity.

{ lib, inputs, nixpkgs, nix-darwin, home-manager, nixos-wsl, zjstatus, username, ... }:

let
  system = "x86_64-linux";
  pkgs = import nixpkgs { inherit system; config.allowUnfree = true; };
  lib = nixpkgs.lib;
in
{
  # Workstation PC
  madoka = lib.nixosSystem {
    inherit system;
    specialArgs = { inherit inputs username zjstatus; };
    modules = [
      ./madoka
      ./configuration.nix
      nixos-wsl.nixosModules.wsl
      home-manager.nixosModules.home-manager {
        home-manager.useGlobalPkgs = true;
        home-manager.useUserPackages = true;
        home-manager.extraSpecialArgs = { inherit username zjstatus; };
        home-manager.users.${username}.imports = [(import ./home.nix)] ++ [(import ./madoka/home.nix)];
      }
    ];
  };

  # Dell XPS 13
  homura = lib.nixosSystem {
    inherit system;
    specialArgs = { inherit inputs username zjstatus; };
    modules = [
      ./homura
      ./configuration.nix
      home-manager.nixosModules.home-manager {
        home-manager.useGlobalPkgs = true;
        home-manager.useUserPackages = true;
        home-manager.extraSpecialArgs = { inherit username zjstatus; };
        home-manager.users.${username}.imports = [(import ./home.nix)] ++ [(import ./homura/home.nix)];
      }
    ];
  };

  # Server / Homelab
  kyubey = lib.nixosSystem {
    inherit system;
    specialArgs = { inherit inputs username zjstatus; };
    modules = [
      ./kyubey
      ./configuration.nix
      home-manager.nixosModules.home-manager {
        home-manager.useGlobalPkgs = true;
        home-manager.useUserPackages = true;
        home-manager.extraSpecialArgs = { inherit username zjstatus; };
        home-manager.users.${username}.imports = [(import ./home.nix)] ++ [(import ./kyubey/home.nix)];
      }
    ];
  };

  ...
}

I use home-manager   to manage my user-specific packages and configuration. You can ignore any references to other inputs as they'll likely not be needed.

All systems import the top-level machines/linux/configuration.nix which is responsible for configuring packages/services which are shared between all my machines, such as ensuring packages like killall and lsof are installed, flakes are enabled, etc.

Additionally, all systems then import their specific home.nix, services.nix, default.nix and hardware-configuration.nix from machines/linux/$HOSTNAME/ so that machines can be customized as needed.

Home Modules

Because I seldom use programs "raw", I generally have a home.nix module for each machine that imports all the other modules I need.

The benefit of using home-manager to manage packages is that I can easily include my dotfiles and other configuration/scripts in the same module so I get my preferred setup on every machine without needing to use tools like rsync or chezmoi.

Generally speaking, home-manager is able to install anything that you can install anything in nixpkgs, so 75% of my nix configuration can be found in any specific machine's home.nix, or under the reusable modules defined in modules/home/.

The main time you can't rely on home-manager to install something is if its a "service".

My heuristic is if something needs a daemon or service to run, it probably can't be installed via home-manager.

When this is the case, I'll write a module under modules/services/ and import it in my machine's services.nix instead.

You get access to a high-level DSL which wraps and exposes configuration options for any packages you might install when using home-manager.

You can see a list of supported options here  .

And you can generally also set raw configuration if needed as an escape hatch, though I rarely do so.

My neovim.nix module is one of my few exceptions -- it's important to me that if needed, I can reuse my neovim configuration on non- nix managed machines, so I actually split my config between neovim.nix which bootstraps the package and dependencies, and neovim/**.lua which is my actual neovim configuration.

{ config, lib, pkgs, ... }:

with lib;
{
  options.modules.neovim = {
    enable = mkOption { type = types.bool; default = false; };
  };

  config = mkIf config.modules.neovim.enable {
    home.packages = with pkgs; [
      stylua
      sumneko-lua-language-server
      shellcheck
      shfmt
      vale
      deno
      nodePackages.prettier
    ];

    programs.fzf.enable = true;
    programs.fzf.enableZshIntegration = true;

    home.sessionVariables = {
      FZF_DEFAULT_COMMAND = "rg --files | sort -u";
      EDITOR = "nvim";
      VISUAL = "nvim";
    };

    home.file.".vale.ini".source = ./neovim/.vale.ini;
    home.file.".config/nvim/lua/config.lua".source = ./neovim/config.lua;
    home.file.".local/share/nvim/site/pack/packer/start/packer.nvim" = {
      source = builtins.fetchGit {
        url = "https://github.com/wbthomason/packer.nvim";
        ref = "master";
        rev = "afab89594f4f702dc3368769c95b782dbdaeaf0a";
      };
    };

    programs.neovim = {
      enable = true;

      viAlias = true;
      vimAlias = true;
      vimdiffAlias = true;

      withNodeJs = true;
      withPython3 = true;

      extraConfig = ''
      lua require('config')
      '';
    };
  };
}

Note that extraConfig = '' lua require('config') ''; which is responsible for delegating to my raw config.

I did originally git clone a seperate neovim repo whenever I did a nixos-rebuild switch but due to the immutability of nix, I had to manually update cached repo SHAs as they changed...

This is one case where I had to use the escape hatch, but I'm happy with the compromise.

When a machine configuration imports modules/home/vim.nix, and sets modules.vim.enabled = true in their configuration, my vim.nix module will:

  1. Install all required packages.
  2. Setup shell integration for all installed packages, and configures them as needed.
  3. Copies over any dotfiles into the required places.

Upon booting neovim, my config is then responsible for bootstrapping my plugin manager, which sets everything else up for me automatically.

Service Modules

As I've said: not all things can be installed via home-manager.

But I've also already shown an example of a "service" in my system: my proxy.nix module.

{ pkgs, lib, config, ... }:

with lib;
{
  options.modules.proxy = {
    enable = mkOption { type = types.bool; default = false; };
    openFirewall = mkOption { type = types.bool; default = false; };
    firewallPorts = mkOption { type = types.listOf types.port; default = [ 80 443 ]; };
    proxies = mkOption { type = types.attrsOf types.port; default = { }; };
  };

  config = mkIf config.modules.proxy.enable {
    networking.firewall.allowedTCPPorts = mkIf config.modules.proxy.openFirewall config.modules.proxy.firewallPorts;

    security.acme.acceptTerms = true;
    security.acme.defaults.email = "contact@vereis.com"

    services.nginx = {
      enable = true;
      virtualHosts =
        builtins.mapAttrs (host: port: {
          enableACME = true;
          forceSSL = true;
          locations."/".proxyPass = "http://127.0.0.1:${toString port}/";
        }) config.modules.proxy.proxies;
    };
  };
}

This module is responsible for configuring a nginx service, and setting up virtual hosts based on the proxies map.

I don't really use too many other services, the ones I do have generally are only used on my homelab server, though I do use the following on all machines:

  • tailscale.nix which is responsible for configuring Tailscale   on all my machines.
  • printer.nix which is responsible for configuring CUPs   on all my machines.

What's next?

This generally sums up my personal usage of nix and related tools. tl;dr:

  • I use nix to manage my packages, my system, and my user configuration.
  • I use nixos to manage my system configuration.
  • I use home-manager to manage my user configuration.
  • I use flakes to manage what versions of packages are installed, on a project-by-project or system-by-system basis.

This literally gives me superpowers -- I can confidently say I haven't distrohopped in years (outside of running Windows for games and other exclusive software).

Hopefully, this post can helped you get to grips with the admittedly very daunting nix tech-tree a little!

You can definitely get a subset of the benefits from using tools like docker, ansible, asdf, pyenv, etc, but the single biggest superpower for nix is that its all a single tool.

There's even more that I haven't touched on yet:

There's always more to learn, but I hope this post has given you a good starting point to get to grips with nix and its related tools.

(END)