Using NixOS

I have been using NixOS for a year now, and here are some reflections on it.

· 17 min read
Using NixOS

Introduction

I have been hearing and reading about NixOS and nix throughout Reddit and various blogs for a while. Nix is both a cross-platform package manager for Unix-like systems and a functional language to configure those systems. It is touted for having the largest collection of packages in the world, as well as for its declarative nature and reproducibility. NixOS is a Linux distribution that is built around the nix package manager.

I have always been a fan of being able to have a config for one’s entire PC or server, and to quickly deploy a new computer or VM instance from that config. I used Ansible to achieve that to some extent, via my Ansible-NAS fork to declaratively deploy Docker containers, but nix was the new thing.

I never really got the need or chance to use it as I was a Windows power user. I used WinGet to configure the system, but in late 2024 the Mac Mini M4 came out and it was such a good value that I bought it almost immediately.

MacOS (Nix-Darwin)

With a new blank computer I could experiment a bit more than with an established system. I decided to try Dustin Lyons’ nix-config as it supported both MacOS (Darwin) and NixOS configurations. There was a simple installation method, nice config layout with modules and overlays, and usage of flakes , which were officially still an experimental feature of nix at the time, but are nevertheless widely used and somehow made more sense to me.

The config uses nix-darwin and nix-homebrew for MacOS and Homebrew configuration. To use both, I just had to add them as inputs in my config:

