Premable

In this second part of the series, we will look at how we can not set up NixOS past installation. How we can install software and various other tools. After part 1 we should have NixOS installed, mind you since I’ve written that blog post I found a way to create a custom ISO image from my Nix config. This ISO contains a custom install script, the main advantage being able to use a tool called disko to partition our disks. Anyway, I will probably write another post in the future going over how you can do this in another blog post.

My NixOS Config Explored

I will also do a more detailed series into my Nix config at some point. This post will be a more general post about one possible way you can structure your NixOS config.

Heavily inspired by Misterio77

Introduction

Currently, we have a single configuration file at /etc/nixos/configuration.nix. However, to edit this file we need sudo permissions, we also cannot easily put it into a git repository and share this configuration with other machines. One potential to this solution is to use Nix Flakes.

Flakes

Nix Flakes exist to improve reproducibility, composability and usability in the Nix ecosystem. What do we mean by that, well in general they make it easier 1.

  • Lock file: They lock all of our dependencies to specific git revisions, so if we try to use the config on another machine it should produce the same “outputs”
  • Entry point: The entry point to every nix flake is the flake.nix file, kinda a main function where everything starts from
  • Share: We can put our flake wherever we want on our system and therefore it is super easy to turn it into a git repo and share it with others

Getting Started

So in my case, I did this by creating a new folder in my home directory mkdir $HOME/dotfiles, then going into that directory cd $HOME/dotfiles, and finally creating a new nix flake nix flake init.

We may need to add the following to our configuration.nix nix.settings.experimental-features = [ "nix-command" "flakes" ]; to allow us to use nix flake command(s).

Now we have a new flake in a git repo. We will now have a flake.nix file which contains three main sections.

inputs

Specifies dependencies of this flake, usually other flakes. Usually, I add dependencies you cannot find on nixpkgs, such as nixvim.

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

    nixvim.url = "github:pta2002/nixvim";
  };
}

One of the imports that is important is the nixpkgs, this will determine which versions of packages we will get. Essentially nixpkgs is just a repo full of different nix derivations which tell Nix how to install a package. Where a Nix derivation is a specific build of a package, which includes all the necessary information, build steps and dependencies for that package.

flake.lock

In our flake.lock file, we have something like this:

"nixpkgs_11": {
  "locked": {
    "lastModified": 1693844670,
    "narHash": "sha256-t69F2nBB8DNQUWHD809oJZJVE+23XBrth4QZuVd6IE0=",
    "owner": "nixos",
    "repo": "nixpkgs",
    "rev": "3c15feef7770eb5500a4b8792623e2d6f598c9c1",
    "type": "github"
  },
  "original": {
    "owner": "nixos",
    "ref": "nixos-unstable",
    "repo": "nixpkgs",
    "type": "github"
  }
},

Where we can see rev is a git sha. In this case, we are looking at a specific branch ref: nixos-unstable so we use the unstable channel, https://search.nixos.org/packages?channel=unstable&from=0&size=50&sort=relevance&type=packages&query=ag.

So if we don’t ever update our flake.lock we will forever be tied to this version of the unstable channel at that moment. Of course, that branch is getting updated multiple times a day. So to update our tools/apps etc. we need to update this lock file. We can do this by running nix flake update, in our dotfiles repo. The unstable is just a branch on the nixpkgs repo where the packages are updated more often. So when we update our flakes (using a nix flake update).

outputs

The output you can think of it as the different devices we want to configure. Which includes our actual NixOS config to set up our machine, such as where to backup our files to, and setting up VPNs. Notice how we are pointing the framework configuration to a configuration file. Which we can build our config using sudo nixos-rebuild switch --flake ~/dotfiles#framework, using #framework to specify which device to build for.

{
  outputs =
    {
      nixosConfigurations = {
        # Laptops
        framework = lib.nixosSystem {
          modules = [ ./hosts/framework/configuration.nix ];
          specialArgs = { inherit inputs outputs; };
        };
      };
    };
}

Configuring NixOS

Now that we have the basic format of what our NixOS config will look like how do we go about actually configuring our system? We split our config into two main bits. Some key bits of my config, I like as much config to be shared between my devices as possible but I also want it to be modular, as not every device needs to use every feature. Some devices don’t even run NixOS and only use home-manager.

NixOS

The first bit is our NixOS config which we will use to configure our device. Think of anything we need “sudo” permissions to do. Again this code block:

{
  outputs =
    {
      nixosConfigurations = {
        # Laptops
        framework = lib.nixosSystem {
          modules = [ ./hosts/framework/configuration.nix ];
          specialArgs = { inherit inputs outputs; };
        };
      };
    };
}

Essentially what we are doing is pointing the flake to use the framework specific configuration.nix file. Then giving it access to the inputs (and outputs). Which we can access in our configuration.

configuration.nix

Where the configuration.nix looks like this:

{ inputs, ... }: {
  imports = [
    inputs.hardware.nixosModules.framework-12th-gen-intel
    inputs.hyprland.nixosModules.default
    inputs.disko.nixosModules.disko

    ./hardware-configuration.nix
    ./disks.nix

    ../../nixos/global
    ../../nixos/users/haseeb.nix

    ../../nixos/optional/backup.nix
    ../../nixos/optional/fingerprint.nix
    ../../nixos/optional/docker.nix
    ../../nixos/optional/fonts.nix
    ../../nixos/optional/pipewire.nix
    ../../nixos/optional/greetd.nix
    ../../nixos/optional/quietboot.nix
    ../../nixos/optional/vfio.nix
    ../../nixos/optional/vpn.nix
    ../../nixos/optional/pam.nix
    ../../nixos/optional/grub.nix
  ];

  networking = {
    hostName = "framework";
  };

  system.stateVersion = "23.05";
}

