- Elm 63.6%
- Haskell 25.2%
- Nix 7.5%
- TypeScript 1.6%
- Kotlin 0.5%
- Other 1.4%
| .forgejo/workflows | ||
| lib | ||
| modules | ||
| packages | ||
| parts | ||
| profiles/user0 | ||
| secrets | ||
| systems | ||
| templates | ||
| .envrc | ||
| .gitignore | ||
| .sops.yaml | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
| schema.mermaid | ||
Nick's NixOS Dotfiles
A single flake that builds every machine I own: two desktops, two laptops, a server, a NAS, an installer ISO, the microVM guests that run my self-hosted services, and the first-party applications those guests serve.
It is a flake-parts repository. Host configurations, Home Manager profiles, NixOS modules, Haskell and Elm packages, deployment nodes, dev shells and formatter hooks are all outputs of the same evaluation, so a change to a shared record propagates to every consumer at build time instead of drifting.
- Structure: flake-parts, with
import-treediscovering modules instead of hand-written aggregator files - Deployment:
deploy-rsover SSH as root, key only - Isolation:
microvm.nixguests, one service per guest, declared through a localhomelab.microvmsabstraction - Secrets:
sops-nixwith age - Nix implementation: Lix
- CI: Forgejo Actions, self-hosted runner, five hosts built per run
Hosts
Naming follows a celestial convention: desktops are planets, laptops are moons, servers are dwarf planets.
| Host | Role | Notable |
|---|---|---|
mars |
Main desktop | Hyprland, Niri and Plasma, ollama, local PostgreSQL, dev instances of both web apps |
deimos |
Laptop | Hyprland, full GUI profile |
phobos |
Secondary laptop | Minimal, CLI only, microVM host |
ceres |
Primary server | Caddy, ACME, Forgejo plus runner, Jellyfin, Mastodon, Minecraft, torrenting stack, the Grafana observability stack, production web apps |
eris |
NAS and secondary server | NFS exports, Vaultwarden, PhotoPrism, WireGuard, Restic |
iso |
Installer image | Bootstrapping only |
Each host is systems/<name>/, which is a two-line entry point plus a config/ directory
of boot, filesystem, hardware, networking, graphics, sops and WireGuard fragments that
import-tree picks up automatically.
Layout
flake.nix Inputs, nixosConfigurations, templates
lib/ Config records, helper functions, deploy-rs nodes
modules/nixos/ System modules, grouped into named bundles
modules/home/ Home Manager modules, one directory per program
profiles/user0/ The user account, its home, dotfiles and scripts
systems/ Per-host hardware and machine-specific config
packages/ First-party applications and tools
parts/ Dev shell, pre-commit hooks, treefmt, flake checks
secrets/ sops-encrypted secrets
templates/ `nix flake init -t` starters for new projects
lib/
Three things live here.
lib/options/ is the heart of the repo: a typed schema for the facts that more than one
module needs to agree on. Four record trees are declared as NixOS options and populated from
data files:
machines.devicesper host: hostnames, LAN addresses, WireGuard keys and IPs, boot and storage mount points with their mount optionsservices.instancesper service: domains, subdomains, ports, ACME cert paths, state and cache paths, and the microVM interface details (MAC, tap discriminator, guest IP, SSH port)people.users: names, labels, SSH keys, email addresses, groupaesthetics.themes: the active palette, fonts and sizes, cursor, window manager gaps, borders and rounding
Because these are options rather than plain attribute sets, a typo in a service record fails
evaluation instead of silently producing an empty string somewhere in a Caddy vhost. The raw
values feeding them live in lib/records.nix.
lib/helpers/ exposes mkLinuxSystem and mkHome, thin wrappers over nixosSystem
and homeManagerConfiguration that fix allowUnfree and thread inputs, self and the
flake-level config through as specialArgs. That last part is why every module in the
tree can write flake.config.services.instances.jellyfin and get the same record.
lib/deploy/ maps the same host IP records into deploy-rs nodes, with activation and
confirmation timeouts raised well past the defaults because microvm@ units are
Type=notify and a host with several guests takes minutes to finish activating.
modules/nixos/
Modules are discovered by directory, then composed into named bundles in
modules/nixos/default.nix:
coregoes on every host: accounts, environment, fonts, Home Manager wiring, locale, nh, nix settings, rsync, SSH, sudo, system statsmantleadds sops and the X servercrustadds desktop hardware and program modules- one bundle per host name, listing exactly what that machine runs
Under homelab/ sit the service definitions:
guests/is one directory per service, each split into a sharedconfig/that describes the guest itself and a per-host wrapper (jellyfinCeres,vaultwardenEris, and so on) that places it on a machine. Roughly two dozen guests: Forgejo, Jellyfin, Mastodon, Minecraft, Vaultwarden, PhotoPrism, Syncthing, OpenCloud, Firefly III, Open WebUI, the torrent stack, Grafana, Loki, Mimir, Tempo, and the dev and prod instances of the web apps.orphans/holds services that run on the host rather than in a guest: ComfyUI, PostgreSQL, Glance, SearXNG, PeerTube. Some are wired into a host, some are parked.caddy/is the reverse proxy, with one config directory per vhost. It refuses to start if it would serve a domain whose ACME certificate has never actually been issued, since the placeholder in that case is a self-signed minica cert.restic/,nfs/,alloyCeres/alloyEris,nasDirs,forgejoRunneranduserConfigs/cover backups, exports, telemetry shipping, NAS directory layout, CI and shared service accounts.
microvm/ is the local guest abstraction. homelab.microvms.<name> takes an IP, a MAC,
resources and a guest module, and emits the microvm.vms entry, the systemd-networkd unit
matched by MAC, the tap interface, the virtiofs shares for state and secrets, and the
tmpfiles rules behind them. Tap names are derived from the attribute name, so two guests
cannot collide, and names past the kernel's 15-character interface limit fold to a hash
rather than truncating into a collision. An assertion catches duplicates at evaluation time.
The same module ships microvm-sync, which restarts exactly the guests whose booted and
current symlinks have diverged. A host rebuild moves those symlinks but does not restart
the VMs, so without it a memory or CPU change looks applied while the running guest still
holds its old allocation.
modules/home/
Home Manager modules, one directory per program, grouped as cli/ (dev tooling, file
utilities, shell, general utilities) and gui/ (browsers, editors, crypto, terminals,
gaming, media, messaging, file sharing, tools). modules/home/default.nix composes them into
one profile per host and user pair, keyed off the same people and machine records, so mars
gets the full desktop set and ceres gets cli only.
The shell is Nushell with Starship, the editor set is Helix and Zed, and theming is driven
from aesthetics.themes so palettes stay consistent across the terminal, window manager and
applications.
profiles/user0/
The user account itself: declarative users with mutableUsers = false and a sops-provided
password hash, group membership, home directory structure via systemd.tmpfiles, helper
scripts, and a generated justfile giving every machine local and remote rebuild recipes,
SSH shortcuts and a vms-<host> recipe that calls microvm-sync remotely.
packages/
First-party code, exposed as flake packages with a matching dev shell each:
| Package | What it is |
|---|---|
uproot-backend |
upRootNutrition backend, Haskell |
uproot-frontend |
upRootNutrition frontend, Elm, built with mkElmDerivation |
uproot-android |
Capacitor and Gradle orchestrator for the upRootNutrition Android app, nix run .#build-android -- [release|debug|compile-only] |
haskeww |
Haskell library and scripts backing the status bar: audio, brightness, battery, GPU, time, weather, workspaces |
btrfs |
btrfs-cleaner, snapshot pruning |
resume |
Typst résumé, two variants, built to PDF |
The Android packages pin their own nixpkgs instance with android_sdk.accept_license,
since the repo-wide allowUnfree only covers NixOS and Home Manager evaluation.
Haskell packages use committed cabal2nix output rather than callCabal2nix, so nothing in
the tree needs import-from-derivation. Regenerate the sibling <pkg>.nix when .cabal
dependencies change.
templates/
nix flake init -t starters, each with a flake, a dev shell, a .envrc and a formatter
config:
nix flake init -t github:<this-repo>#haskell # Haskell with cabal and HLS
nix flake init -t github:<this-repo>#elm # Elm Land frontend with a Haskell backend
nix flake init -t github:<this-repo>#typst # Typst documents
Development
direnv allow drops you into the default shell, which carries sops, age, ssh-to-age,
deploy-rs, nixfmt, nil, just and the rest of the day-to-day tooling, and installs the
pre-commit hooks on entry.
Two formatting layers, deliberately kept in agreement:
- pre-commit runs
nixfmton staged Nix files andcommitizenon the commit message, so commits follow Conventional Commits. - treefmt (
nix fmt) covers the whole tree: nixfmt, deadnix, statix, ormolu, hlint, cabal-fmt, elm-format, rustfmt, taplo, typstyle, yamlfmt, mdformat. Itsnix flake checkgate is off, because the tree is not yet treefmt-clean across every language.nixfmtwidth is pinned to 100 in both layers so the two never fight.
Per-project shells are available for each package, for example nix develop .#uproot-backend
or nix develop .#uproot-frontend.
Secrets
Everything sensitive lives in secrets/secrets.yaml, encrypted with age through sops-nix
and decrypted to /run/secrets at activation. .sops.yaml holds the recipient list. Guests
receive secrets through a read-only virtiofs share from the host rather than holding their
own keys.
Note that evaluating this flake requires SSH access to a private forge, because one input is
an out-of-tree project flake. Both the workstation and the CI runner get the host key from
programs.ssh.knownHosts in modules/nixos/core/ssh.
Building and deploying
# Local host
nixos-rebuild switch --sudo --flake .#mars
# Remote host, over deploy-rs
deploy .#ceres
# Remote host, over nixos-rebuild
nixos-rebuild switch --flake .#ceres --target-host 192.168.50.240 --sudo --ask-sudo-password
# Restart only the guests whose configuration actually changed
ssh -t ceres sudo microvm-sync
# Build without switching
nix build .#nixosConfigurations.ceres.config.system.build.toplevel
# Evaluate everything
nix flake check
The generated justfile on each machine wraps most of these: just rebuild,
just rebuild-<host>, just <host> for SSH, just vms-<host> for the guest sync.
CI
.forgejo/workflows/ci.yml runs on a self-hosted runner. A cheap nix flake check --no-build
gates an expensive matrix that builds all five host toplevels, so a flake that does not even
evaluate never burns five serial builds. Runs supersede each other per ref, since Nix keeps
whatever a cancelled run already realised.
The main branch is ignored on push by design: this repo is the development fork and pushes
to main are constant, while upstream only ever sees already-tested merges. The real gate is
the pull request.
Credits
Not everything here is original. The material below belongs to other people, is used with attribution, and carries its own terms rather than the repository's.
| What | Where | Whose |
|---|---|---|
haskeww, the EWW status bar library and scripts |
packages/haskeww/ |
Originates from the AskYourself project, AGPL-3.0-or-later, same as this repository |
| Many of the SVG icons in the Elm frontends | src/Config/Style/Icons/ in each Elm package, and templates/elm/ |
Font Awesome Free, Fonticons Inc., CC BY 4.0 |
| Notification sounds | packages/upRootNutrition/uproot-frontend/static/alerts/ |
akx/Notifications, Aarni Koskela and contributors, CC BY 4.0 |
| OBS Studio themes | modules/home/gui/apps/media/video/obsStudio/themes/*.qss |
Philippe Groarke, GPL-2.0-or-later |
| OBS Studio and Thunderbird themes | .../obsStudio/themes/Catppuccin/, .../thunderbird/themes/*.xpi |
Catppuccin contributors, MIT |
Acknowledgements
The upRootNutrition branding, including Enaychi, and the compatIQ branding are the work
of ardlak on Discord (user ID 91007151513825280).
Licence
AGPL-3.0-or-later for everything original to this repository. AGPL over GPL deliberately: both web applications are hosted services, and §13 is what keeps a modified hosted copy from being a private fork. The GPL-2.0-or-later OBS themes credited above are why the project licence has to stay GPL-compatible.
Two paths are present in the tree but are not granted under the AGPL, because they are not mine to relicense. Strip them before redistributing, or satisfy yourself independently that you hold the rights:
| Path | What it is |
|---|---|
modules/home/gui/apps/browsers/firefox/config/search/config/icons/ |
Search-engine logos and trademarks, reproduced to identify the engines they belong to — the same nominative use a browser's own search settings make of them |
packages/resume/ |
Personal résumé content and contact details |
"upRootNutrition", as well as its logos and wordmarks, are not licensed by the AGPL grant either. The licence covers the software; it does not grant permission to run a service under these names or to present a fork as the original.