- Published on
Multi-System NixOS: Ditching Git Branches for Sanity
Multi-Host NixOS
I had pure intentions. I really just wanted one sane baseline that follows me everywhere, without pretending all machines live the same life. The day-job desk box, the box bolted to a CNC laser, and my home rig should not all be snowflakes, but they also shouldn’t cosplay as identical twins when one’s bolted to a CNC laser and the other is for games and bad decisions at 1 A.M.
NixOS promises this balance, then most of the “solved” approaches promptly forget that humans actually use these machines.
Git branches per system: Works until it doesn't. You've got five branches diverging, cherry-picking becomes tedious, and every improvement you make on one system requires manual propagation to the others. It's the kind of friction that makes you just leave that b0rk3d shit unfixed.
We Have Optiions
Monorepo with massive conditionals:home.nix becomes a rats' nest of if hostname == "x" then ... else if hostname == "y" chains. You end up maintaining the configuration complexity of five systems inside one file. That's not configuration management, that's configuration chaos.
Separate repos per system:
Fine until you want shared improvements. Now you're maintaining five slightly different versions of the same tools. Now, that's that bull-shit!
Reality
I've got five genuinely different machines:
| System | Hardware | Purpose |
|---|---|---|
| dev-laptop | ThinkPad (AMD) | Main dev machine with code/design/3D tools |
| alt-laptop | ThinkPad variant | Secondary mobile workstation for in-feild work |
| legacy-laptop | Older ThinkPad | Fallback/testing hardware |
| workstation | RTX GPU Desktop | GPU compute + NAS + streaming |
| field-tablet | Ruggedized tablet | Touch input, minimal config, won't die in the Mojove Desert |
They're not variants of each other. They're actually different.
Different hardware, different use cases, different environmental constraints.
All of this matters.
Finding A Solution
This was when I stopped pretending I could unify everything.
Instead: shared common modules.
~/nixos-config/modules/home/
├── _common/ # Stuff that's actually universal
│ ├── mime-apps.nix # File type handlers
│ ├── user-dirs.nix # XDG directory structure
│ └── base-tools.nix # Tools that work everywhere
├── laptop/
│ └── home.nix # Primary laptop (imports _common)
├── workstation/
│ └── home.nix # GPU workstation (imports _common)
└── ...
Each system gets its own home.nix. Each imports the shared modules. That's it.
What Goes in _common/?
mime-apps.nix - File type associations. These don't change between systems:
{
xdg.mimeApps.defaultApplications = {
"application/pdf" = "zathura.desktop";
"image/jpeg" = "viewnior.desktop";
"text/plain" = "nvim.desktop";
"inode/directory" = "org.gnome.Nautilus.desktop";
};
}
base-tools.nix - Universal CLI utilities:
{ pkgs, ... }:
{
home.packages = with pkgs; [
# search tools
ack ripgrep fzf fd
# git ecosystem
git gh lazygit
# networking
curl aria2 nmap
# media viewers
zathura viewnior mpv
# all the other baseline shit
];
}
user-dirs.nix - Standard XDG directory layout:
{
xdg.userDirs = {
enable = true;
download = "$HOME/Downloads";
documents = "$HOME/Documents";
pictures = "$HOME/Pictures";
videos = "$HOME/Videos";
music = "$HOME/Music";
desktop = "$HOME/Desktop";
templates = "$HOME/Templates";
publicShare = "$HOME/Public";
};
}
What Stays System-Specific?
GPU tools. Obviously I am not putting ROCm (AMD) and CUDA (NVIDIA) in the same config.
# workstation/home.nix - NVIDIA-specific
{ pkgs, ... }:
{
imports = [
../_common/mime-apps.nix
../_common/user-dirs.nix
../_common/base-tools.nix
];
home.packages = with pkgs; [
cudatoolkit
nvidia-docker
nvtop
# GPU compute stuff
];
}
# laptop/home.nix - AMD-specific
{ pkgs, ... }:
{
imports = [
../_common/mime-apps.nix
../_common/user-dirs.nix
../_common/base-tools.nix
];
home.packages = with pkgs; [
rocmPackages.rocm-smi
amdgpu_top
# AMD compute stuff
];
}
Audio production. Not every system needs audacity, qjackctl, and a bunch of audio bridges.
3D/CAD. FreeCAD, KiCAD, OpenSCAD—only on systems that actually use them.
Specialty Hardware. SpaceNavigator drivers (CAD Mouse), touch keyboards, NAS server daemons.
Video production. OBS Studio, video editors—workstation only.
Example: File Handling
The three-layer fix:
Layer 1: MIME associations
Define once in _common/mime-apps.nix. Propagates to all systems automatically.
{
xdg.mimeApps = {
enable = true;
defaultApplications = {
# Documents
"application/pdf" = "zathura.desktop";
"text/plain" = "nvim.desktop";
# Images
"image/jpeg" = "viewnior.desktop";
"image/png" = "viewnior.desktop";
"image/gif" = "viewnior.desktop";
# Audio/Video
"audio/mpeg" = "mpv.desktop";
"audio/flac" = "mpv.desktop";
"video/mp4" = "mpv.desktop";
"video/x-matroska" = "mpv.desktop";
# Directories
"inode/directory" = "org.gnome.Nautilus.desktop";
};
};
}
Layer 2: Window manager rules
In your Hyprland config (or whatever WM you're running):
# modules/home/pkgs/hyprland.nix
{
wayland.windowManager.hyprland.settings = {
windowrulev2 = [
"float,class:^(mpv)$"
"float,class:^(viewnior)$"
"float,class:^(zathura)$"
"float,class:^(cava-visualizer)$"
"size 400 300,class:^(cava-visualizer)$"
];
};
}
Layer 3: Smart hooks (Optional)
Create an mpv Lua script that knows what to do with audio files:
-- ~/.config/mpv/scripts/audio-visualizer.lua
function is_audio_file()
local has_video = mp.get_property_number("video", 0)
local has_audio = mp.get_property_number("audio", 0)
return has_audio > 0 and has_video == 0
end
function launch_cava()
-- spawn cava in a separate terminal
mp.command_native({
name = "subprocess",
args = { "foot", "-a", "cava-visualizer", "-e", "cava" },
detach = true,
})
end
mp.register_event("file-loaded", function()
if is_audio_file() then
launch_cava()
end
end)
Result: Click an audio file → mpv + cava launches automatically with a nice visualizer. Click video → mpv floats. Click PDF → zathura opens. Works the same on all systems.
With git branches, you apply this fix separately five times. With shared modules, you fix it once.
The Import Pattern
Here's what a complete system-specific home.nix looks like:
# ~/nixos-config/modules/home/workstation/home.nix
{ config, pkgs, lib, ... }:
{
imports = [
# Common modules
../_common/mime-apps.nix
../_common/user-dirs.nix
../_common/base-tools.nix
# Shared optional modules
../shared/neovim.nix
../shared/hyprland.nix
../shared/git.nix
# System-specific
./nvidia.nix
./streaming.nix
];
home.username = "your-user";
home.homeDirectory = "/home/your-user";
home.packages = with pkgs; [
# Workstation-only packages
obs-studio
davinci-resolve
blender
];
home.stateVersion = "24.05";
}
The beauty here is explicit control. You can see exactly what's being imported. No hidden conditionals, no hostname checks scattered throughout.
The Nuts & Bolts
- Respects reality: Not pretending all your systems are the same.
- Eliminates redundancy: Shared config is actually shared.
- Scales: Add system #6? Import the common modules, add system-specific stuff, done.
- Maintainable: Each system's
home.nixis readable and focused. - Debuggable: When something breaks, you know exactly where to look.
- Future-proof: When you eventually migrate to a proper flake setup, this structure ports directly.
The Migration
If you're stuck with git branches right now, here's the path out:
- Create
modules/home/_common/in your main branch - Add the three common modules (mime-apps, user-dirs, base-tools)
- Merge main into each branch
- Update each branch's
home.nixto import from_common/ - Remove duplicated config from each branch
- Gradually move more shared config into
_common/or ashared/directory
# Start with the common structure
mkdir -p ~/nixos-config/modules/home/_common
touch ~/nixos-config/modules/home/_common/{mime-apps,user-dirs,base-tools}.nix
# Extract your common stuff
# Test on one system first
nix flake check
sudo nixos-rebuild switch --flake .#your-system
# If it works, propagate to other branches/systems
No breaking changes. Branches keep working as-is, just cleaner.
What This Isn't
This isn't a silver bullet for "one config to rule them all." It's not a replacement for proper system-level NixOS configuration per host (that stays in hosts/ or systems/). It's not magic.
It's just: stop duplicating your user-level configuration across branches. Define the things that don't change once, in _common/. Define the things that do change in each system-specific module. Import both. Move on with your life.
Directory Structure Reference
For the visual learners, here's the full structure I ended up with:
~/nixos-config/
├── flake.nix
├── flake.lock
├── hosts/
│ ├── dev-laptop/
│ │ └── configuration.nix
│ ├── workstation/
│ │ └── configuration.nix
│ └── ...
└── modules/
├── nixos/ # System-level modules
│ ├── nvidia.nix
│ ├── amd.nix
│ └── ...
└── home/ # Home Manager modules
├── _common/
│ ├── mime-apps.nix
│ ├── user-dirs.nix
│ └── base-tools.nix
├── shared/
│ ├── neovim.nix
│ ├── hyprland.nix
│ └── git.nix
├── dev-laptop/
│ └── home.nix
├── workstation/
│ └── home.nix
└── field-tablet/
└── home.nix
To Sum Things Up…
Five systems, one godmode user, one repo, minimal duplication.
The common modules pattern works because it acknowledges reality:
some things are universal, most things aren't; so configure accordingly!
Now go fix your MIME apps and stop fighting git…