inputs = {
    darwin = {
      url = "github:LnL7/nix-darwin/master";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    nix-homebrew = {
      url = "github:zhaofengli-wip/nix-homebrew";
    };
    homebrew-bundle = {
      url = "github:homebrew/homebrew-bundle";
      flake = false;
    };
    homebrew-core = {
      url = "github:homebrew/homebrew-core";
      flake = false;
    };
    homebrew-cask = {
      url = "github:homebrew/homebrew-cask";
      flake = false;
    };

With that, and enabling homebrew modules in darwinConfiguration, I was able to configure quite a lot of the MacOS system: system settings, fonts, dock configurations, launchd agents, dotfiles, and most applications through homebrew - everything declaratively in the config.

It was a very pleasant experience to use the system like that. One aspect of nix that needs to be understood - and is often perceived as a drawback - is that now (especially on MacOS/Darwin) nearly every aspect of the system needs to be configured declaratively in the config. It then has to be rebuilt and switched to or activated. You can’t just change a setting in the UI or install a new app with homebrew install neofetch; you have to update the config, e.g.:

{pkgs}:
with pkgs; [
   neofetch
]

And then build and switch to the new configuration:

nix run .#build-switch

This is too cumbersome and restrictive for some, but that is the cost of having fully reproducible and git-managed configs. This can be restrictive when you are constantly tinkering with your config, trying out new settings, etc., and for that reason to this day I have not moved my neovim or hyprland config to nix yet.

CIFS Mounts

One very useful module I created was automatic CIFS volume mounting:

{
  user,
  lib,
  ...
}: let
  paths = [
    "/Users/${user}/mnt/server/mount"
    ...

  ];
in {
  system.activationScripts.mountsPermissions.text = ''
    echo "Setting up mount paths..." >&2
    for path in ${lib.concatStringsSep " " paths}; do
      if [ ! -d "$path" ]; then
        mkdir -p "$path"
        chown ${user}:staff "$path"
        chmod 755 "$path"
      fi
    done
  '';

  launchd.user.agents.mount_server.serviceConfig = {
    Label = "mount.server";
    RunAtLoad = true;
    ProgramArguments = [
      "/sbin/mount_smbfs"
      "-f"
      "0775"
      "-d"
      "0775"
      "smb://user:password@server/mount"
      "/Users/${user}/mnt/server/mount"
    ];
    StandardErrorPath = "/tmp/mount_server.err.log";
    StandardOutPath = "/tmp/mount_server.out.log";
  };

  ...
}

With that, I had all my mounts already connected after login. The drawback, as you immediately see, is that the username and password are part of the config - which is the reason I have never published my own publicly. This ultimately leads to secrets management, but more on that later.

NixOS

After having some experience with nix already on MacOS, I wanted to try out the whole Linux distribution—NixOS. I spun up a Proxmox VM and installed NixOS from the installation image and tried out the config I had on MacOS, but it was failing on boot, so the initial impression was rather poor.

I soon found out that for NixOS to work in a VM, you have to add appropriate kernel modules:

  boot = {
    initrd.availableKernelModules = [... "virtio_pci" "virtio_scsi"];
  };

I did not continue in a VM anymore, until half a year later, when I gave the Mac Mini to my daughter and replaced my main PC with a powerful Ryzen 9900X with NVIDIA RTX 5080 GPU. This time I was determined to make it work, although I was not yet ready to ditch Windows, so dual-booting was the first thing I set up.

I followed Drake Rossman’s guide initially, but later switched to have Windows and NixOS on separate drives and let systemd boot handle that:

{...}: {
  fileSystems."/" = {
    device = "/dev/disk/by-uuid/9f02d085-5042-48e8-80ed-18818a1e0d7e";
    fsType = "ext4";
  };

  fileSystems."/boot" = {
    device = "/dev/disk/by-uuid/1B3D-34CF";
    fsType = "vfat";
    options = ["fmask=0077" "dmask=0077"];
  };

  # Local drives
  # Windows
  fileSystems."/mnt/local/C" = {
    device = "/dev/disk/by-uuid/3C1C892F1C88E4EC";
    fsType = "ntfs-3g";
    options = ["nofail" "rw" "uid=1000" "gid=1000" "fmask=0077" "dmask=0077"];
  };

  fileSystems."/mnt/local/D" = {
    device = "/dev/disk/by-uuid/289AC1639AC12E5E";
    fsType = "ntfs-3g";
    options = ["nofail" "rw" "uid=1000" "gid=1000" "fmask=0077" "dmask=0077"];
  };

  fileSystems."/mnt/local/E" = {
    device = "/dev/disk/by-uuid/900C0A480C0A29B4";
    fsType = "ntfs-3g";
    options = ["nofail" "rw" "uid=1000" "gid=1000" "fmask=0077" "dmask=0077"];
  };
}
  boot = {
    # Use the systemd-boot EFI boot loader.
    loader = {
      systemd-boot = {
        enable = true;
        configurationLimit = 15;
        consoleMode = "max";
        edk2-uefi-shell = {
          enable = true;
          sortKey = "z_uefishell";
        };
        windows = {
          "nvme1n1p1" = {
            title = "Windows 11";
            # sudo blkid //check Windows ESP PARTUUID
            # reboot to systemd-boot uefi shell and type: map
            # find the FS alias match Windows ESP (ex: HD0a66666a2, HD0b, FS1, or BLK7)
            efiDeviceHandle = "HD2b";
            sortKey = "a_windows";
          };
        };
      };
      efi.canTouchEfiVariables = true;
    };

    supportedFilesystems = [
      "ntfs"
    ];
  };

That allowed me to have Windows always visible in the boot menu, although it is no longer the default.

From the start I wanted to use another hyped program from the Linux world— Hyprland . Hyprland is a dynamic tiling window compositor that looks great with all the animations, colors, rounded corners, blur, and transparency. It does indeed look great and is very fast on my system, although it uses between 800–1000 MB of GPU VRAM. To enable it on NixOS you just set the option programs.hyprland.enable to true:

{pkgs, ...}: {
  programs = {
    hyprland = {
      enable = true;
      portalPackage = pkgs.xdg-desktop-portal-hyprland;
    };
    xwayland.enable = true;
  };
  environment.sessionVariables.NIXOS_OZONE_WL = "1";
  environment.sessionVariables.WLR_NO_HARDWARE_CURSORS = "1";
}

Quickshell and Caelestia

Initially I was running my Hyprland config based on JaKooLit’s Hyprland-Dots but very soon while browsing Reddit I discovered Caelestia Shell :


[Hyprland] I <3 Quickshell
by u/Rexcrazy804 in unixporn

I was instantly struck by its simplicity and fluidity and I decided to switch to it. The only thing I did not like was the bar on the left, rather than the usual top or bottom, so I forked and continue to maintain my own version with the bar moved to the top. Caelestia has a flake.nix, so to use it on NixOS you just need to add it as an input:

inputs = {
    caelestia.url = "github:anarion80/caelestia-shell?ref=topbar";
};

And then add the necessary packages:

{
  pkgs,
  inputs,
  ...
}: {
  environment.systemPackages = [
    inputs.caelestia.packages.${pkgs.stdenv.hostPlatform.system}.default
    inputs.caelestia.inputs.caelestia-cli.packages.${pkgs.stdenv.hostPlatform.system}.default
    pkgs.app2unit
    pkgs.extractmonopcm
    pkgs.converttopcm
  ];
}

Issues

Initially I struggled with screen flickering, no audio, Bluetooth keyboard connectivity issues, screenshots, screen sharing, etc., but eventually I was able to resolve most if not all of them.

Screen issues were fixed first by using open-source NVIDIA beta drivers and I think were simply fixed in later versions:

    hardware.nvidia = {
      modesetting.enable = true;
      powerManagement.enable = false;
      powerManagement.finegrained = false;
      nvidiaPersistenced = false;
      open = true;
      nvidiaSettings = true;
      package = config.boot.kernelPackages.nvidiaPackages.beta;
    };

I could not connect my Kinesis Bluetooth keyboard to the system. The whole Bluetooth controller was not even getting detected or visible, so there was no way to discover and pair the keyboard.

After some searching I tried disabling and then enabling wireless services:

rfkill block bluetooth
rfkill unblock bluetooth
bluetoothctl discoverable on
bluetoothctl pairable on

And that was it - the controller appeared and the keyboard saw it and paired with it. Once or twice since then due to some unknown reason the keyboard did not connect again I had to apply the rfkill commands again.

Screen sharing was temporarily solved by downgrading bitdepth in Hyprland and is now fixed.

Secrets Management

When using flakes, the whole config needs to be added to git, and it gets copied to the nix store, so you can’t have any sensitive information unencrypted in the repo config and managing secrets becomes mandatory.

I decided to use sops-nix for secrets management, as it was often recommended for NixOS. Installation was straightforward, generating age encryption keys and creating the encrypted system.yaml file as well.

I have my sops-encrypted secrets stored in a private repo, which is then an input in my flake.

Where I struggled for a while was with home-manager use of sops-nix. home-manager has only a subset of features provided by the system-wide sops-nix and the secrets end up in a different location - under $HOME. So you have to be mindful of where the particular secret is going to be used, in what scope, by what user/service, and then separate them accordingly between system and home-manager.

Another difficult aspect was even importing the sops-nix into home-manager modules. Dustin Lyons’ original config was using agenix for secrets rather than sops-nix and its layout was not clear enough to inject sops-nix into it. I had to redo the whole config, make it more modularized and separate home-manager enough, so that it finally clicked for me and I knew where to import the module. In the end, this is the relevant piece of my nixosConfigurations:

        nixpkgs.lib.nixosSystem {
          system = cleanSystem;
          specialArgs = {
            inherit inputs localSecrets flakePath user;
            inherit hostName;
          };
          modules = [
            ./modules/home-manager/module.nix
            home-manager.nixosModules.home-manager
            sops-nix.nixosModules.sops
            disko.nixosModules.disko
            ({
              config,
              pkgs,
              lib,
              ...
            }: {
              home-manager = {
                sharedModules = [inputs.sops-nix.homeManagerModules.sops];
                useGlobalPkgs = true;
                useUserPackages = true;
                extraSpecialArgs = {inherit inputs localSecrets;};
                users.${user} = import ./modules/home-manager {
                  inherit config pkgs lib inputs localSecrets;
                  profile = config.modules.home-manager.profile;
                  extraConfig = config.modules.home-manager.extraConfig;
                  extraPackages = config.modules.home-manager.extraPackages;
                  excludePackages = config.modules.home-manager.excludePackages;
                };
              };
            })
            (hostsDir + "/${hostName}/default.nix")
          ];
        };

I use secrets for SSH keys, passwords, email addresses, API keys, CIFS mounts credentials, etc. Especially this last part is very useful for me, because I have all the many CIFS/SMB mounts from different servers mounted automatically with credentials encrypted. It took a while to arrive at a working solution, and Qwen was helpful in doing the legwork.

Ultimately, I have only mount passwords encrypted in sops:

mounts:
    mount1: password1
    mount2: password2

While all other data is in the module itself:

mountsConfig = {
    # Define all your mounts here with their non-sensitive parameters
    "server1_mount1" = {
        username = "anarion-nas";
        local_path = "/mnt/server1/mount1";
        remote_path = "server1/mount1";
    };
    "server2_mount1" = {
        username = "anarion-nas";
        local_path = "/mnt/server2/mount1";
        remote_path = "server2/mount1";
    };
    "server3" = {
        username = "admin";
        local_path = "/mnt/server3";
        remote_path = "server/admin/download";
        vers = "1.0";
        mount_user = "anarion-nas";
    };
};

Then I use sops-nix templates to create credential files for all the mounts.

This is the resulting module, which also takes care of creating the mount directories and fileSystems configurations:

{
  config,
  lib,
  pkgs,
  ...
}: let
  # Helper function to get user ID from username
  getUserId = username: let
    userConfig = config.users.users.${username} or null;
  in
    if userConfig != null
    then toString userConfig.uid
    else "1000"; # fallback to 1000

  # Helper function to get group ID from username
  getGroupId = username: let
    groupConfig = config.users.groups.${username} or null;
  in
    if groupConfig != null
    then toString groupConfig.gid
    else "1000"; # fallback to 1000

  # Generate filesystem configurations for each mount
  generateFileSystems = mounts:
    lib.mapAttrs' (mountName: mountConfig: let
      # Use permissions from config or default to 775
      fileMode = mountConfig.permissions or "0775";
      dirMode = mountConfig.permissions or "0775";
      vers = mountConfig.vers or "2.0";
      # Use mount_user for uid/gid if specified, otherwise fallback to username
      mountUser = mountConfig.mount_user or mountConfig.username;
    in {
      name = mountConfig.local_path;
      value = {
        device = "//${mountConfig.remote_path}";
        fsType = "cifs";
        options = [
          # Create a credentials file for this specific mount
          "credentials=/run/mount-credentials/${mountName}"
          "uid=${getUserId mountUser}"
          "gid=${getGroupId mountUser}"
          "username=${mountConfig.username}"
          "vers=${vers}"
          "file_mode=${fileMode}"
          "dir_mode=${dirMode}"
          "nounix"
          "nofail"
          "x-systemd.idle-timeout=60,x-systemd.device-timeout=5s,x-systemd.mount-timeout=5s"
        ];
        depends = ["/run/mount-credentials/${mountName}"];
      };
    })
    mounts;

  # Generate activation script to create mount directories
  generateMountDirs = mounts: let
    mkdirCommands =
      lib.mapAttrsToList (mountName: mountConfig: let
        # Use permissions from config or default to 775
        permissions = mountConfig.permissions or "775";
        # Use mount_user for ownership if specified, otherwise fallback to username
        owner = mountConfig.mount_user or mountConfig.username;
      in ''
        mkdir -p ${mountConfig.local_path}
        chown ${owner}:${owner} ${mountConfig.local_path}
        chmod ${toString permissions} ${mountConfig.local_path}
      '')
      mounts;
  in
    lib.concatStringsSep "
" mkdirCommands;

  # Define the mounts configuration structure
  mountsConfig = {
    # Define all your mounts here with their non-sensitive parameters
    "server1_mount1" = {
      username = "anarion-nas";
      local_path = "/mnt/server1/mount1";
      remote_path = "server1/mount1";
    };
    "server2_mount1" = {
      username = "anarion-nas";
      local_path = "/mnt/server2/mount1";
      remote_path = "server2/mount1";
    };
    "server3" = {
      username = "admin";
      local_path = "/mnt/server3";
      remote_path = "server/admin/download";
      vers = "1.0";
      mount_user = "anarion-nas";
    };
  };
in {
  # Create credential files
  sops.templates =
    lib.mapAttrs' (mountName: mountConfig: {
      name = "cifs-${mountName}-credentials";
      value = {
        path = "/run/mount-credentials/${mountName}";
        mode = "0600";
        content = ''
          username=${mountConfig.username}
          password=${config.sops.placeholder."mounts/${mountName}"}
        '';
      };
    })
    mountsConfig;

  # Generate filesystem configurations
  fileSystems = generateFileSystems mountsConfig;

  # Create activation script to ensure mount directories exist
  system.activationScripts.createMountDirs = {
    text = generateMountDirs mountsConfig;
    deps = ["var"];
  };
}

