Wes Bragavim-wes
Platform Engineering

Cross-platform development environment with Nix and Ansible

July 26, 2025
tree-branches

Or: How I learned to stop worrying and love the flake

After two years of maintaining an increasingly complex Ansible setup that supported macOS, Ubuntu, Arch Linux, and Fedora, I finally admitted defeat. Not to the problem of cross-platform development environments—that's still worth solving—but to the approach I was taking.

My old setup was a monument to the "works everywhere" philosophy, complete with OS detection logic, platform-specific package managers, and enough Docker containers to simulate every Linux distribution known to humanity. It worked, technically. But it was becoming a maintenance nightmare that spent more time fighting package managers than actually setting up development tools.

So I threw it all away and rebuilt it with Nix. Here's why.

The Problem with "Universal" Ansible

The core issue with cross-platform Ansible isn't that it doesn't work—it's that it works too well. You can absolutely build a system that detects whether you're on macOS or Ubuntu or Arch Linux, then calls the right package manager with the right flags to install the right packages. The problem is what happens next.

Here's what my "universal" approach looked like in practice:

- name: Install ripgrep
  homebrew:
    name: ripgrep
  when: ansible_os_family == "Darwin"

- name: Install ripgreg 
  apt:
    name: ripgrep
  when: ansible_distribution == "Ubuntu"

- name: Install ripgrep
  pacman:
    name: ripgrep
  when: ansible_distribution == "Archlinux"

- name: Install ripgrep
  dnf:
    name: ripgrep
  when: ansible_distribution == "Fedora"

Multiply this by every tool in my development environment. Then add special cases for when package names differ across distributions. Then add version pinning because different package managers ship different versions. Then add fallback installation methods for when the primary approach fails.

What started as "install ripgreg everywhere" became a 50-line task with conditional logic, error handling, and platform-specific workarounds. And that was just for one tool.

The architecture was impressive in its thoroughness—cross-platform support for five different operating systems, automatic OS detection, package manager abstraction, comprehensive Docker testing. But impressive architecture doesn't solve the fundamental problem: you're still at the mercy of every package manager's quirks, update cycles, and breaking changes.

The Maintenance Tax

Here's what nobody tells you about cross-platform package management: it's not just about getting the initial setup right. It's about maintaining compatibility as:

  • Ubuntu changes their default Python version
  • Homebrew deprecates a formula you depend on
  • Arch Linux updates a package that breaks your dotfiles
  • Fedora decides to restructure their package names
  • A new macOS version breaks half your scripts

My solution was more testing. More Docker containers. More OS-specific logic. More maintenance burden for every single tool I wanted to add to my environment.

Enter Nix: The Nuclear Option

Nix is the nuclear option for cross-platform development environments. Instead of abstracting over different package managers, it replaces them entirely. Instead of detecting your OS and doing different things, it does exactly the same thing everywhere.

Here's my new setup in its entirety:

make all      # Complete setup
make nix      # Install Nix and packages  
make zsh      # ZSH configuration
make ssh      # SSH setup (vault required)
make dotfiles # Deploy dotfiles (vault required)

Five commands. One approach. Zero platform-specific logic.

The magic happens in flake.nix, which defines every single package I need:

packages = with pkgs; [
  # Search & Navigation
  fzf ripgrep bat fd-find zoxide
  
  # Network & Archives  
  wget curl unzip
  
  # Development
  tmux jq git-delta gh git-lfs stow
  
  # Languages
  go nodejs python3 fnm uv
  
  # Build Tools
  cmake ninja gettext
  
  # Security
  openssh sops age
];

That's it. Same versions, same behavior, everywhere. My MacBook gets the exact same ripgrep as my Linux servers. No more debugging version mismatches. No more platform-specific workarounds.

Why Not Go Full Nix?

Here's where most people would ask: "If Nix solves the package management problem, why not just use Nix for everything?" The answer is that pure Nix optimizes for the wrong thing when it comes to dotfiles.

Nix home-manager creates immutable symlinks to the Nix store. Your .vimrc becomes a read-only link to /nix/store/hash-vimrc. Want to test a quick config change? You have to:

  1. Edit your Nix configuration
  2. Run the rebuild command
  3. Wait for Nix to process everything
  4. Hope it works

That's fine for packages—I want my ripgrep installation to be immutable and reproducible. But for dotfiles? I experiment constantly. When I'm tweaking my Neovim config, I want to :source % and see the change immediately. When I'm adjusting my tmux settings, I want to hit prefix + r and test the new behavior. When I'm trying out a new shell alias, I want to source ~/.zshrc and use it right away.

The pure Nix approach turns configuration iteration from a real-time feedback loop into a batch processing job. That's not development—that's bureaucracy.

The Hybrid Architecture

So I made a deliberate architectural choice: use the right tool for the right job.

  • Nix: Handles packages (the stuff that should be immutable)
  • Stow: Handles dotfiles (the stuff I actually edit)
  • Ansible: Orchestrates everything and manages secrets

This gives me:

Reproducible environments: My package versions are locked and consistent everywhere. No more debugging why fzf behaves differently on different machines.

Development-friendly configs: I can edit my dotfiles and see changes instantly. No rebuild cycles killing my creative flow.

Secure secret management: Ansible vault encrypts my SSH keys and sensitive configs without getting in my way.

Platform abstraction where it matters: Nix eliminates OS differences for packages. Stow works the same everywhere for dotfiles. Ansible handles the orchestration consistently.

The result? I get reproducible environments that don't fight me when I'm trying to work.

The Results

My new setup is:

  • Simpler: 5 commands instead of 8, zero platform logic
  • Faster: No more OS detection, no more package manager abstractions
  • More reliable: Same tool versions everywhere, every time
  • Easier to test: One approach to validate instead of five
  • Actually maintainable: Adding a new tool means adding one line to flake.nix

The best part? It still supports every platform I care about. macOS, Linux distributions, ARM64, x86_64—Nix handles all of it transparently.

The Lesson

Sometimes the right solution isn't more abstraction—it's less. Instead of building increasingly complex systems to handle every edge case, sometimes you need to step back and ask: "What if there were no edge cases?"

Nix eliminates the edge cases by making every platform look the same. My development environment no longer has to care whether it's running on macOS with Homebrew or Arch Linux with pacman. It just works, everywhere, the same way.

If you're maintaining cross-platform development tooling and finding yourself writing more abstraction layers than actual functionality, consider the nuclear option. Sometimes it's the only way to win.


You can see the full transformation in this PR. The diff tells the whole story: 200+ lines of complexity reduced to a handful of clean, maintainable configuration.