Six years ago I wrote about moving to an immutable dev environment using Docker. Now I’m back to Nix. Again.

The Long Way Back

After many years living inside tmux, I was comfortable with my setup. Neovim, Docker containers that vanished on exit, everything ephemeral and reproducible. It worked beautifully.

Then 2020 happened. New job. New laptop with strict rules on what you could run. They had a cloud dev environment setup - VSCode with Remote-SSH connecting to beefy cloud boxes. My Docker setup wasn’t an option. I enabled vim keybindings in VSCode and adapted.

The cloud environment went away in 2023. I could’ve gone back to neovim then. But I didn’t. VSCode had pulled me in. The ecosystem, the extensions, the AI integrations that were starting to show up. Copilot worked seamlessly. Remote-Containers made the Docker setup painless. The muscle memory of vim keybindings kept me productive enough that I didn’t question it.

We started using Cursor shortly after it launched. Better AI features than VSCode, and I kept using it for a while. It has useful features - inline mermaid rendering and such - but eventually Claude Code took over my workflows. Still, everything was vim emulation, not actually vim.

Close enough is a lie you tell yourself.

I’d been using Claude Code in a terminal window for so long that I finally asked myself: why am I even bothering to open VSCode or Cursor at this point? The terminal workflow was already there. The AI assistance was already there. I was just clinging to the editor out of habit.

Coming Home

Switching back to neovim full-time was obvious once I admitted it. Claude Code works the same way in the terminal whether I’m in VSCode or vim. The claudecode-nvim integration is simple - keybindings to focus the assistant, send code, accept/reject changes. No electron wrapper, no remote server, just neovim, Claude, and my terminal.

Modern neovim has tree-sitter and LSP support. But more importantly, the features I relied on from VSCode plugins - intelligent refactoring, complex code navigation, that kind of thing - got replaced by agentic workflows. Why click through a refactoring menu when you can just tell Claude what you want?

I still reach for Cursor sometimes for specific features. But neovim feels like home again in a way VSCode never did. My fingers know where everything is. The muscle memory is still there under five years of vim-emulation compromise.

The Docker Problem

Before the cloud environment, before VSCode, there was Docker. The immutable Docker environment was my honest attempt at a reproducible dev setup. It worked. For years. But it had friction I’d learned to ignore.

Docker-for-Mac’s filesystem is terrible. Always was, still is. Watch a Rails app boot inside a container - 30 seconds. Same app on the host filesystem - 3 seconds. File watchers would miss changes. bundle install took forever. I worked around it with named volumes for everything that mattered.

But named volumes meant state to manage. Every Docker factory reset - and they happened more often than I’d like - meant an hour of restoring from restic. History files, local databases, cached gems. Did I remember to backup that postgres data volume? Hope so, because it’s gone now.

The networking was annoying. SSH-ing into my own dev container, port forwarding, volume mounts everywhere. Worth it when I needed VSCode’s Remote-Containers extension. Not worth it when I’m back in neovim full-time.

I could’ve stuck with Docker and just run neovim locally. But once I started questioning one part of the setup, everything else fell apart. Why containers at all? What problem was I actually solving?

Nix, But Different This Time

I’d tried Nix before. Multiple attempts over the years.

October 2020: Started with home-manager. Used it actively through 2021 with niv pinning, shell.nix wrappers, everything in a monolithic home.nix file. It worked, but only I understood it.

Late 2021: Migrated to yadm and deleted all the Nix configs. Went back to Brewfile, manual dotfiles, the standard macOS approach.

May 2025: Tried again with a nixos-config setup inspired by evantravers/dotfiles and Mitchell Hashimoto’s approach. Built modular configurations with nix-darwin - machines/meduseld.nix, users/qmx/darwin.nix, lib/mksystem.nix builder functions. Used it through the summer.

The abstraction layers were the problem. Every change needed edits across multiple files. The builder functions in lib/mksystem.nix added complexity without clarity. It worked, but I was spending time fighting the structure instead of using it. Too much boilerplate for what should’ve been simple.