Some things shared between all of my configs is:

  • inputs (we discussed above)
  • hardware-configuration
  • disks (used to partition drives)
global

The global config set up the following, which I think I will need in all of my devices. Such as:

  • locale
  • nix settings
  • pam auth
  • opengl
  • persistence

For example, pam.nix looks like this:

{
  security.pam.services = {
    swaylock = {
      u2fAuth = true;
    };

    login = {
      u2fAuth = true;
    };

    sudo = {
      u2fAuth = true;
    };
  };
}

Allow us to use a Yubikey to login, unlock Swaylock and to grant sudo permissions.

users/haseeb.nix

Then we decide which users we want to configure on that device, which for now is always haseeb but could change. Perhaps you want multiple users on a specific device. Where we set up things like:

  • default shell
  • groups
  • docker
  • libvirt
  • etc …
  • home-manager config
    • so a NixOS rebuild also rebuilds the home manager config
  • hashed password stored using sops-nix (encrypted)
optional features

Then alongside the “global” features, we have a bunch of features/config options which can optionally be turned on by importing. I think I will likely move to a system I have with my home manager config where you don’t turn it on by importing but by setting an option in an attribute set (you will see this a bit later). However for now you simply import the optional feature. Which include:

  • backups
  • fingerprint (not all my devices have fingerprint readers)
  • enabling thunderbolt
  • quierboot/grub (could also use systemd-boot)
  • pipewire

You can find a full list of options here

Home Manager

Home Manager is a tool we can use to help configure apps using Nix in our home folder. This includes managing dotfiles. This can partly be done using nix expressions, used to generate the dotfiles.

This is the main part of my config, which I use to configure my “user” space. Basically, everything I can do with my user that doesn’t require root permissions 2. This includes things like:

  • terminal emulator
  • dotfiles
  • browsers
  • editor (nvim)
  • tmux

If I can configure it via home manager I will. You can find a full list of home manager options here.

Where the home.nix file acts like a configuration.nix but for home manager.

{
, pkgs
, lib
, config
, ...
}: {
  imports = [
    ../../home-manager
  ];

  config = {
    modules = {
      browsers = {
        firefox.enable = true;
      };

      editors = {
        nvim.enable = true;
      };

      multiplexers = {
        tmux.enable = true;
      };

      shells = {
        fish.enable = true;
      };

      terminals = {
        alacritty.enable = true;
        foot.enable = true;
      };
    };

    my.settings = {
      wallpaper = "~/dotfiles/home-manager/wallpapers/rainbow-nix.jpg";
      host = "framework";
      default = {
        shell = "${pkgs.fish}/bin/fish";
        terminal = "${pkgs.foot}/bin/foot";
        browser = "firefox";
        editor = "nvim";
      };
    };

    colorscheme = inputs.nix-colors.colorSchemes.catppuccin-mocha;

    home = {
      username = lib.mkDefault "haseeb";
      homeDirectory = lib.mkDefault "/home/${config.home.username}";
      stateVersion = lib.mkDefault "23.05";
    };
  };
}

Here we “import” all of our options in home-manager and pick and choose what to enable per device:

  config = {
    modules = {
      browsers = {
        firefox.enable = true;
      };

      editors = {
        nvim.enable = true;
      };

      multiplexers = {
        tmux.enable = true;
      };

      shells = {
        fish.enable = true;
      };

      terminals = {
        alacritty.enable = true;
        foot.enable = true;
      };
    };
  };

You can see here I enable alacritty and foot terminal managers for this device, so I will have access to both. Then we also have this which are custom options I have defined. Which will determine the default apps to use.

Where foot.nix looks something like:


{ config, lib, ... }:

with lib;
let
  cfg = config.modules.terminals.foot;
in
{
  options.modules.terminals.foot = {
    enable = mkEnableOption "enable foot terminal emulator";
  };

  config = mkIf cfg.enable {
    programs.foot = {
      enable = true;
    };
  };
}

Here you can see we check if cfg.enable if the foot terminal is enabled then it will be included in our final nix expression.

    my.settings = {
      wallpaper = "~/dotfiles/home-manager/wallpapers/rainbow-nix.jpg";
      host = "framework";
      default = {
        shell = "${pkgs.fish}/bin/fish";
        terminal = "${pkgs.foot}/bin/foot";
        browser = "firefox";
        editor = "nvim";
      };
    };

Which looks something like allows us to have custom options:

{ lib, pkgs, ... }:
let
  inherit (lib) types mkOption;
in
{
  options.my.settings = {
    default = {
      shell = mkOption {
        type = types.nullOr (types.enum [ "${pkgs.fish}/bin/fish" "${pkgs.zsh}/bin/zsh" ]);
        description = "The default shell to use";
        default = "${pkgs.fish}/bin/fish";
      };

      terminal = mkOption {
        type = types.nullOr (types.enum [ "alacritty" "${pkgs.foot}/bin/foot" ]);
        description = "The default terminal to use";
        default = "${pkgs.foot}/bin/foot";
      };

      browser = mkOption {
        type = types.nullOr (types.enum [ "firefox" ]);
        description = "The default browser to use";
        default = "firefox";
      };

      editor = mkOption {
        type = types.nullOr (types.enum [ "nvim" "code" ]);
        description = "The default editor to use";
        default = "nvim";
      };
    };
  };
}

These will then get references in other bits of the config like (take from my sway config):

{config, ...}: {
    # ....
    "exec ${config.my.settings.default.browser}";
}

Thats it! We’ve gone over how you can setup your NixOS/Nix config, like how I have setup my own!

Appendix