root@vereis.com ~ blog projects

This post has been retroactively added to my "BTW" series, which is a collection of posts about the tools I use and why I use them.

Like most people, I started my computing journey on Windows.

I heard about Linux in middle school, but was honestly completely happy with Windows and never looked into it.

That all changed when a friend introduced me to:

  1. bblean
  2. rainmeter
  3. cygwin

I fell deep into the ricing community, run mainly on deviantart at the time, and I started to learn about Linux.

The term "ricing" refers to the practice of customizing the appearance of your desktop environment.

It originated from the car culture, where "ricing" refers to modifying a car for aesthetics rather than performance.

While cygwin was great, eventually I jumped ship to Linux proper and became a terminal distro hopper.

Circa June 2020, I was between projects at my job and a co-worker was talking about nix and I was intruiged.

After a few false starts, needless to say I was hooked.

Goals

Unlike a lot of posts out there commending nix, I'm by no means an expert.

Theoretically, I barely know enough nix to be dangerous... but I have used nix in anger for a few years now and I think I have a pretty good grasp why I like it.

If you're looking for a deep dive into nix, this post isn't for you, but do check out:

In my experience, you can get away with using nix without knowing much about the language itself, and I think that's a pretty good thing.

So, let's talk about nix!

What is Nix?

In short, nix is a set of tools that solve a lot of problems!

One problem it doesn't solve is being clearly defined, as "nix" can refer to:

  1. a package manager
  2. a package repo
  3. a language
  4. an operating system
  5. ...

For the purposes of this post, I'll try to be clear about which one of these I'm talking about!

If its not clear, reach out and I'll edit this post to clarify!

Installation

If you want to install NixOS, you can do so via the NixOS installation guide.

You can do the same thing for nix, but this might cause issues on MacOS and requires some configuration.

I recommend this installer instead, which:

  1. is the easiest way to install nix on MacOS.
  2. fixes a lot of MacOS specific issues with the default installer.
  3. opts into experimental features by default, which is widely recommended.

See this post for more information on why you should enable experimental features.

This post will assume you have the experimental features enabled, but if you don't, you can always enable them later.

The Nix Package Manager

The nix package manager is a CLI tool that allows you to install and manage packages on your system.

You can think about nix as being an equivalent to brew or apt, but with a few key differences:

  1. Functional: There is "no global state" to mutate.
  2. Declarative: You can define your system configuration in a file.
  3. Cross-platform: It works on Linux, Mac, and Windows (via WSL).

The term "package manager" also refers to a few different concepts in the development world.

There are fuzzy boundaries here, but they feel distinct to me:

  • System package management: like apt, brew, pacman, etc.
  • Language package management: like npm, pip, gem, etc.
  • Project package management: like asdf, rvm, pyenv, etc.

The cool thing about nix is that it can do all of these things, and more!

Basic Usage

If you're new to nix, the following commands should feel familiar:

# To search for a package
nix search nixpkgs firefox
# To install a package
nix-env --install firefox
# To uninstall a package
nix-env --uninstall firefox

Whenever you install packages via nix, the following things happen:

  1. looks up package in nixpkgs.
  2. builds the package in /nix/store/.
  3. updates your PATH to include the package.

Do note that using nix-env to install packages it a bit of an anti-pattern.

You're opting out of the functional and declarative nature of nix by doing so.

Let's talk a bit about what that means.

Functional Package Management

The functional nature of nix gives you three things:

  1. Reproducibility: package versions, dependencies, configuration will always be the same.
  2. Isolation: packages are installed in a way that they don't interfere with each other.
  3. Rollbacks: if you install a package that breaks your system, you can roll back to a previous generation of your system configuration.

Note that this is a bit of a simplification, but it should be enough to get you started.

Basically, when nix installs a package, the path the package is installed to is unique to that package and version.

This is done by hashing the package name, version, and dependencies together to create a unique path.

Because of this, you get some superpowers:

  • You can have multiple versions of the same package installed at once.
  • The current "state" of your system is managed via symlinks.
  • Rolling back is just changing symlinks to point to a different version of the package.

You can even "preview" or temporarily installing packages in a transient shell without impacting anything else.

Try running nix-shell -p <package> to see what I mean!

This throws you into a new bash shell with your package installed.

Declarative Package Management

In addition to being functional, nix is also declarative.

Basically: you tell nix what you want, and it figures out how to get there.

There are two main mechanisms for doing this:

  1. via a shell.nix file
  2. via an experimental feature called flakes

Nix Shells

You can think about a shell.nix file as a way to define a "project" or "environment" that you want to work in.

For example, if you're working on a project that requires nodejs, python, and git, you can create a shell.nix file that looks like this:

{ pkgs ? import <nixpkgs> {} }:

with pkgs; mkShell {
  buildInputs = [
    nodejs
    python3
    git
  ];
}

Then, when you run nix-shell, it will create a new shell with those packages installed.

You can also add environment variables, shell hooks, and other configuration to the shell.nix file.

I'd recommend using direnv and lorri to automatically enter the shell whenever you cd into a directory with a shell.nix.