October 2025: Started over. This time, a coworker, Jitsusama, jumped on pairing sessions to help. His two-repo pattern finally clicked.

Two repositories:

core.nix - What’s common across all your machines. Tools and their configurations. No personal data, no email addresses, no GPG keys. Just “I want git, zsh, neovim, tmux configured this way.” Sharable across contexts. Version controlled separately.

dotfiles - Everything that varies. Personal info in modules/. Machine-specific packages in hosts/ - Ghostty on macOS, work-specific tools on the work machine. Imports core.nix as a flake input.

The pattern solved what the May setup couldn’t. Clean separation of concerns. Want the same neovim config everywhere? It’s in core.nix. Need different packages on your work machine? Override in dotfiles. No abstraction layers, no builder functions. Just composition.

And you get Nix’s predictability throughout. Same configuration builds the same environment, every time. Dependencies don’t randomly shift. Updates happen when you want them, not when some package manager decides.

The Migration

Started simple. Created core.nix with essentials:

  • git, zsh, tmux, direnv, starship
  • neovim with LSP, the plugins I actually use
  • Claude Code integration
  • Ghostty as my terminal
  • GPG/Yubikey setup

Each tool gets its own directory. Clean, self-contained, easy to understand. No magic, just Nix modules.

Then dotfiles:

  • Personal info in modules/
  • Machine-specific stuff in hosts/meduseld/ (my MacBook)
  • Later added hosts/wk3/ for my Raspberry Pi 5

Three layers. Core provides defaults, modules add personal data, hosts override for specific machines. Simple precedence, no surprises.

Cross-Platform for Free

The real test was deploying to the Raspberry Pi 5 running Debian. I use it as a development server. Same home-manager config, different platform.

A couple of platform-specific tweaks (macOS uses pinentry_mac, Linux uses pinentry-curses), but that’s it. Everything else just worked. Same neovim setup, same zsh, same Claude Code integration, same git config, same aliases, same everything.

This matters more than it sounds. No mental overhead remembering which tools are where. No context switching friction when I SSH to the Pi. No “wait, how did I configure this here?” moments. Same muscle memory everywhere. Builds behave identically because it’s the same tools, same versions, same configs.

Both machines pulling from the same core.nix. The pattern proved itself at scale. No containers, no VMs, no manually syncing dotfiles. Just Nix doing its thing across platforms.

Pattern Over Tools

Here’s what I learned going through this twice: the immutability pattern matters more than the implementation.

2019: Docker containers with --rm. Ephemeral filesystem, reproducible setup, scripted deployment.

2025: Nix with home-manager. Declarative config, reproducible builds, version-controlled environment.

Different tools, same goal. Destroy and rebuild anytime. No manual setup. No “works on my machine” problems. Configuration is code.

The Docker approach was right for 2019. Nix is right for now. Maybe something else will be right for 2030.

Starting Over

If you want to try the two-repo pattern:

core.nix: Tools you always want, configured how you like them. No personal data. Start small - git, your shell, your editor. One directory per tool. Use platform conditionals when needed.

dotfiles: Import core.nix from GitHub. Add personal info in modules/, host-specific stuff in hosts/<hostname>/.

The beauty of the flake setup: the only thing you need to install on a new machine is Nix itself. Then:

git clone <your-dotfiles>
cd dotfiles
nix develop  # Provides home-manager, nix-darwin, everything
home-manager switch --flake .

No manual installation of home-manager or nix-darwin. The dev shell has it all. On macOS, add nix-darwin to the mix. On Linux, just home-manager works fine.

The pattern scales. Two hosts now, could easily be twenty. Same core, different personal data.

Wrapping Up

Six years is a long time. The 2019 Docker solution was right then, but tools change, workflows change, and what you need from your environment changes. Nix with the two-repo pattern is right for now - cleaner, faster, more portable, and finally feels like home.

hope this is useful!