Beyond the golden image, part 2: Lord of the repo.
Layers, branches and the lock file: building one NixOS workplace for every partner
In part 1 I ended with "I haven't decided yet where this series goes next". Well, the repo decided for me. In one week it went from three layers to four, picked up a second partner, broke itself in a way that took me way too long to spot, and gained a test branch because I got nervous about pushing to production.
So this part is about the boring stuff that makes or breaks a workplace design. How you build up layers so one config can serve multiple organizations. How you test a change before every laptop gets it, and how the lock file makes sure "the same config" actually means the same config.

One thing to keep in mind while reading: I'm coming from a completely different ecosystem. Twenty years of Microsoft, VMware, Omnissa and Citrix, and only a few weeks of Nix. What I'm trying to do here is set best practices for a NixOS workplace, the way we have them for golden images and Intune.
But I'm still learning, and I share what I find along the way, including the mistakes. If you're a Nix veteran and you see me doing something the hard way or just stupid, please tell me. That's half the reason I write these.
From three layers to four
In part 1 my repo had three layers: DAWO-Core underneath, org/ for everything that applies to all laptops, and hosts/ for hardware differences. That works fine as long as you are one organization. But I don't build for one organization. In my world, one IT organization often delivers the workplace to several other organizations, each with their own Entra tenant, their own Nextcloud, their own branding.
If you have worked with Workspace ONE, you already know the answer: Organization Groups. A global level with the defaults, a level per organization underneath that inherits everything and only overrides what is different. So I added a fourth layer, Partners/, one folder per partner.
So what is a partner? In my design, a partner is an organization that gets the workplace from us, but has its own identity. Our organization doesn't have customers, we work with partners, hence the name. In an MSP you would call them customers. In a large organization it could just as well be a business unit or a department with its own tenant.
What they have in common: they're a separate organization with their own choices. Their own Entra tenant (or another identity provider), their own Nextcloud, their own branding, and often their own set of apps on top of the organization's standard. All of that lives in the partner's folder.
The repo now looks like this:
dawo-werkplek/
├── flake.nix picks the blocks, merges the layers (mkHost)
├── flake.lock pins every input (more on this below)
├── org/ layer 2: ALL laptops, ALL partners
│ ├── default.nix
│ ├── apps.nix catalog of applications with on/off switches
│ └── Config-desktop-wallpaper.nix
├── hosts/ layer 3: hardware type only
│ ├── laptop-sata.nix
│ └── laptop-nvme.nix
└── Partners/ layer 4: per partner organization
├── Edwin/
│ ├── default.nix
│ ├── Config-system-identity.nix Entra tenant, app id
│ └── Config-app-nextcloud.nix Nextcloud URL
└── Ruud/
└── ...In case you're wondering who Edwin and Ruud are: Edwin is me, set up as a partner with my own test tenant. Ruud is a colleague of mine, playing the role of a second partner in my little "organization", with a completely different Entra tenant configured in Himmelblau. Two partners is the smallest number that proves the layer actually works.
Layer | Folder | Applies to | What lives there |
1. Core | DAWO-Core, as a flake input | Every DAWO workplace, also outside my organization | Security baseline, boot loader, encrypted disk layout, desktop profiles |
2. Organization | org/ | Every laptop of every partner | Organization baseline, desktop, login screen, updates via comin, standard apps, account lockout |
3. Host | hosts/<type>.nix | One hardware model, for every partner | Hardware differences only, like the disk path |
4. Partner | Partners/<name>/ | Every laptop of one partner organization | Entra tenant and app id, Nextcloud URL, wallpaper, partner-specific apps and settings |
Read it from top to bottom: each layer is narrower than the one above it. Core is everyone using DAWO, org/ is everyone we deliver to, a host is one model, and a partner is one organization.
The identity config moved from org/ into the partner layer on purpose. Every partner has their own tenant and app registration, so Himmelblau's settings are per partner by definition.
The org layer: your own baseline
The org/ layer deserves a closer look, because it is the heart of the design. Everything in there applies to every laptop of every partner. Think of it as your organization's baseline.
In Windows terms there are two baselines in play. The Microsoft security baseline or CIS benchmark is the vendor's idea of a secure default. On top of that you build your own: your GPOs, your Intune configuration profiles, your standard apps. DAWO-Core is the first kind. org/ is the second.
DAWO decides how a secure NixOS workplace looks; org/ decides how our workplace looks.
Here is a shortened version of my org/default.nix:
{ lib, ... }:
{
imports = [
./apps.nix
./Config-system-hostname.nix
./Config-desktop-wallpaper.nix
];
# Desktop: Plasma for everyone, a partner may choose otherwise
dawo.desktop.plasma.enable = lib.mkDefault true;
services.displayManager.defaultSession = "plasma";
# Login screen: GDM instead of SDDM (more on why in part 3)
services.displayManager.sddm.enable = lib.mkForce false;
services.displayManager.gdm.enable = lib.mkForce true;
dawo.pam.lockout.services = [ "login" "su" "sudo" "gdm-password" ];
# Updates: comin follows our own repo instead of DAWO's
dawo.autoUpdate.enable = lib.mkDefault true;
dawo.autoUpdate.options.repoUrl = "https://github.com/<your-org>/dawo-werkplek.git";
# Standard apps for every partner
werkplek.apps.euroOffice.enable = lib.mkDefault true;
werkplek.apps.nextcloud.enable = lib.mkDefault true;
}Notice the two kinds of settings. Things a partner may change, like the desktop or the standard apps, get lib.mkDefault: a sensible default, not a rule. Things that are a deliberate choice for the whole organization, like replacing DAWO's login screen, get lib.mkForce. That one isn't up for discussion per partner.
The lockout line is a nice example of why you want this layer at all. DAWO locks an account after five failed attempts, but by default only on the login screen DAWO expects with Plasma. Because I swapped that login screen, the GDM login wasn't covered anymore. One line in org/, fixed for every partner at once. That is what a baseline is for.
What does not go in org/: anything that differs per partner (that's the partner layer) and anything that differs per model (that's the host layer). If you catch yourself writing an if-statement on a partner name in org/, it belongs one layer down.
One function to rule them all
With two hardware types and two partners, I suddenly had four configurations. Writing those out by hand is a recipe for copy-paste errors, so flake.nix got a small helper function, mkHost:
mkHost = { host, partner }:
let
naam = "${host}-${lib.toLower partner}";
args = { inputs = dawo.inputs; hostConfig.name = naam; inherit himmelblau; };
in lib.nixosSystem {
specialArgs = args;
modules = [
dawo.modules.nixos.profiles-dawo-generic
dawo.modules.nixos.boot-loader
dawo.modules.nixos.hardware-generic-intel
dawo.modules.nixos.disko-single-nvme-luks
# ... a few more DAWO blocks
./org
./hosts/${host}.nix
./Partners/${partner}
];
};And then the list of what actually exists:
nixosConfigurations = {
laptop-sata-edwin = mkHost { host = "laptop-sata"; partner = "Edwin"; };
laptop-nvme-edwin = mkHost { host = "laptop-nvme"; partner = "Edwin"; };
laptop-sata-ruud = mkHost { host = "laptop-sata"; partner = "Ruud"; };
laptop-nvme-ruud = mkHost { host = "laptop-nvme"; partner = "Ruud"; };
};The name is always <hardware type>-<partner>. That same name shows up in nixosConfigurations, in hostConfig.name and in comin, which uses it to figure out which configuration belongs to this laptop. One name, everywhere.
That works for two partners and two hardware types. With fifty partners and five hardware types you don't want 250 hand-written lines. So let's make it dynamic. The folders already tell you what exists: every .nix file in hosts/ is a hardware type, every folder in Partners/ is a partner. Let Nix read them and build every combination:
let
# every hosts/<type>.nix is a hardware type
hosts = map (lib.removeSuffix ".nix")
(builtins.attrNames (lib.filterAttrs
(name: type: type == "regular" && lib.hasSuffix ".nix" name)
(builtins.readDir ./hosts)));
# every Partners/<name>/ folder is a partner
partners = builtins.attrNames (lib.filterAttrs
(name: type: type == "directory")
(builtins.readDir ./Partners));
in
{
nixosConfigurations = builtins.listToAttrs (map
({ host, partner }: {
name = "${host}-${lib.toLower partner}";
value = mkHost { inherit host partner; };
})
(lib.cartesianProduct { host = hosts; partner = partners; }));
}mkHost itself doesn't change, only the list around it. I tested that list separately: two host files and three partner folders give exactly six configurations, and stray files like a README are ignored. Now adding a partner is just adding a folder. Same for a new laptop model: drop a file in hosts/ and every partner can use it. No lines in flake.nix to forget, and the installer menu picks it up automatically.
Two things to keep in mind. First, Nix only sees files Git knows about. Locally that means a git add is enough; when you build from GitHub, it has to be pushed. A new partner folder that Git doesn’t know about simply doesn’t exist yet. Second, this gives every partner every hardware type. If a partner only runs one model, that becomes a filter on the list. One step at a time.
Priorities, now with four players
Remember from part 1: there is no chain. The order in that modules list doesn’t decide who wins; the priority of a value does. With a fourth layer, that rule becomes the backbone of the whole design, so here is the full ladder I ended up using:
How you write it | Priority | Where I use it |
lib.mkDefault x | 1000 (weakest) | org/, so a host or partner can deviate without drama |
lib.mkOverride 900 x | 900 | Beats DAWO's own mkDefault, still loses to a plain value |
x (plain value) | 100 | hosts/ and Partners/ |
lib.mkForce x | 50 | Only when it really has to, like my SATA disk |
lib.mkOverride 40 x | 40 | Beats even DAWO's mkForce. Possible, but you are now overriding the security baseline. Don't. |
One nuance. “Nix refuses to guess” applies to options that hold a single value, like true/false or a URL. Lists are different: two lists with the same priority aren’t a conflict, they get merged. A small example:
# org/default.nix
environment.systemPackages = [ pkgs.firefox ];
# Partners/Edwin/default.nix
environment.systemPackages = [ pkgs.keepassxc ];Same priority, no error. Edwin’s laptops simply get both: Firefox from org/, KeePassXC from his own folder. That’s merging working for you, and it’s why environment.systemPackages works from every layer at once.
Want Edwin to get only his own list? You could write lib.mkForce [ pkgs.keepassxc ], but be careful: that doesn’t just remove Firefox. It throws away every package that any module added, including DAWO’s, your own app catalog and even the basic tools NixOS itself ships. That’s exactly why I use switches to remove something, like werkplek.apps.euroOffice.enable = false;, instead of forcing a list.
The design rule that falls out of that ladder is simple. org/ uses mkDefault for anything a partner might want to change. A partner then just writes a plain value and wins, no mkForce needed. If you find yourself sprinkling mkForce around in the partner layer, your org layer is too strict.
The bug that didn't throw an error
In part 1 I praised Nix for refusing to guess when two values have the same priority. I still stand by that. But here's the thing that often trips people up: when two values have a different priority, Nix doesn't complain at all. It just picks the strongest one. Silently.
That bit me. While testing, I had added this line to keep updates off:
dawo.autoUpdate.enable = lib.mkOverride 900 false;Later I turned updates on for everyone in org/ with lib.mkDefault true. Build succeeded, everything looked fine. Except updates never happened. Priority 900 beats priority 1000, so my forgotten test line won. No error, no warning, nothing.
If that sounds familiar, it should. It is exactly the "which GPO won?" problem, just in a different jacket. The difference is that in Nix I can find it with one grep through a Git repo instead of an RSoP report. Lesson learned: when you change your mind about a setting, delete the old line. Don't stack a new one on top.
And there’s a better tool than grep. Nix can tell you which file won, and with which priority:
nix eval .#nixosConfigurations.laptop-nvme-edwin.options.dawo.autoUpdate.enable.files
nix eval .#nixosConfigurations.laptop-nvme-edwin.options.dawo.autoUpdate.enable.highestPrioThe first would have pointed straight at the file with my forgotten test line, the second would have answered 900. That’s your RSoP report, just in a terminal.
Building blocks with a switch
Layers decide where a setting lives. The next question is how you make a setting easy to turn on or off per layer. For applications I built a small catalog in org/apps.nix, under my own namespace werkplek.* so it can never collide with DAWO's dawo.* options:
{ config, lib, pkgs, inputs, ... }:
let
cfg = config.werkplek.apps;
# EuroOffice isn't in nixpkgs 26.05 yet, so it comes from unstable
unstable = inputs.nixpkgs-unstable.legacyPackages.${pkgs.stdenv.hostPlatform.system};
in
{
options.werkplek.apps = {
euroOffice.enable = lib.mkEnableOption "EuroOffice desktop editors";
nextcloud.enable = lib.mkEnableOption "Nextcloud sync client";
};
config.environment.systemPackages =
lib.optional cfg.euroOffice.enable unstable.euro-office-desktopeditors
++ lib.optional cfg.nextcloud.enable pkgs.nextcloud-client;
}That gives three levels, and if you squint, it is an app catalog with assignments:
apps.nix says what is possible. Every app is off by default.
org/default.nix says what is on for everyone: werkplek.apps.euroOffice.enable = lib.mkDefault true;
A host or partner that doesn't want it: werkplek.apps.euroOffice.enable = false; One line, plain value, done.
Adding a new application is one option line and one package line. The same pattern works for things that aren't apps. My wallpaper module offers a werkplek.wallpaper option, and each partner sets it to their own image in their own folder. Partner Edwin gets the black background, partner Ruud gets the dark blue one. Same code, different value, different layer.
Gotchas I collected along the way
A new file does nothing until it is in the imports of a default.nix. I created a perfectly fine module, rebuilt, and wondered why nothing changed. Nix only sees what you import.
Don't import a file into itself. I accidentally pasted imports = [ ./apps.nix ] into apps.nix. Result: stack overflow; max-call-depth exceeded. Infinite loop, my bad.
Linux is case sensitive. Partners/ with a capital P has to be written exactly like that in flake.nix. And yes, I also ended up with both Installer/ and installer/ in my repo, which Linux is fine with and Windows and macOS really are not.
Nix only sees what Git knows about. Building from a local Git flake? A new file needs at least a git add; it doesn’t have to be committed. Building from GitHub? Then it has to be pushed. Forgot either one? Then it doesn’t exist.
One does not simply push to main
Once I turned on automatic updates, it hit me what I had actually built. comin follows the main branch of my repo. With DAWO's defaults it checks every 30 minutes, and when there is a new commit, it rebuilds. On every laptop.