The issue with using this approach is that it relies on something called channels.

A channel is a way to point nix to a specific version of nixpkgs, which is the package repository for nix.

The problem is that channels are imperatively managed global state. If you update your channel, it will update all the packages in your system:

# List your channels (check channel versions, third party repos)
nix-channel --list
# Update your channels (like running apt-get update && apt-get upgrade)
nix-channel --update

Thankfully, flakes are the solution to this!

Nix Flakes

flakes are a new way to manage nix packages and configurations.

The main differences for package management are:

  1. They specify inputs, i.e. the repositories you want to use.
  2. They come with a lock file that locks the exact versions of the packages you're using.

This means that any two people using the same flake.nix and flake.lock will get the same versions of the packages, regardless of what version of nixpkgs they have installed.

An example flake.nix file for installing packages might look like:

{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

  outputs = { self, nixpkgs, ... }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      packages.x86_64-linux.default = pkgs.mkShell {
        buildInputs = [
          pkgs.firefox
          pkgs.git
        ];
      };
    };
}

This flake.nix file will create a shell with firefox and git installed, and the versions of those packages will be locked in the flake.lock file.

You can run nix develop to enter the shell, and nix run to run a command in the shell.

Likewise, direnv will automatically enter the shell whenever you cd into a directory with a flake.nix.

This basically allows you to use nix as a replacement for asdf, rvm, pyenv, etc.

That leads us nicely on to the next topic: NixOS.

The NixOS Operating System

Because the nix package manager can declaratively manage packages, it can also declaratively manage your entire system configuration.

This is the entire premise of NixOS, for example:

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

{
  imports = [ hardware-configuration.nix ];

  environment.systemPackages = with pkgs; [ firefox git ];
  networking.hostName = "my-hostname";
  services.sshd.enable = true;
}

This configuration.nix file will install firefox and git, set the hostname to my-hostname, and enable the SSH daemon.

Just like the package management examples, this builds your new system configuration into the /nix/store/ and symlinks the current state into /etc/.

The nixos-rebuild switch command will then apply the configuration and update your system.

Note: nixos-rebuild is a bit of a misnomer, as it doesn't actually rebuild your entire system every time you run it.

It will only rebuild the parts of your system that have changed, and it will use the cached versions of the packages if they haven't changed.

Because nix-lang is a fully-fledged programming language, you can extend or abstract your config via modules.

Some modules I recommend include:

Using modules, I've been able to abstract and share modules between my laptops, workstations, home servers, and even my family's machines.

I can even push updates to my modules and update my family's machines remotely.

Check out my dotfiles.

Because of how NixOS manages your system, you can also roll back to a previous generation of your system configuration with ease.

# List your generations
nixos-rebuild list-generations
# Rollback to a previous generation
nixos-rebuild switch --rollback

However, this leads to one little problem...

The Filesystem Hierarchy Standard

The Filesystem Hierarchy Standard (FHS) is a set of guidelines that define the directory structure and directory contents in Unix-like operating systems.

The FHS defines where things should go on the filesystem, and gives some interoperability guarantees when it comes to installing packages.

By this, I mean you can install a package from the internet (i.e. curl | sh) and expect it to work.

Assuming you have the right dependencies installed, of course.

This... isn't the case with NixOS. There is no real /bin, /lib, etc. on NixOS.

Instead, everything is managed via the /nix/store/ directory, which is symlinked into the filesystem as needed.

Generally speaking, that means non-NixOS packages won't work out of the box, and you'll need to use nix to install them.

You can use hacks like steam-run which provides a FHS-like environment for running non-NixOS packages, but YMMV.

The good news is that nixpkgs is the largest software repository in the world, and writing your own package definitions is pretty easy.

In my experience, I've never had to write a package definition. I have, however, had to write a few nix modules to manage services and configuration.

Modules

On my various servers, one component I always reach for is a reverse proxy.

This is a great use case for NixOS modules, as I've written a simple module that:

  1. Installs nginx.
  2. Manages my firewall rules.
  3. Generates reverse proxy rules for me.

It means that in any of my servers, I can configure a reverse proxy as simply as:

imports = [
  modules/services/proxy.nix
]

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;
  };
};

The implementation of the module is pretty simple too, as it's a simply wrapper around the standard nginx module in nixpkgs.

{ 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;
    };
  };
}

One interesting thing to note is that nix-lang is lazily evaluated.

If multiple modules set the same configuration, say, allowedTCPPorts, nix will merge them together into a single system config.

Managing Multiple Machines

One of the great things about NixOS is the ability to manage and reuse your configuration across multiple machines.

For this post, I'll be focusing on doing this with flakes.

If you're not using flakes, you can still copy the module system I talk about later, but you'll need to manually symlink different configuration.nix files to /etc/nixos/configuration.nix for each machine.

For example, I have a few different machines:

  • My main workstation madoka currently running Windows.
  • My Dell XPS 15 homura running NixOS.
  • My homelab server kyubey running NixOS.
  • My MacBook Pro iroha running MacOS.
  • My Surface Pro X mami running Windows ARM.

