# One Nix Flake for Every Machine I Own

> Three Macs, two NixOS machines, two VPSes, a Raspberry Pi, WSL and two Android phones, all built from one flake. How the repository is laid out, how home-manager is shared down to a phone, and the bugs that only showed up on real hardware.

- Author: Maulana Sodiqin (https://msdqn.dev)
- Published: Oct 8, 2026
- Updated: Oct 8, 2026
- URL: https://blog.msdqn.dev/blog/one-nix-flake-for-every-machine
- Tags: nix, nixos, nix-darwin, home-manager, infrastructure, developer-experience

I own three Macs, two NixOS machines, two VPSes, a Raspberry Pi, a Windows machine with WSL and two Android phones. All of them are configured from one Git repository, [infra.msdqn.dev](https://github.com/maulanasdqn/infra.msdqn.dev), and one `flake.nix`. My shell, my editor, my SSH keys, my secrets and my server firewall rules are written once and built for whichever machine asks.

The repository started on December 19, 2025 as a small nix-darwin config for one MacBook. It now has over 800 commits. This post is about how it is laid out, the few decisions that made it scale past two machines, and the problems that only showed up once it was running on real hardware.

## Why one flake

The usual path is a dotfiles repo for the laptop, a different setup for the server and whatever the phone ships with. Each one drifts on its own. A fix to my zsh config on the MacBook never reaches the VPS, and the VPS has a hardened SSH config the laptop never learns about.

Nix fixes the drift if everything lives in one place. A flake pins every input in `flake.lock`, so the MacBook and the Raspberry Pi build my Neovim from the same nixpkgs commit. When I change something shared, every machine gets it on its next rebuild, and when I break something, `git revert` puts it back.

The cost is that one repository now has to describe very different machines: an Apple Silicon laptop, an x86 VPS with a static IP, an ARM board on my desk and a phone running Nix inside proot. Most of this post is about handling that difference without copying code.

## The layout

```
.
├── flake.nix
├── config.nix
├── config.local.nix
├── hosts/
│   ├── darwin/{macmini-mrscraper,macbook-mrscraper,beast}/
│   ├── workstation/{vivobook,pc}/
│   ├── vps/{hostinger,digitalocean}/
│   ├── raspi/
│   ├── wsl/
│   └── android/{honor,poco-f3}/
├── profiles/{base,workstation,server}.nix
├── modules/
│   ├── nix.nix
│   ├── darwin/
│   ├── nixos/
│   └── home/
├── pkgs/
└── secrets/secrets.yaml
```

The split is simple. `hosts/` holds what is true for one machine: hardware, disk layout, hostname, host-only packages. `profiles/` holds policy shared by a class of machines, such as "every server gets fail2ban". `modules/` holds the building blocks, split by platform: `darwin/` for nix-darwin, `nixos/` for NixOS and `home/` for home-manager, which works on all of them.

A host file should read like a short list of what makes that machine different. If it starts repeating something another host also has, that thing belongs in a profile or a module.

## One file for the values that differ

Usernames, hostnames, IP addresses and feature toggles live in `config.nix`. The committed version has only placeholders. My real values live in `config.local.nix`, which is gitignored, and the flake merges the two:

```nix
defaultConfig = import ./config.nix;
localConfigPath = ./config.local.nix;
config =
  if builtins.pathExists localConfigPath then
    defaultConfig // (import localConfigPath)
  else
    defaultConfig;
```

This keeps the repository public without publishing my VPS address, and it means anyone can clone it, fill in their own values and build. Feature toggles like `enableRust`, `enableLaravel` and `enableGolang` live in the same file, so turning on a whole language toolchain is a one-line change.

## Builder functions instead of copied machines

I have three Macs, and they are almost identical. Instead of three copies of the same module list, the flake has one function:

```nix
mkDarwinMachine =
  { hostModule, aggressive }:
  {
    nixpkgs.hostPlatform = "aarch64-darwin";
    imports = [
      determinate.darwinModules.default
      home-manager.darwinModules.home-manager
      nix-homebrew.darwinModules.nix-homebrew
      ./modules/nix.nix
      ./modules/darwin
      ./modules/home/darwin.nix
      hostModule
      ({ ... }: {
        _module.args = mkDarwinSpecialArgs aggressive;
        home-manager.extraSpecialArgs = mkDarwinSpecialArgs aggressive;
      })
    ];
  };
```

Each Mac is then one call:

```nix
macmini-mrscraper = mkDarwinMachine {
  hostModule = ./hosts/darwin/macmini-mrscraper;
  aggressive = false;
};

beast = mkDarwinMachine {
  hostModule = ./hosts/darwin/beast;
  aggressive = true;
};
```

`mkWorkstationMachine` does the same for the two NixOS PCs. When I added `beast`, an M5 Mac, the whole change was a new host folder and three lines in `flake.nix`.

### The one flag that matters most

That `aggressive` argument is the most important switch in the Mac config. It becomes `enableAggressiveTweaks`, which gates everything that affects the whole machine rather than just my user: NVRAM boot arguments, a HID keyboard remap, global `pmset` power settings, performance launch daemons, a system-wide PostgreSQL and Homebrew's `cleanup = "zap"`.

My MacBooks are mine alone, so they get `true`. The Mac mini is shared with a second account, so it gets `false`. On that machine, `zap` would uninstall Homebrew apps the other person installed, along with their data.

One thing that surprised me: you cannot use a module argument to decide what goes in an `imports` list, because Nix has to resolve imports before it can evaluate the arguments. So the performance and PostgreSQL modules are imported on every Mac, and the flag is checked inside each module.

### specialArgs must reach home-manager too

Look at the builder again. The same arguments are passed twice: once as `_module.args` for nix-darwin, and again as `home-manager.extraSpecialArgs`. That is not an accident.

My Neovim module imports `nixvim` in its `imports` list. If `nixvim` only reaches home-manager through `_module.args`, it is not available yet when Nix resolves imports, and evaluation fails with infinite recursion. Passing it through `extraSpecialArgs` makes it available at import time. The error message does not point anywhere near the cause.

## clan for deploying

All machines except WSL and the phones are registered with [clan](https://clan.lol), which builds on top of the flake. The flake calls `clan-core.lib.clan` with the machine list, and clan produces both `nixosConfigurations` and `darwinConfigurations` from it. It also gives every machine a deploy target:

```nix
hostinger = {
  nixpkgs.hostPlatform = "x86_64-linux";
  imports = [
    ./hosts/vps/hostinger
    ({ ... }: {
      _module.args = hostingerSpecialArgs;
      clan.core.networking.targetHost = config.vpsHostingerIP;
    })
  ];
};
```

Deploying the VPS is then one command:

```sh
clan machines update hostinger --build-host root@<vps> --target-host root@<vps>
```

The `--build-host` part matters. My Macs are `aarch64-darwin` and the VPS is `x86_64-linux`, and a Mac cannot build Linux closures without a Linux builder VM. Determinate Nix can run one, but on a 16 GB machine I would rather have the RAM, so the VPS builds its own system. Fresh VPSes are installed with [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) and [disko](https://github.com/nix-community/disko), which partition the disk and install NixOS over an existing Ubuntu or Debian in one step.

## Sharing home-manager with a phone

The shared parts of my environment are zsh, Starship, tmux, Git and Neovim through [nixvim](https://github.com/nix-community/nixvim). On the Macs and PCs they are part of the system's home-manager. On my Honor phone, Nix runs inside [nix-on-droid](https://github.com/nix-community/nix-on-droid), which has its own single-user home-manager and no desktop at all.

To share the same modules, several of them are split into two files:

- `hm.nix` is a plain home-manager module with nothing desktop-specific in it.
- `default.nix` mounts `hm.nix` under the user on NixOS and macOS, and may add desktop extras.

The phone imports `hm.nix` directly. The Macs import `default.nix`. Same Neovim config, same shell, same prompt, on a laptop and on a phone.

### Why the phone runs on a stable release

Everything else follows `nixpkgs-unstable`. The Honor phone is pinned to NixOS 25.11 for nixpkgs, home-manager and nixvim, and the flake has separate `-stable` inputs just for it. The reasons are specific:

- A glibc 2.42 and Nix 2.31.3 change broke builds under proot, which nix-on-droid uses. 25.11 predates it.
- nixvim's `main` branch pins Neovim 0.12, which freezes at startup under proot.
- 25.11's aarch64 binaries, including Vim plugins, treesitter grammars and language servers, are all in the official cache, so the phone downloads them instead of compiling on its own CPU.

The general lesson: following unstable everywhere is fine until one machine is unusual enough to hit bugs nobody else hits. The flake makes it cheap to give that one machine its own pins without changing the rest.

## Secrets with sops-nix

Secrets live encrypted in `secrets/secrets.yaml`, managed by [sops-nix](https://github.com/Mic92/sops-nix). `.sops.yaml` lists the age keys allowed to decrypt them, one for my Mac and one for the VPS. The file is committed, so it is versioned with everything else, but it is useless without one of those keys.

On the VPS, sops-nix derives its age key from the machine's SSH host key, so there is no extra key file to copy over by hand:

```nix
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];

sops.secrets."ssh_private_key" = {
  mode = "0600";
  owner = "root";
  path = "/root/.ssh/id_ed25519";
};
```

Each secret is decrypted at activation into a file with its own owner and mode, and services read their environment from those files. Nothing secret ever enters the Nix store, which is world-readable.

## Tuning Nix per machine

All Macs run [Determinate Nix](https://determinate.systems), so `nix.enable` is `false` and settings go through `determinateNix.customSettings`. The shared settings add my own Cachix cache next to the official one, enable parallel evaluation and run garbage collection weekly through a launchd job. Determinate Nix leaves store maintenance off by default, so that job has to be added by hand.

The interesting part is what differs per machine. `beast` is an M5 with 10 cores and 16 GB of RAM. Determinate's defaults resolve to `max-jobs = 10` and `cores = 0`, meaning up to ten builds at once, each allowed to use every core. That allows up to 100 compile threads on a machine with 10 cores, and on 16 GB it swaps instead of compiling. So `beast` gets:

| Setting | Value | Why |
| --- | --- | --- |
| `max-jobs` | 4 | Memory use grows with each parallel build, so this caps RAM, not CPU |
| `cores` | 2 | 8 threads total, close to the 4 performance cores |
| `max-substitution-jobs` | 32 | This machine downloads far more than it builds |
| `http-connections` | 50 | Parallel downloads are the real bottleneck |

The VPS goes the other way: `max-jobs = 1`, `cores = 2` and zram swap at 50% of RAM. A small VPS will otherwise be killed by its own rebuild.

The way to check these numbers is simple. Watch `sysctl vm.swapusage` during a large build. If swap grows, `max-jobs` is too high. If performance cores sit idle with free RAM, it is too low.

## Things that only broke on real machines

These are the bugs I could not have predicted from reading documentation. Each one is written down in the README next to the fix.

**Nix's HTTP/2 fetcher hung on the Mac mini.** Downloads would never finish and never fail. The process sat at 0% CPU with its sockets closed, holding the build open until I killed it. It stalled two flake updates and a rebuild before I found it. A path Nix had been "copying" for ten minutes downloaded with `curl` in 0.07 seconds. `stalled-download-timeout` does not help, because it only covers a transfer that started and then went quiet. The fix is one host-only setting, `http2 = false`. Losing HTTP/2 costs a few extra connections, which is a much better trade than a build that stops dead.

**mac-app-util broke every Mac rebuild.** It makes Nix-installed apps visible to Spotlight by generating small launcher apps with an SBCL (Common Lisp) binary. On current macOS that binary dies with `failed to allocate 1048576 bytes at 0x300100000`, which aborted `darwin-rebuild switch` halfway through activation. I replaced it with home-manager's built-in `targets.darwin.copyApps`, which copies real app bundles into `~/Applications/Home Manager Apps`. One fewer input, and the problem was gone.

**sshd rejected my authorized keys.** If home-manager writes `~/.ssh/authorized_keys`, the file is a symlink into `/nix/store`, and sshd's `StrictModes` refuses it with "bad ownership or modes for directory /nix/store". Incoming keys now go through `users.users.<name>.openssh.authorizedKeys.keys` in the system config, which nix-darwin serves to sshd from `/etc/ssh/nix_authorized_keys.d/`. Home-manager manages outgoing SSH config only.

**Audit logs filled the VPS disk.** An old `auditd` rule logged every process start. The NixOS-generated `auditd.conf` had no rotation settings, and the log grew to 68 GB. The tools that read it had already been removed, so the fix was to turn auditd off. A config that is easy to add is also easy to forget about.

**zsh took 220 ms to start.** `compinit`, the prompt and some plugins were each being loaded more than once. Removing the duplicates brought startup to about 120 ms.

## No comments in Nix files

The repository has one rule that surprises people: `.nix` files contain no comments. Every folder with Nix files has a `README.md`, and anything that needs explaining goes there. The HTTP/2 story above lives in `hosts/darwin/macmini-mrscraper/README.md`, not above the line that sets `http2 = false`.

I started this because comments in config files rot. Someone changes the value and leaves the comment. A README per folder is something I actually re-read, and it is where I look when I come back to a machine months later. It is also where an AI agent working on the repo looks first, so the reasoning travels with the code. It is the same idea behind [standard](/blog/why-i-built-my-own-standard): make a decision once and write it down next to the code it explains.

When I removed the existing comments, I did not trust a regex. `#` shows up all over this repository inside strings: hex colors, shebangs and comments inside embedded shell and nginx config. So I made sure the change did nothing by comparing derivation hashes before and after:

```sh
nix eval --raw ".#nixosConfigurations.hostinger.config.system.build.toplevel.drvPath"
nix eval --raw ".#darwinConfigurations.beast.system.drvPath"
```

If the hash is the same, the change made zero difference to the built system. If it moved, revert. This trick works for any refactor that should not change behavior, not just removing comments, and it is one of the best reasons to use Nix at all.

## Should you do this

If you have one laptop, a flake with nix-darwin or NixOS plus home-manager is already worth it. You get a machine you can rebuild from scratch, and a history of every change you made to it.

If you have more than one machine, here is what I would do from day one, based on what I had to change later:

1. Put per-machine values in one config file, and keep the real values out of Git.
2. Write a builder function as soon as you have a second machine of the same kind.
3. Pass the same arguments to home-manager as to the system, or you will meet the infinite recursion error.
4. Split home-manager modules so the shareable part has no desktop dependencies.
5. Use sops-nix from the start. Moving secrets in later is more work than starting with them.
6. Write down every workaround next to the code, with the symptom you saw. You will hit the same bug again.

The full configuration is public at [github.com/maulanasdqn/infra.msdqn.dev](https://github.com/maulanasdqn/infra.msdqn.dev). Take what is useful. The host READMEs are probably the most valuable part, because they record what broke and why, which is the part no documentation tells you.
