julian@hub:~/blog$ cat declarative-fleet.md

Building a Declarative Multi-Host NixOS Fleet.

Managing more than one Linux machine usually turns into a sync nightmare. You tweak a config on your desktop, forget to copy it to your laptop, and suddenly half your keybinds and scripts are out of date.

In my setup, I manage my desktop workstation (junixos) and laptop (junixbook) from a single Git repository using Nix Flakes. Here is how the structure works in practice—from custom option namespaces to zero-friction secrets and automated USB backups.

1. Auto-Importing Overlays and Flake Structure

Instead of manually listing dozens of overlay files in flake.nix, I wrote a simple helper that reads the modules/overlays/ directory and imports any .nix file it finds:

# flake.nix snippet
{
  outputs = { self, nixpkgs, ... }@inputs:
    let
      loadOverlays = dir:
        if builtins.pathExists dir then
          let
            files = builtins.attrNames (builtins.readDir dir);
            nixFiles = builtins.filter (name: builtins.match ".*\\.nix" name != null) files;
          in
          map (name: import (dir + "/${name}")) nixFiles
        else [ ];

      myOverlays = loadOverlays ./modules/overlays;
    in {
      nixosConfigurations."junixos" = nixpkgs.lib.nixosSystem {
        specialArgs = { inherit inputs; };
        modules = [
          { nixpkgs.overlays = myOverlays; }
          inputs.disko.nixosModules.disko
          ./hosts/junixos/disk-config.nix
          ./hosts/junixos/configuration.nix
          inputs.sops-nix.nixosModules.sops
          inputs.home-manager.nixosModules.default
          inputs.nixvim.nixosModules.nixvim
        ];
      };
    };
}

2. The custom.* Namespace Approach

One trap in NixOS is writing raw configuration blocks everywhere. When you configure services directly in host files, things quickly become messy.

Instead, I define reusable modules under a custom.* hierarchy. A host configuration ends up looking clean and intentional:

# hosts/junixos/configuration.nix
{
  custom = {
    system = {
      kernel = "cachyos";
      boot.silent.enable = true;
      android.enable     = true;
    };

    desktop = {
      hyprland.enable    = true;
      gaming = {
        enable           = true;
        isolated.enable  = false;
      };
    };

    services = {
      backup = {
        enable            = true;
        deviceUuid        = "9e2b10a4-3a21-4f12-88ec-123456789abc";
        autoTriggerOnPlug = true;
      };
      zapret.enable         = true;
    };
  };
}

3. Secrets Without GPG Headaches (sops-nix + SSH Keys)

I didn't want to deal with GPG keys or manual password manager exports when bootstrapping a fresh machine.

Using sops-nix, the system converts the machine's existing SSH host key (/etc/ssh/ssh_host_ed25519_key) into an age identity. All encrypted secret YAML files live in secrets/ inside the repo, and Nix decrypts them automatically at runtime:

# modules/nixos/sops.nix
{ config, pkgs, ... }:
let
  secretsDir = ../../secrets;
  hostSecretsFile = secretsDir + "/${config.networking.hostName}.yaml";
  commonSecretsFile = secretsDir + "/common.yaml";
in {
  sops = {
    defaultSopsFormat = "yaml";
    age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];

    defaultSopsFile =
      if builtins.pathExists hostSecretsFile then hostSecretsFile
      else if builtins.pathExists commonSecretsFile then commonSecretsFile
      else null;
  };

  environment.systemPackages = with pkgs; [ sops age ssh-to-age ];
}

4. Automated BorgBackups on USB Drive Insertion

Manual backups get forgotten. To fix this, I set up a BorgBackup service that triggers via a udev rule whenever I plug in my external backup drive:

# modules/services/backup.nix
{ config, lib, ... }:
let
  cfg = config.custom.services.backup;
in {
  config = lib.mkIf cfg.enable {
    # Automount disk on demand with a 15s spin-up timeout
    fileSystems."${cfg.mountPoint}" = {
      device = "/dev/disk/by-uuid/${cfg.deviceUuid}";
      fsType = "ext4";
      options = [ "noauto" "nofail" "x-systemd.automount" "x-systemd.device-timeout=15s" ];
    };

    # Udev rule: triggers systemd backup service as soon as the drive is plugged in
    services.udev.extraRules = lib.mkIf cfg.autoTriggerOnPlug ''
      ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}=="${cfg.deviceUuid}", TAG+="systemd", ENV{SYSTEMD_WANTS}+="borgbackup-job-systemBackup.service"
    '';

    services.borgbackup.jobs.systemBackup = {
      paths = [ "/home/${config.custom.user}" "/etc/ssh" "/var/lib/sops-nix" ];
      repo = "${cfg.mountPoint}/${cfg.repoName}";
      encryption.mode = "repokey-blake2";
      encryption.passCommand = "cat ${config.sops.secrets.borg_passphrase.path}";
      compression = "auto,zstd,3";
    };
  };
}

Whenever I plug in the drive, systemd automounts the partition, runs the encrypted incremental backup, and unmounts it when finished.

Summary

Having a unified multi-host flake means I can spin up a fresh machine or sync changes across hardware in a few minutes without guessing what packages were installed.

The complete configuration, Neovim modules, and Hyprland setup are available on GitHub in my NixOS-Dotfiles repository.