Setting up NixOS with Niri, Home Manager and a declarative desktop

How I configured a declarative Wayland desktop on NixOS with Niri, Home Manager and flakes.

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 is the setup I ended up with: NixOS with flakes, Home Manager, the Niri window manager, and a Wayland-first desktop.

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:

{
  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.

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:

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. I pick a scheme once and reuse it everywhere.

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:

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";
#!/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:

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 part I care about most is that my desktop now lives in versioned files: packages, keybinds, colors, SSH hosts, and git config. A bad change is one rollback away, and a new machine starts from git clone plus nixos-rebuild switch.

Series

NixOS migration

4 posts View series
  1. 1 Setting up NixOS with Niri, Home Manager and a declarative desktop Guide · You are here
  2. 2 Modularizing your NixOS configuration Guide
  3. 3 Fixing clipboard persistence in Wayland (Niri + NixOS) Note
  4. 4 Flutter on NixOS: a devenv guide Guide

Comments

Back to top