2026·04·20 8 min read #nixos #flakes #home-manager #sops #dotfiles #declarative

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:

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:

nix
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:

nix
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:

Host configs then read:

nix
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

plaintext
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:

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:

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

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:

nix
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:

plaintext
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:

nix
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:

make
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:

What I’d do next

I haven’t yet:

Tour the source

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.