After years on Arch Linux I got tired of remembering every tweak I’d made to get my system right after a fresh install. Dotfiles helped, but they never told the full story. NixOS fixes that: if it’s not in the config, it doesn’t exist.

This post documents my setup: NixOS with flakes, Home Manager, the Niri window manager, and a Wayland-first desktop.

NixOS migration series

This is the first article in my NixOS migration series. Follow-ups: Modularizing your NixOS configuration and Fixing clipboard persistence in Wayland.

Why Niri?

I used i3 for years. When I moved to Wayland, I tried Sway and Hyprland, but neither felt right. Niri is a scrollable tiling compositor, the windows scroll horizontally in columns instead of being split into fixed workspaces. It sounds strange but works surprisingly well once you get used to it.

The flake structure

Everything lives in ~/nixos:

flake.nix
configuration.nix
home.nix
hardware-configuration.nix
scripts/
  alacritty-scratchpad.sh
  vicinae/
    open-nixos-config.sh

The flake.nix wires everything together:

flake.nix
{
  description = "NixOS config";
 
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    home-manager = {
      url = "github:nix-community/home-manager/master";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    niri = {
      url = "github:sodiboo/niri-flake";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    vicinae.url = "github:vicinaehq/vicinae";
    noctalia = {
      url = "github:noctalia-dev/noctalia-shell";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    nix-colors.url = "github:misterio77/nix-colors";
  };
 
  outputs = inputs@{ self, nixpkgs, home-manager, niri, vicinae, noctalia, nix-colors, ... }: {
    nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      specialArgs = { inherit inputs; };
      modules = [
        ./configuration.nix
        home-manager.nixosModules.home-manager
        {
          home-manager.useGlobalPkgs = true;
          home-manager.useUserPackages = true;
          home-manager.users.YOUR_USER = import ./home.nix;
          home-manager.extraSpecialArgs = { inherit inputs; };
          home-manager.sharedModules = [
            niri.homeModules.niri
            vicinae.homeManagerModules.default
            noctalia.homeModules.default
            nix-colors.homeManagerModules.default
          ];
        }
      ];
    };
  };
}

Flakes and Git

Flakes only see files tracked by Git. If you add a new file and wonder why the build can’t find it, git add is almost always the answer.

System configuration

configuration.nix handles system-level things: NVIDIA drivers, Docker, portals, PipeWire.

configuration.nix
hardware.graphics = {
  enable = true;
  enable32Bit = true;
};
 
services.xserver.videoDrivers = [ "nvidia" ];
 
hardware.nvidia = {
  modesetting.enable = true;
  open = false;
  nvidiaSettings = true;
  package = config.boot.kernelPackages.nvidiaPackages.stable;
};
 
virtualisation.docker.enable = true;
users.users.YOUR_USER.extraGroups = [ "docker" ];
 
security.rtkit.enable = true;
services.pipewire = {
  enable = true;
  alsa.enable = true;
  alsa.support32Bit = true;
  pulse.enable = true;
};
 
xdg.portal = {
  enable = true;
  xdgOpenUsePortal = true;
  config.common.default = "*";
  extraPortals = [ pkgs.xdg-desktop-portal-gtk ];
};

XDG portal

Without the portal setup, apps like Slack can’t open links in the browser and screenshot tools won’t work. The GTK portal works well outside of GNOME sessions, but avoid the GNOME portal unless you’re running a full GNOME session.

Niri configuration

Niri is configured through Home Manager. The sodiboo flake exposes a home module that handles the package and config file generation:

home.nix
programs.niri = {
  enable = true;
  settings = {
    prefer-no-csd = true;
    input.keyboard.xkb = {
      layout = "us";
      variant = "intl";
    };
    layout = {
      gaps = 4;
      focus-ring = {
        enable = true;
        width = 2;
        active.color = "#${config.colorScheme.palette.base0D}ff";
        inactive.color = "#${config.colorScheme.palette.base03}ff";
      };
    };
  };
};

Color scheme with nix-colors

Instead of hardcoding hex values everywhere, I use nix-colors to apply a consistent color scheme across all apps. Pick a scheme once, apply it everywhere.

home.nix
imports = [ inputs.nix-colors.homeManagerModules.default ];
colorScheme = inputs.nix-colors.colorSchemes.dracula;

Then reference colors anywhere:

active.color = "#${config.colorScheme.palette.base0D}ff";

This works for Alacritty, Niri’s focus ring, tab indicators — anything that accepts hex colors.

Alacritty colors

Here’s an example applying the scheme to Alacritty:

home.nix
programs.alacritty.settings.colors = {
  primary = {
    background = "#${config.colorScheme.palette.base00}";
    foreground = "#${config.colorScheme.palette.base05}";
  };
  normal = {
    black   = "#${config.colorScheme.palette.base00}";
    red     = "#${config.colorScheme.palette.base08}";
    green   = "#${config.colorScheme.palette.base0B}";
    yellow  = "#${config.colorScheme.palette.base0A}";
    blue    = "#${config.colorScheme.palette.base0D}";
    magenta = "#${config.colorScheme.palette.base0E}";
    cyan    = "#${config.colorScheme.palette.base0C}";
    white   = "#${config.colorScheme.palette.base05}";
  };
};

Screenshot

The native Niri screenshot action works but requires the XDG portal to be running.

The bind:

"Print".action.screenshot = {};

Scratchpad terminal

Niri doesn’t have a built-in scratchpad like i3, but you can implement one with a shell script and Nirius. Press Super+S to toggle a floating Alacritty terminal. If it doesn’t exist yet, it creates one. If it’s visible, it hides it to a named workspace. If it’s hidden, it brings it back.

The key insight is using a named workspace (scratchpad-hidden) as a holding area and tracking the window by app-id via niri msg -j windows and jq.

The bind:

"Mod+S".action = spawn "/home/YOUR_USER/.local/bin/alacritty-scratchpad";
alacritty-scratchpad
#!/usr/bin/env sh
set -eu
 
NIRIUS=$(which nirius)
NIRIUSD=$(which niriusd)
APP_RE='^scratchpad-alacritty$'
APP_ID='scratchpad-alacritty'
 
# Scratchpad commands require the daemon.
if ! pgrep -x niriusd >/dev/null 2>&1; then
  "$NIRIUSD" >/dev/null 2>&1 &
  sleep 0.1
fi
 
center_if_scratchpad_focused() {
  if niri msg -j focused-window 2>/dev/null | rg -q "\"app_id\"\\s*:\\s*\"$APP_ID\""; then
    niri msg action center-window >/dev/null 2>&1 || true
  fi
}
 
# Toggle: if scratchpad window exists, this shows or hides it.
if "$NIRIUS" scratchpad-show -a "$APP_RE" >/dev/null 2>&1; then
  center_if_scratchpad_focused
  exit 0
fi
 
# First run: create the terminal with normal Alacritty decorations.
alacritty --class scratchpad-alacritty --title Scratchpad &
 
i=0
while [ "$i" -lt 60 ]; do
  sleep 0.05
  if "$NIRIUS" focus -a "$APP_RE" >/dev/null 2>&1; then
    "$NIRIUS" scratchpad-toggle -a "$APP_RE" >/dev/null 2>&1 || true
    "$NIRIUS" scratchpad-show -a "$APP_RE" >/dev/null 2>&1 || true
    center_if_scratchpad_focused
    exit 0
  fi
  i=$((i + 1))
done
 
exit 1

With this window rule:

{
  matches = [{ app-id = "^scratchpad-alacritty$"; }];
  open-floating = true;
  default-column-width.proportion = 0.60;
  default-window-height.proportion = 0.50;
}

Absolute paths

The Niri spawn path doesn’t inherit your shell’s PATH, so always use absolute paths for scripts called from binds.

Vicinae script commands

Vicinae is the launcher I use. It supports script commands: shell scripts with a few metadata directives that get indexed and show up in the search.

The catch on NixOS is that Home Manager creates symlinks into the Nix store, and Vicinae doesn’t follow them when scanning script directories. The fix is mkOutOfStoreSymlink:

home.file.".local/share/vicinae/scripts".source =
  config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/nixos/scripts/vicinae";

Scripts in that folder just need the right directives:

#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Open NixOS config
# @vicinae.mode silent
 
subl ~/nixos

Reloading scripts

After adding scripts, search for “Reload Script Directories” inside Vicinae to pick them up without restarting.

Binary caches

Niri, Noctalia, and Vicinae aren’t in the main nixpkgs cache, so without binary caches you’ll be compiling them from source. Add their Cachix caches to avoid that:

configuration.nix
nix.settings = {
  substituters = [
    "https://cache.nixos.org"
    "https://niri.cachix.org"
    "https://vicinae.cachix.org"
  ];
  trusted-public-keys = [
    "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
    "niri.cachix.org-1:Wv0OmO7PsuocRKzfDoJ3mulSl7Z6oezYhGhR+3W2964="
    "vicinae.cachix.org-1:1kDrfienkGHPYbkpNj1mWTr7Fm1+zcenzgTizIcI3oc="
  ];
};

Final thoughts

The payoff is worth the learning curve. My entire desktop — packages, keybinds, colors, SSH hosts, git config — lives in a handful of files. Rolling back a bad change is one command. Setting up a new machine is a git clone and a nixos-rebuild switch.