Local Secrets

There is one secrets-related problem I was not able to solve satisfactorily. I use lieer and notmuch to fetch Gmail emails for local consumption in neomutt . I declare my email accounts using home-manager option accounts.email.<name>.* where I need to explicitly add an email address, but I want even those email addresses as encrypted secrets.

There is really no way to use sops-encrypted secrets as variables in nix during evaluation, so we have to resort to some workarounds.

In the local repo I simply create a local file with limited permissions that contains secrets I want to be available during rebuild.

That file is then read in the flake:

localSecrets = builtins.fromJSON (builtins.readFile "${self}/local_secrets.json");

And resulting attribute set is then used throughout the config:

  accounts.email.accounts = {
      ${localSecrets.email_accounts."1".address} = {
        primary = true;
        flavor = "gmail.com";

        realName = localSecrets.email_accounts."1".realName;
        signature = {
          showSignature = "none";
          text = ''
            ${localSecrets.email_accounts."1".realName}
          '';
        };

You need to create the following file:

{
  "email_accounts": {
    "1": {
      "address": "[email protected]",
      "realName": "Real Name"
    }
  }
}

The file is added to .gitignore, but that poses another challenge as nix with flakes does not see the file (as it should - because otherwise it would be copied to nix store and available to read by anyone). We need to use the local file without committing it to the repo (hence .gitignore), but making it available to nix for evaluation.

For that, we have to resort to yet another workaround :

  • Tell git to track local_secrets.json but without adding it:

    git add --intent-to-add local_secrets.json

  • Tell git to assume that local_secrets.json doesn’t have any changes:

    git update-index --assume-unchanged local_secrets.json

Multiple Hosts

Dustin Lyons’ original config provides one host per platform, so when I wanted to add a NixOS config for another host - my laptop - I had to update the config. I wanted it to be somewhat automated, so that I would only add a new host config under the hosts/nixos folder, and then it would be automatically included in nixosConfigurations, with no need to update the main flake.nix file. An optional systemFile under a particular host would determine the system (x86_64-linux, etc.).

With that, I have a mkHostConfig function that maps over all the valid hosts from the folder and creates a config for them:


nixosConfigurations = let
      # Get all directories in the hosts directory
      hostsDir = ./hosts/nixos;
      hostDirs = builtins.attrNames (builtins.readDir hostsDir);

      # Filter out files (only keep directories) and non-nixos directories if needed
      isDirectory = name: let
        path = hostsDir + "/${name}";
      in
        builtins.pathExists (path + "/default.nix");

      validHosts = builtins.filter isDirectory hostDirs;

      # Create a configuration for each host
      mkHostConfig = hostName: let
        # Check if host has a specific system architecture defined
        systemFile = hostsDir + "/${hostName}/system";
        hostSystem =
          if builtins.pathExists systemFile
          then builtins.readFile systemFile
          else "x86_64-linux";
        # Remove any trailing whitespace/newlines
        cleanSystem = builtins.replaceStrings ["\n" " "] ["" ""] hostSystem;
      in
        nixpkgs.lib.nixosSystem {
          system = cleanSystem;
          specialArgs = {
            inherit inputs localSecrets flakePath user;
            inherit hostName;
          };
          modules = [
            ./modules/home-manager/module.nix
            home-manager.nixosModules.home-manager
            sops-nix.nixosModules.sops
            disko.nixosModules.disko
            ({
              config,
              pkgs,
              lib,
              ...
            }: {
              home-manager = {
                sharedModules = [inputs.sops-nix.homeManagerModules.sops];
                useGlobalPkgs = true;
                useUserPackages = true;
                extraSpecialArgs = {inherit inputs localSecrets;};
                users.${user} = import ./modules/home-manager {
                  inherit config pkgs lib inputs localSecrets;
                  profile = config.modules.home-manager.profile;
                  extraConfig = config.modules.home-manager.extraConfig;
                  extraPackages = config.modules.home-manager.extraPackages;
                  excludePackages = config.modules.home-manager.excludePackages;
                };
              };
            })
            (hostsDir + "/${hostName}/default.nix")
          ];
        };

      # Create attribute set of host configurations for x86_64-linux only
      hostConfigs = builtins.listToAttrs (
        builtins.map (name: {
          inherit name;
          value = mkHostConfig name;
        })
        validHosts
      );
    in
      hostConfigs;
  };

Stable vs. Unstable

When using NixOS and nix packages you can use them from the official stable and unstable channels. Stable has less frequent updates and less breakage; unstable is the bleeding edge. I initially selected the unstable:

inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
};