In EUC terms: every commit to main is a recompose of every pool, during office hours, without a pilot group. My mkOverride 900 bug was harmless because it turned something off. A typo in the login config would have locked every user out within half an hour. That is not a workplace design, that is a very efficient way to fill the service desk queue.
We solved this years ago in our world with rings. A test pool before the production pool. Update rings in Intune. A pilot group in Workspace ONE. In Git, the natural equivalent is a branch.
The Test branch
So the repo now has a branch called Test. It starts as a copy of main, and every change lands there first. The flow looks like this:
Make the change in the Test branch.
On a test laptop: build from the Test branch and try it.
Works? Open a pull request from Test to main, squash and merge.
comin picks up main and rolls it out to all laptops within 30 minutes.
Never push straight to main without testing. Not even "just a small fix". Especially not a small fix.
Building from a specific branch is just a matter of adding ?ref= to the flake reference:
sudo nixos-rebuild test --refresh \
--flake "github:<your-org>/dawo-werkplek?ref=Test#laptop-nvme-edwin"Remember test from part 1? It activates the new config right now, but doesn't add a boot entry. Reboot, and you're back on whatever main gave you. For trying a branch, that is exactly the safety net you want.
Moving a laptop to Test, temporarily
Sometimes a single reboot is too short. You want one pilot laptop to run the Test branch for a day or a week, like moving a desktop into the pilot pool for a while. For that you use switch instead of test, so the Test version also survives a reboot.
There is one catch: comin. It keeps following main, so within half an hour it would happily "fix" your pilot laptop by putting main back. So you pause comin first:
sudo systemctl stop comin
sudo nixos-rebuild switch --refresh \
--flake "github:<your-org>/dawo-werkplek?ref=Test#laptop-nvme-edwin"The laptop now runs the Test branch, for as long as comin is stopped. systemctl stop only lasts until the next reboot. After that, comin starts again with the system, sees main, and brings the laptop back to it. Want to go back sooner? sudo systemctl start comin does the same without the reboot.
I like that this is the default behaviour. Forget about your pilot laptop, and it should quietly return to production after the next reboot. In all honesty I did not validate this yet! The only way to make a change permanent for everyone is the pull request to main.
What you want | How | How long it lasts |
Try a change once | nixos-rebuild test from the Test branch | Until the next reboot |
Pilot a laptop on Test | Stop comin, then nixos-rebuild switch from the Test branch | Until comin runs again (reboot or start) |
Back to main | Reboot, or start comin | comin rebuilds from main |
Change it for everyone | Pull request from Test to main | Permanent |
One more thing I found along the way: comin itself also supports testing branches, named testing-<hostname>. In my setup that “hostname” is the configuration name, so a branch called testing-laptop-nvme-edwin would hit every laptop running that configuration, not just one.
Does the pull request become the change process?
In part 1 I asked whether a pull request could become the change process. A week later, I can at least say it feels right. The PR shows exactly which lines change. The squash commit message explains why. And the Git history is the audit trail, for free.
But let's be honest: right now I'm the developer, the reviewer and the approver of my own PRs. That is a CAB of one. The next step is branch protection on main and a second pair of eyes, and that is where it stops being a technical question and starts being an organizational one.
The lock file, or: what "the same config" really means
In part 1 I called flake.lock the best part. Then I spent the first weeks running without one. Classic.
Life without a lock file
When you build from a flake on GitHub and there is no flake.lock in the repo, Nix wants to create one, can't write it back to GitHub, and tells you so: cannot write lock file. The quick fix is --no-write-lock-file, and that is what I did. Everything built. Problem solved, right?
Not quite. Without a lock file, Nix resolves every input fresh, every time. Whatever nixpkgs, Himmelblau and friends look like at that moment, that's what you get. Install a laptop on Monday and another on Thursday, and they can be built from different versions while running "the same config". And because Himmelblau isn't pulled from a binary cache in my setup yet, a new Himmelblau commit meant every laptop compiled it from source again. That takes a while. A long while.
In EUC terms: I had a golden image that silently rebuilt itself from whatever the internet offered that day. You'd never accept that from a reference image. You shouldn't accept it here either.
The tag is the intent, the lock is the fact
I did pin DAWO-Core to a tag in flake.nix:
dawo.url = "git+https://codeberg.org/DAWO/DAWO-Core?ref=refs/tags/v0.2.0";
himmelblau.url = "github:himmelblau-idm/himmelblau/main";That felt like enough. It isn't, for two reasons. First, Himmelblau points to main, which moves. Second, DAWO-Core has its own inputs, like nixpkgs and home-manager, and the tag says nothing about which versions of those you end up with.
This is the distinction that made it click for me. flake.nix is the purchase order: "I want DAWO v0.2.0 and the latest Himmelblau". flake.lock is the packing slip: exactly which commit arrived, for every input, including the inputs of your inputs, each with a content hash. Simplified, one entry looks like this:
"himmelblau": {
"locked": {
"owner": "himmelblau-idm",
"repo": "himmelblau",
"rev": "<exact commit>",
"narHash": "sha256-<hash of the contents>",
"type": "github"
},
"original": {
"owner": "himmelblau-idm",
"repo": "himmelblau",
"ref": "main",
"type": "github"
}
}original is what you asked for. locked is what you got. My lock file now pins 29 inputs. That is my bill of materials, and it is in Git.
Creating it in a clean place
I didn't want the lock file to depend on whatever happened to be on my own laptop that day. So it is generated by a GitHub Action, .github/workflows/flake-lock.yml, which runs nix flake lock on a clean runner and commits the result to the Test branch. It runs when the workflow file changes and can be started by hand. One GitHub quirk: the Run workflow button only shows up once the workflow file exists in the default branch. Took me a bit to figure out why my button was missing.
A side note on --no-write-lock-file: it stays in my rebuild commands. With a lock file in the repo, Nix reads it and uses those exact versions. The flag only stops it from trying to write a lock file back to GitHub.
Updating is a change, so treat it like one
The lock file doesn't update itself, and that's the point. Updating is a deliberate action:
Command | What it does |
nix flake lock | Creates the lock file or adds missing inputs. Never touches inputs that are already locked |
nix flake update | Updates all inputs to the latest that flake.nix allows |
nix flake update himmelblau | Updates only that one input (Nix 2.19 and later) |
Change the tag in flake.nix, then lock | Moves DAWO-Core to a new release, like an image upgrade |
And every update to flake.lock goes through the same flow as any other change: Test branch first, test build, then the pull request to main. The PR diff of a lock file shows exactly which inputs moved from which commit to which commit. Try getting that level of detail from "Patch Tuesday, all updates approved".
The gotcha: changing flake.nix without updating the lock
So what happens if you change flake.nix but forget the lock file? I tested it, because I wanted to know before it bit me.
If you only change configuration (modules, settings, the partner loop), nothing special happens. The lock file is only about inputs, so Nix builds your new config with the pinned versions. Exactly what you want.
If you change an input, it gets interesting. Say I move DAWO-Core from the v0.2.0 tag to v0.3.0. The lock file remembers what I originally asked for, sees that flake.nix now asks for something else, and fetches that input again. What happens next depends on how you build:
How you build | What happens |
Locally, in your own repo | Nix updates flake.lock for that one input and writes it. Everything else stays pinned |
From GitHub with --no-write-lock-file (my laptops and comin) | Nix fetches the new input on the fly and builds with it. The lock file in the repo stays old. No error, no warning |
With --no-update-lock-file | Nix refuses: the flake requires lock file changes, and they're not allowed |
The middle row is the sneaky one. Everything builds, everything looks fine, but that input is no longer pinned. Every laptop fetches whatever matches flake.nix at that moment. For that one input, you're right back where you started.
Two lessons for my own setup. First: if you touch the inputs, regenerate the lock file in the same change. My GitHub Action only runs by itself when the workflow file changes, so a new tag in flake.nix doesn't trigger it. Second: that third row is your friend. Run your checks with --no-update-lock-file and they fail exactly when someone changed the inputs without updating the lock. A guardrail instead of a good intention.
The translation table, part 2
Part 1 had a table mapping our world to NixOS. Here is the sequel:
What we know from EUC | The DAWO / NixOS equivalent |
Workspace ONE Organization Groups, customer OG under a global OG | Partners/<name>/ on top of org/, with mkDefault as the inheritance |
App catalog with assignments | werkplek.apps.* switches: catalog in apps.nix, default in org/, exception per host or partner |
Test pool, pilot group, Intune update ring | The Test branch |
Pushing the new image to production | Pull request from Test to main, comin does the rest |
Image version, build number | The Git commit of the repo (config plus flake.lock) |
Patch Tuesday | nix flake update, through the Test branch like any other change |
"Which GPO won?" | Priorities. Searchable in Git, but still silent when priorities differ |
A public repo: easy, but think twice
One takeaway I want to call out separately. There are no secrets in my repo. The LUKS password is asked during installation and never stored. The Entra app id is not a secret. No client secrets, no keys, no tokens. That means the repo can be public, and that makes life very easy. Every laptop just pulls it, no credentials, no token management, nothing to rotate.
But think about what you are publishing. A public repo is the blueprint of your workplace. Which hardening is on and which is off. Which login flow you use and which tenant sits behind it. And thanks to flake.lock, the exact version of every component, ready to be matched against the next CVE list. No secrets doesn't mean no information.
Compare it to our world. Nobody would put an export of their GPOs or their Intune baseline on GitHub for the world to read. Hiding it doesn't make you secure, but handing out the map doesn't help either.
For a lab and a blog series like this one, public is fine, and honestly part of the point. For a real organization I would go for a private repo, on an internal Git platform or a private GitHub or Codeberg repo, with a read-only deploy token for comin. That moves the problem to managing that token, but that is a problem we know how to solve.
What's next
In part 1 I said switching identity provider should be a change in one file, not a migration project. Part 3 puts that to the test: Entra ID integration, with and without Keycloak. First Himmelblau talking to Entra ID directly, then Keycloak in between, federated with Entra. Both live in the partner layer, so it really is one file per partner. Or at least, that's the theory.
How do you handle testing and rollout in your workplace today: rings, pilot groups, or "push and pray"? And if you're running Nix at scale, I'd love to hear how you structure your branches. Let me know in the comments.

Opmerkingen