From NixOS to macOS: Porting a Declarative Workstation Across Platforms
When a lifetime Linux user buys a MacBook, the dotfiles follow. How the NixOS/Hyprland config from Part 1 survived the platform switch with 90% of home modules intact — and what had to change.
In Parts 1-5, I described a fully NixOS workstation: Hyprland compositor, systemd for every service, Wayland-native tools, and a security posture built on Linux kernel primitives. Then I bought a MacBook.
This post covers the architecture of the migration: how the single flake.nix grew a darwinConfigurations output, the patterns that kept 90% of the home-manager config cross-platform, the services that needed platform-specific replacements, and the bugs I hit along the way.
Architecture: one flake, two platforms
The original flake.nix produced a single output: nixosConfigurations.default for the NixOS machine. Adding macOS support meant adding:
# nix/flake.nix
nix-darwin = {
url = "github:LnL7/nix-darwin/master";
inputs.nixpkgs.follows = "nixpkgs";
};
darwinConfigurations.macbook = nix-darwin.lib.darwinSystem {
system = "aarch64-darwin";
modules = [
./hosts/mac/default.nix
home-manager.darwinModules.home-manager
{ home-manager.users.usman = import ./home/mac-default.nix; }
];
};
nix-darwin is the macOS equivalent of NixOS's system-level config: it manages fonts, system defaults, Homebrew casks, and launchd daemons. home-manager handles the user layer on both platforms. The home/ directory is 90% shared code. That separation is what makes the cross-platform approach viable.
The cross-platform pattern: lib.optionals
Every package in programs.nix that's Linux-only wraps itself in a guard:
home.packages = (with pkgs; [
# Cross-platform: shell tools, monitoring, media, docs — ~50 packages here
eza bat fd ripgrep sd jq yq-go tealdeer bottom hyperfine tokei yt-dlp mpv ...
]) ++ lib.optionals pkgs.stdenv.isLinux (with pkgs; [
# Linux-only: Wayland desktop components
wl-clipboard grim slurp waybar dunst swaylock hyprpaper ...
iotop traceroute
pkgs.jetbrains.idea
]);
(fish, neovim, git, starship, and tmux aren't in this list — they're configured via their own programs.* modules in shell.nix, neovim.nix, git.nix, and terminal.nix, which apply the same cross-platform pattern at the module level instead of the package level.)
The nixpkgs curation is more Linux-centric than you'd expect. Many popular tools have no macOS build in nixpkgs even though the upstream project fully supports macOS:
| Package | Nixpkgs platforms | macOS fix |
|---|---|---|
ghostty |
linux only |
Homebrew cask |
chromium |
linux only |
Homebrew cask |
jetbrains.idea |
linux only |
Homebrew cask |
corretto21 |
linux only |
temurin-bin-21 (cross-platform) |
traceroute |
linux only |
Built-in macOS /usr/sbin/traceroute |
Every one of these caused a build failure on the first darwin-rebuild switch. The rule that saves you: check meta.platforms before adding any package to the shared list, and prefer cross-platform alternatives where they exist.
Service translation: systemd → launchd
The Linux config has systemd.user.services for SSM tunnels, portless proxy, and backup timers. macOS uses launchd.agents:
# Linux (work.nix)
systemd.user.services = {
project-db-dev = {
Unit.Description = "...";
Service.ExecStart = "aws ssm start-session ...";
Service.Restart = "always";
};
};
# macOS (mac-extras.nix)
launchd.agents = {
project-db-dev = {
enable = true;
config = {
Label = "com.internal.project.ssm.db.dev";
ProgramArguments = [ "aws" "ssm" "start-session" ... ];
KeepAlive = true;
RunAtLoad = true;
};
};
};
The mapping is one-to-one:
| Linux systemd | macOS launchd |
|---|---|
WantedBy = default.target |
RunAtLoad = true + KeepAlive = true |
Type = oneshot |
StartInterval = N or no KeepAlive |
journalctl -u name -f |
tail -f ~/Library/Logs/name.log |
systemctl --user status |
launchctl list | grep name |
Fish shell cross-platform
Nix's module system has a priority mechanism. lib.mkDefault sets a value at below-normal priority; lib.mkForce overrides at above-normal priority. That lets shell.nix declare sensible Linux defaults that mac-extras.nix can silently win over:
# shell.nix (shared)
programs.fish.shellAbbrs = {
open = lib.mkDefault "xdg-open"; # Linux fallback
sc = lib.mkDefault "grim -g \"$(slurp)\" ~/Pictures/S..."; # Linux
};
# mac-extras.nix (macOS override)
programs.fish.shellAbbrs = {
open = lib.mkForce "open";
sc = lib.mkForce "open -a Shottr";
};
The result: shell.nix stays pure convention. Everything macOS-specific lives in mac-extras.nix, which you can read as a complete diff of how the platforms diverge.
The bugs I hit
Here's what bit me:
-
command = fishis mandatory. Ghostty's.appbundle on macOS doesn't use your login shell. It defaults to/bin/bash. Withoutcommand = fishin the Ghostty config, fish abbreviations, starship prompt, and zoxide are all silently absent. For days I had a fish-looking prompt that was actually bash. It's interminal.nixnow with a comment explaining why. -
Aerospace's key name for the Return key is
enter, notreturn.alt-enteris the binding AeroSpace's own docs use for "open a new terminal window." Getting this wrong (typingalt-returnwhile debugging some other issue) silently fails with no error, no fallback — the terminal launch binding just does nothing. -
system.primaryUserrequirement. A mid-2025 breaking change in nix-darwin requires explicitly declaringsystem.primaryUser = "usman"for user-scoped options. Without it,darwin-rebuildfails with a cryptic error about the very user you have been using the whole time. -
ifis a reserved keyword. In Nix,ifcannot be an unquoted attribute. The Aerospace window rules (on-window-detected = [{ if.app-id = "com.apple.finder"; ... }]) need quoting:"if".app-id. Nix doesn't tell you this clearly. It just fails to parse. -
sops temp file paths. sops searches for
.sops.yamlby walking UP from the encrypted file. Thesops-create-keys.shscript wrote temp files to/tmp/, so sops never found~/dotfiles/.sops.yaml. Fix: create temp files inside thesecrets/directory. -
Ghostty on macOS uses the system shell by default. Worth repeating. This one cost the most debugging time.
What transferred cleanly
- Neovim (nixvim module): identical on both platforms
- Git config: identical (SSH key management differs only via
UseKeychain) - Tmux: identical
- Starship prompt: identical
- Fish config: 95% shared (5% overridden in mac-extras.nix)
- GTK/Qt theming: doesn't apply on macOS (auto-skips via
lib.mkIf isLinux) - SSM SSH tunnels: identical logic, different service manager
What's next
In Part 7, I walk through a full security audit of the resulting setup: the methodology, the 10-point checklist, and the surprising things I found when I actually checked my own config. Part 8 covers the complete new-machine bootstrap pipeline: from bare MacBook to productive workstation in a single script.