I use nix to manage all of these machines, and I can easily share my configuration between them.

Thanks to WSL, the configuration for all my Windows machines is practically the same as the configuration for my Linux machines.

And thanks to nix-darwin, I can use the same configuration for my MacOS machine.

The only differences are using nixos-wsl for WSL and nix-darwin for MacOS to manage the system configuration.

For example, nix-darwin let's you manage brew casks and MacOS services declaratively.

You can check out what my top-level flake.nix looks like:

{
  description = "Vereis' NixOS configuration";

  inputs =
    {
      nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
      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, 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 username; }
        import ./machines/windows { inherit (nixpkgs) lib; inherit inputs nixpkgs home-manager nixos-wsl username; }
      );
    };
}

Note: when importing a directory, nix will assume the directory contains a default.nix file.

Here, you can see I'm importing:

  • nixpkgs for the package repository.
  • nix-darwin for managing MacOS.
  • nix-homebrew for brew integration.
  • nixos-wsl for managing WSL.
  • home-manager for managing user configuration.

For my personal machines, I differentiate between machines/ configuration, modules/ configuration.

The former is responsible for configuring each machine, and is the entrypoint where I'll start pulling in modules.

The latter is where said modules live. I also differentiate between home/ and services/ modules which are normal applications versus services respectively.

That way, I can easily share modules between machines and reuse them as needed.

Note: you don't have to split things up at all if you don't want to!

I just like ensuring my modules come with all the configuration I need to run them, and I find it easier to manage when they're split up.

Likewise, I tend to re-use my configs between machines so splitting it up makes that easier.

Machine Configuration

The default.nix file in machines/* is responsible for delegating configuration based on hostname to a specific config.

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

let
  system = "x86_64-linux";
  pkgs = import nixpkgs { inherit system; config.allowUnfree = true; };
  lib = nixpkgs.lib;
in
{
  madoka = lib.nixosSystem {
    inherit system;
    specialArgs = { inherit inputs username; };
    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; };
        home-manager.users.${username}.imports = [(import ./home.nix)] ++ [(import ./madoka/home.nix)];
      }
    ];
  };

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

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

  ...
}

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

In fact, the most recent version of my configuration simplifies this a little so check it out!

The key thing this does is import:

  1. Global system configuration: machines/*/configuration.nix
    • Configures nix itself.
    • Configures timezone, locales, etc.
  2. Host system configuration: machines/*/$host/default.nix
    • Configures services, hardware, kernel modules, etc.
    • Imports my services modules.
  3. Host user configuration: machines/*/$host/home.nix
    • Configures user packages, dotfiles, etc.
    • Imports my home modules.

The most important part of my own config is the home.nix files per host, so let's get into that!

Home Manager

As mentioned earlier, home-manager is a tool for managing your user configuration, dotfiles, and packages.

I use it to install any package that doesn't require a daemon or service to run.

When you use home-manager to install packages, you get access to declarative configuration, for example:

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

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

  config = mkIf config.modules.zsh.enable {
    home.packages = with pkgs; [ zsh ];

    programs.zsh = {
      enable = true;

      autocd = true;
      enableCompletion = true;
      autosuggestion.enable = true;

      prezto = {
        enable = true;
        prompt.theme = "powerlevel10k";
        editor.keymap = "vi";
      };
  };
}

Installing a package is then as simple as importing your module and adding modules.zsh.enable = true; to your configuration.nix file.

You can also declaratively source files into your home directory if what you want to do is low-level or not supported by home-manager.

You can use the following to find supported packages and configuration options! Home Manager Search.

For example, I might want my neovim config on hosts where I don't have access to nix, so I source it instead:

{ 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')
      '';
    };
  };
}

You can see the home.file."name".source = ... syntax is used to copy files into the home directory.

This way I can keep specific dotfiles reusable outside of using nix -- not that I've ever needed this.

However, not everything can be installed via home-manager...

Service Modules

If you need to install a service or daemon, you'll need to use nixos modules instead.

Despite the name, nixos modules can be used on any system that uses nix, including MacOS and WSL.

My previously shared proxy.nix module above is a good example of this. Note that this is enabled the same way as home/ modules, namely:

imports = [
  modules/services/proxy.nix
]

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;
  };
};

Superpowers

For me, the ability to manage my entire system configuration in a declarative way is the killer feature of nix.

The fact that it replaces the need for asdf, rvm, pyenv, etc. is just icing on the cake.

To end off this post, this is all it takes to bootstrap a new machine:

# Install Nix via Determinate Systems
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install --determinate

# Temporarily install `git` so I can clone my dotfiles
nix-shell -p git

# Clone my dotfiles
git clone https://github.com/vereis/nix-config
cd nix-config
sudo nixos-rebuild switch --flake .#$HOSTNAME

If my projects or job have a flake.nix in them, entering those directories automatically bootstraps a dev environment for me.

And is literally just works no questions asked. I genuinely believe nix is the future.

I hope you try it out! If nothing else, it's a new thing to learn!