A modular NixOS flake for four hosts: one codebase, one Justfile, no drift
How I manage four NixOS machines (one server, three workstations) from a single flake: profiles, modular composition, sops-nix secrets, hot-reload dotfiles, and a Justfile that hides the rough edges.
I grew up on a street where Linux user groups and rap jams met in the same courtyards - breakdance circles spilling into the nights, a Senao PCMCIA card passed around like an idol, wardriving gear-of-choice, paired with a Pringles-can cantenna or, on a good night, a big grid antenna lifted off one of Etna’s high-ground repeaters. I have been running Linux since Slackware 7 - back when Patrick Volkerding got sick with something no doctor could name and wrote to the community that he thought he was going to die. I spent days building Gentoo from source. Tried every flavor of BSD. Went deep into ARM distros - more of them than I care to count - and started a long, lifelong relationship with Yocto. For years I settled down with Arch, thinking that was my thing.
Until a former colleague named Pedro challenged me with NixOS. That changed me.
Now I run four NixOS machines:
- karaburan - headless home server (Docker, AI services, Ollama). Running on a 1U HP ProLiant DL360p Gen 8; I sawed the chassis open so the PCIe riser and power could route out to a full-size external GPU. Currently swapping that card for a Tesla P40 - Sunshine game-streaming role is parked until the new GPU lands.
- ghibli - primary desktop workstation (Nvidia, Hyprland, gaming, local LLM)
- gremo - secondary workstation (Hyprland, gaming, Nvidia)
- grigio - work laptop (office VPN, embedded BSP toolchain, Intune compliance)
Different hardware, different jobs, different installed software. One shared git repo. Zero config drift. I rebuild any of them with just up <hostname>.
The source is public: bomba5/nixos-public. This post is the tour.
The shape of the problem
Any NixOS user who runs more than one machine eventually hits the same choice: one flake per machine (easy to start, diverges over time), or one flake for all machines (painful to start, stays consistent forever).
I went for the second. The cost was mostly upfront: figuring out how to share code between a headless server and a gaming workstation without either one accumulating cruft the other doesn’t need. Seven design moves made it worth it.
The overall shape looks like this:
flowchart TD
A[flake.nix] --> B{Host Config
hosts/*/configuration.nix}
B --> C[Profiles
modules/profiles/base.nix + graphical.nix]
C --> D[Modules
desktop, services, network, etc.]
D --> E[Home Manager
per-user config]
D --> F[System Services
NixOS modules]
E --> G[Dotfiles
modules.system.dotfilesPath]
D --> H[SOPS Secrets
secrets/secrets.yaml]Move 1: a tiny flake.nix that delegates everything
The flake itself is 44 lines. It does one thing - it declares four nixosConfigurations and passes each one through a helper function:
mkHost =
hostname:
nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./hosts/${hostname}/configuration.nix
home-manager.nixosModules.home-manager
sops-nix.nixosModules.sops
];
specialArgs = {
unstable = unstable.legacyPackages.x86_64-linux;
flake-self = self;
};
};Every host enters the system through its own hosts/<hostname>/configuration.nix. That file is the only place where per-host decisions live: hardware profile, imported modules, user-specific overrides. Everything else is reusable.
Inputs: nixpkgs 25.11, home-manager/release-25.11, nixpkgs-unstable (for cherry-picked packages that aren’t in stable yet), and sops-nix for secrets. Unstable is passed as specialArgs.unstable so any module can pkgs.unstable.foo when it needs to.
Move 2: profiles aggregate modules; hosts import profiles
The anti-pattern I wanted to avoid is a host config that looks like this:
imports = [
../../modules/network.nix
../../modules/shell.nix
../../modules/editors/neovim.nix
../../modules/editors/vim.nix
../../modules/editors/emacs.nix
# ...30 more lines
];That scales badly. Instead I have two profiles:
modules/profiles/base.nix- everything common to all four hosts: shell, editors, network basics, sops, home-manager scaffolding.modules/profiles/graphical.nix- everything common to the three workstations: Wayland stack, Hyprland, fonts, audio, graphics.
Host configs then read:
imports = [
./hardware-configuration.nix
./extra-packages.nix
./ssh-keys.nix
../../modules/profiles/base.nix
../../modules/profiles/graphical.nix # only on desktops
# host-specific modules come below
../../modules/network/home-lan.nix
../../modules/vpn/office-wireguard.nix
../../modules/development/embedded-bsp.nix
];The karaburan host config drops the graphical.nix line and picks up docker, ai/comfyui, ai/open-webui instead. That’s the whole delta for turning a desktop config into a headless server.
Move 3: 15 modules, one responsibility per directory
modules/
├── ai/ # Ollama, ComfyUI, Open WebUI, stable-diffusion
├── audio/ # PipeWire + realtime
├── crypto/ # GPG agent, keepass
├── desktop/ # Hyprland, Waybar, Mako, Wofi, Sway (alt)
├── development/ # embedded-bsp (C/C++/Python toolchain + NFS netboot), Segger J-Link
├── editors/ # Neovim, Vim, Doom Emacs
├── games/ # Steam, gamescope, Minecraft
├── logging/ # journald tuning, remote log forwarding
├── network/ # home-LAN defaults, generic NetworkManager, waypipe
├── profiles/ # base + graphical aggregators
├── services/ # Docker, Sunshine, miscellaneous systemd services
├── shell/ # zsh + plugins, starship, completions
├── virtualisation/ # virt-manager, qemu/libvirt
└── vpn/ # WireGuard home + office (sops-wired; stubs in the public repo)Each directory is exactly what it says on the tin. If a module outgrows a single file, it gets a subdirectory. If a module only applies to some hosts (e.g., ai/ollama.nix), the host that wants it imports it directly; the profile doesn’t impose it.
Profiles and modules compose roughly like this:
graph LR Base[profiles/base] --> Core[core.nix] Base --> Shells[shell/zsh + shell/tmux] Base --> Editors[editors/neovim] Base --> Services[services/ssh + logging/rsyslog] Base --> Docker[virtualisation/docker] Base --> Crypto[crypto/gpg] Graphical[profiles/graphical] --> Base Graphical --> Desktop[desktop/core + hyprland + nvidia] Graphical --> Audio[audio/audio] Graphical --> Avahi[services/avahi] Graphical --> BT[services/bluetooth]
Move 4: custom system options for portability
One annoying thing about NixOS configs online is that they hard-code the username. “bomba” appears in four places, and copying someone else’s config means find-and-replacing before it even builds.
I lifted that into custom options in modules/core.nix:
options.modules.system.mainUser = mkOption {
type = types.str;
default = "bomba";
description = "Primary username.";
};
options.modules.system.dotfilesPath = mkOption {
type = types.path;
default = "/etc/nixos";
description = "Path to the git repository on the target host.";
};Home Manager modules then look like:
{ config, ... }:
let cfg = config.modules.system;
in {
home-manager.users.${cfg.mainUser} = { ... };
}If someone else forks this, they set modules.system.mainUser = "theirname"; once in their host config. Done.
Move 5: dotfiles are symlinks back into the repo (hot-reload)
The classic “write my dotfiles in Nix” approach rebuilds the system every time I tweak my Neovim config. That’s a 30-second loop for a one-line change. Unacceptable.
The trick is config.lib.file.mkOutOfStoreSymlink:
home.file.".config/nvim".source =
config.lib.file.mkOutOfStoreSymlink
"${osConfig.modules.system.dotfilesPath}/dotfiles/nvim";Home Manager puts .config/nvim into ~, but instead of copying into the immutable Nix store, it symlinks to /etc/nixos/dotfiles/nvim - the actual git checkout. Edit a file there, git commit, changes are live instantly. No rebuild.
The dotfiles tree in the repo:
dotfiles/
├── desktop/
│ ├── hypr/ ├── kitty/ ├── mako/
│ ├── waybar/ ├── wofi/ ├── yazi/
│ ├── splash/ └── wallpapers/
├── doom/
├── nvim/
├── vim/
├── zsh/
├── sccache/
└── sunshine/Everything in dotfiles/ is a normal git-tracked config for the program. No Nix templating, no generated files. The Nix layer only does the symlinking.
Move 6: secrets via sops-nix, not plaintext anywhere
SSH keys, Git credentials, GPG keys, Wazuh agent keys - all encrypted with my age key and committed to the repo:
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
sops.defaultSopsFile = ../../secrets/secrets.yaml;
sops.secrets.git_config = { };
sops.secrets.ssh_host_ed25519 = {
path = "/etc/ssh/ssh_host_ed25519_key";
owner = "root";
mode = "0600";
};The secrets file is encrypted at rest. At activation time, sops-nix decrypts and places each secret at the target path with the right owner and mode. Nothing ever lands on disk unencrypted outside of /run/secrets (a tmpfs).
Put together, a nixos-rebuild switch does this dance in one pass:
sequenceDiagram participant Repo as /etc/nixos participant Sops as secrets/secrets.yaml participant HM as Home Manager participant NixOS as System Modules Repo->>NixOS: nixos-rebuild switch --flake .#NixOS->>HM: Pass modules.system.* + dotfilesPath HM->>Repo: mkOutOfStoreSymlink dotfiles/* Sops-->>NixOS: Decrypt secrets during build Sops-->>HM: Link secrets into user config HM-->>User: Ready-to-use desktop/shell/apps
Move 7: a Justfile that hides the verbose commands
Every nixosrc tutorial uses sudo nixos-rebuild switch --flake .#hostname. That’s 40 characters I never want to type again. The Justfile:
build HOSTNAME:
nixos-rebuild build --flake .#{{HOSTNAME}}
switch HOSTNAME:
sudo nixos-rebuild switch --flake .#{{HOSTNAME}}
up HOSTNAME:
sudo nixos-rebuild switch --upgrade --flake .#{{HOSTNAME}}
update:
nix flake update
edit-secrets:
sops secrets/secrets.yaml
format:
nixpkgs-fmt .
just up karaburan - pull latest from nixpkgs, rebuild, switch. just edit-secrets - open the encrypted file in $EDITOR with sops doing the transparent decrypt/re-encrypt.
What it looks like in practice
Full numbers on the current repo:
- 4 hosts across a home server, one gaming desktop, one personal desktop, and one work laptop.
- 15 module categories, ~40 individual
.nixfiles, ~2,900 lines total. - One flake update per ~2 weeks, applied across all four hosts in under an hour.
- Zero config drift between the three workstations. When I add Steam to ghibli, it’s one line added to a module imported by all three - gremo gets it next rebuild.
What I’d do next
I haven’t yet:
- Added
treefmt-nix+statix+deadnixto the flake’schecksoutput. That’d catch formatting + dead code + anti-patterns on everynix flake check. On the list. - Extracted Home Manager into a separate flake output. Right now it’s nested inside the NixOS system module. Works, but I’d like HM to build standalone on non-NixOS targets eventually.
- Explored
flake-partsorflake-utils-plusto reduce the boilerplate. The current flake is fine but it’s hand-rolled.
Tour the source
- Repo: github.com/bomba5/nixos-public
- Architecture doc (mermaid diagrams, module details): docs/architecture.md
- Justfile: Justfile
- License: public domain / informal. Fork, copy, vandalize.
If you’re juggling multiple NixOS machines and drifting into per-host chaos, this shape is worth cribbing. If you’re already doing something similar with flake-parts or NixCats or Home Manager standalone and think my approach is stupid, I am very much interested in why - the whole thing is a work in progress.
- bomba