NixOS flake for my machines. Each host is assembled from the same pieces:
global defaults, opt-in modules, the host's hardware, its users and a theme.
Dotfiles are plain files in home/ directories, layered and copied into
$HOME by hjem.
flake.nix hosts and the users on them, packages, checks
global/ applies to every host
home/ global dotfile layer
hosts/<host>/ hardware, enabled modules, specs.md (hardware inventory)
home/ host dotfile layer (monitors, keybinds)
users/<user>/ account, packages, programs
home/ user dotfile layer
modules/ opt-in NixOS modules (modules.*) and the dotfile layering
theme/
base/ theme options, program targets, the `theme` command
<name>.nix named themes
overlays/ changes to nixpkgs packages
pkgs/ packages defined here (pkgs/<name>/package.nix)
templates/ `nix flake init -t` templates
Everything under global/, modules/, hosts/<host>/ and users/<user>/
is imported automatically, so a new .nix file there is picked up without
being listed anywhere. home/ directories are skipped.
flake.nix lists the hosts and which users live on them:
nixosConfigurations = lib.mapAttrs mkHost {
desktop.users = ["lex"];
laptop.users = ["lex"];
};A host turns modules on in hosts/<host>/default.nix
(modules.desktop.hyprland.enable, modules.theme.default, ...). A user can do
the same for things that follow the person rather than the machine, such as
modules.software.gaming.enable in users/lex/default.nix.
A file at global/home/.config/kitty/kitty.conf ends up at
~/.config/kitty/kitty.conf. The same relative path can exist in three
layers, merged from least to most specific:
generated (dotfiles.files) -> global/home -> hosts/<host>/home -> users/<user>/home
- Text files (
.conf,.lua,.css,.ini, shell files, ...) are concatenated. The globalhyprland.luadefinesmainMod, the host layer adds monitors, the user layer adds autostart and binds. .jsonfiles are merged key by key: a more specific layer only needs the keys it changes (nested objects merge too, lists are replaced). A host could shiphosts/laptop/home/.config/VSCodium/User/settings.jsoncontaining just{"window.zoomLevel": 1}..jsonc,.yaml,.yml,.toml,.qml,.qmldirand.svgcan't be combined, so the most specific layer wins.- Images and fonts are copied unchanged.
Files are copied rather than symlinked, so programs can write to them. Every rebuild resets them to what the repo says; files that support comments get a first line saying so. Port changes you want to keep back into the repo.
Modules can generate files too (dotfiles.files, e.g. Hyprland's portal
config). Those are the lowest layer, so a home/ file at the same path
appends to them or replaces them. Theme files (kitty colors, GTK CSS, ...)
are not dotfiles: the theme command owns them, see below.
Layered files can use {{name}}, filled in at build time:
| Placeholder | Value |
|---|---|
{{qml.importPaths}} |
Quickshell's QML import paths, for qmllint |
{{pkgs.<attr>}} |
store path of a package, e.g. {{pkgs.nix-direnv}} |
{{host}}, {{flake}} |
host name, store path of this flake |
An unknown placeholder fails the build with the file name, so typos don't ship.
Modules add their own with dotfiles.vars."name" = "value";.
A named theme is a small file in theme/ that sets colors; theme/base
turns them into program configuration. See docs/themes.md.
Themes switch at runtime, without a rebuild: click the gear at the bottom of
the bar (right click goes back), or run theme next, theme set <name>,
theme list. Kitty, Hyprland borders, the bar, Firefox, VSCodium, the volume
popup and the GTK dark/light preference change right away; GTK and Qt colors
apply to apps started afterwards. The choice is kept per user across rebuilds;
modules.theme.default is the theme before anyone picks one.
dynamic takes its colors from the wallpaper. Set one with
theme wallpaper <image>; from then on dynamic shows up in the cycle, and
while it's active every new wallpaper recolors the desktop. Dark or light
follows the image.
Hyprland with a Quickshell frame around the screen. The left side of the frame is the bar, top to bottom:
| Item | Hover | Click | Scroll |
|---|---|---|---|
| Clock | date | ||
| Workspace pills (this monitor) | switch | step through | |
| Brightness (laptops) | percentage | 5% steps | |
| Volume | output and level | left: mute, right: pwvucontrol | 5% steps, up to 150% |
| Network | connection | left: nmtui, right: Wi-Fi on/off | |
| Battery (laptops), power profile icon (desktop) | level, time left, power profile | cycle power profile | |
| Gear (bottom) | current theme | left: next theme, right: previous | |
| Dot in the bottom-right corner | notes popup |
Keys beyond the usual window management (global/home/.config/hypr/hyprland.lua
and users/lex/home/.config/hypr/hyprland.lua):
| Keys | Action |
|---|---|
| volume, mute, mic mute, brightness keys | change the value and show the swayosd popup |
SUPER + N |
night light on/off; it also follows the schedule in global/home/.config/hypr/hyprsunset.conf (4500 K from 21:00 to 07:00) |
SUPER + Print |
region screenshot to the clipboard |
SUPER + SHIFT + Print |
region screenshot into satty for annotation (Enter copies) |
Every host has three power profiles (performance, balanced, power-saver),
switched by clicking the battery or profile icon in the bar. The laptop uses
TLP for them, other hosts power-profiles-daemon (global/power.nix).
On the laptop (hosts/laptop/power.nix), plugging in selects performance
with no limits and unplugging selects power-saver:
| Profile | CPU | Screen and effects |
|---|---|---|
| performance | turbo, performance EPP | 240 Hz, blur, shadows, animations |
| balanced | turbo, balance_power EPP | 144 Hz |
| power-saver | no turbo, power EPP, iGPU capped at 900 MHz | 60 Hz, no blur, shadows or animations |
On battery the ethernet controller is unbound and NVIDIA Dynamic Boost is
stopped. The refresh rate and effects come from a user service in
modules/desktop/hyprland.nix, so they apply to any host with a laptop panel.
The laptop's NVIDIA GPU stays powered off unless something asks for it
(modules.hardware.graphics.nvidia.hybrid.offloadOnly): desktop programs
only see the Intel GPU. Steam, Heroic, Lutris and Prism Launcher run on the
NVIDIA GPU; anything else can with nvidia-offload <command>. Check whether
it is powered off with
cat /sys/bus/pci/devices/0000:01:00.0/power/runtime_status (suspended).
| Need | Where |
|---|---|
| A package from nixpkgs | users/<user>/packages.nix |
| A package that needs a NixOS service, group or kernel module | modules/software/<name>.nix, enabled from a host or user |
| A tweak to a nixpkgs package (wrapper, patch, override) | overlays/<name>.nix, a plain final: prev: overlay picked up automatically |
| A package that doesn't exist in nixpkgs | pkgs/<name>/package.nix, a callPackage function exposed as pkgs.<name> |
Packages in pkgs/ are also flake outputs, so nix build .#hid-send works.
Every directory in pkgs/omp-extensions/ is an Oh My Pi extension; its
package.json supplies name and version, and users/lex/omp.nix installs and
enables all of them.
nh os switch # or: sudo nixos-rebuild switch --flake .#laptop
nix flake check # formatting, host evaluation, hyprland.lua syntax, package tests
nix fmt . # alejandra