And it has been surprisingly stable. I usually refresh and update my config (nix flake update) every few weeks or when I need some updated package. After adding more and more packages, though, it more often happens that one package is failing to build and I need to wait a couple of days until it is fixed. Then perhaps some other package is broken, so the time before I can upgrade is even longer.

Whenever I’m blocked, I look for an existing issue in the nixpkgs repo and I subscribe to it. Sooner or later there will be a PR with a fix, so I subscribe to that one too. When that PR is merged, I keep checking the molybdenum software nixpkgs PR tracker with the PR number - when it shows the merge to nixpkgs-unstable branch is done, I can retry the build and activation.

Another related topic is the time it takes to build and switch. Even though I use the binary cache, certain packages need to be compiled and the whole process can take 1-2 hours on my system. It’s not a big deal for me, but one needs to be aware.

Inspiration

There are so many great nix configs on GitHub; I often pick up some interesting modules or scripts from them. A helpful tip here is to search GitHub for *.nix files only: https://github.com/search?q=firefox+path%3A*.nix&type=code With this tip you can find, for example, a super privacy-oriented Firefox browser config like this , with all the necessary settings set, plugins installed, and bookmarks defined.

Conclusion

Looking back on my NixOS journey, the learning curve was steep but worth it. The initial investment in understanding nix’s declarative approach, secrets management, and module system has paid off in spades. I now have a fully reproducible, version-controlled system configuration that I can deploy across multiple machines with confidence.

I have been daily driving this NixOS setup for a year now and it has been great! I have not switched back to Windows in half a year. I can proudly say I use NixOS❄️ now 😉

Related Posts

Using NixOS
· 17 min read
My custom keyboard journey
· 23 min read
Home Assistant Daily Digest
· 10 min read