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.
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 addis 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. Pick a scheme once, apply 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
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, create it; if it’s visible, hide it to a named workspace; if it’s hidden, bring 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 1With 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 ~/nixosReloading 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 learning curve is real! The first few days I spent more time debugging Nix evaluation errors than using my desktop. Error messages can be cryptic, and the interaction between Home Manager, flakes, and third-party modules adds surface area for things to go wrong in non-obvious ways.
But the payoff is worth it. My entire desktop including 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.