Last updated on (from git)

Building a Maintainable Nix Configuration: A Modular Approach

Building a Maintainable Nix Configuration: A Modular Approach

Building a Maintainable Nix Configuration: A Modular Approach

How I transformed a 663-line monolithic configuration into a clean, modular system—and why you might want to do the same

TL;DR: This guide shows you how to restructure a monolithic Nix configuration (663+ lines) into focused, maintainable modules. You’ll learn when to modularize, how to do it safely, and patterns for organizing packages, shell configuration, git settings, and more.

Introduction

If you’ve been using Nix for managing your development environment, you’ve probably experienced this progression:

Day 1: “This is amazing! One file to manage everything!”
Week 4: “Hmm, this file is getting long, but it’s still manageable.”
Month 3: “Where did I put that git configuration? Ctrl+F time…”
Month 6: “I’m afraid to change anything because I don’t remember what depends on what.”

Sound familiar?

What is Home Manager?


The Journey: From Simple to Complex to Simplified

The Beginning: Simplicity

Like most people, I started with a simple home.nix:

# home.nix - Day 1
{ config, pkgs, ... }: {
  home.packages = with pkgs; [ git vim curl ];
  programs.zsh.enable = true;
  home.username = "me";
  home.homeDirectory = "/Users/me";
}

Twenty lines. Clean. Understandable. Perfect.

The Middle: Complexity

Fast forward three months. I’ve discovered the power of Nix. I’m managing everything:

  • 150+ packages across multiple categories
  • Custom shell functions and aliases
  • Git configuration with 20+ aliases
  • Neovim configuration with plugins
  • Tmux setup with custom keybindings
  • Custom development scripts

The result? My home.nix is now 663 lines. Scrolling through it takes work. Finding things requires searching. Making changes feels risky.

Common pitfall: The “just one more thing” syndrome. Each addition seems harmless, but collectively they create an unmaintainable monolith. By line 300, you’ve lost the plot.

The “Aha” Moment

The breaking point came when I wanted to share my git configuration with a colleague. I had to:

  1. Open the 663-line file
  2. Scroll to find the git section (around line 345)
  3. Copy about 80 lines and remove personal details
  4. Explain context that wasn’t obvious

Then they asked: “What about your shell aliases?”

That was it. Time to restructure.

The End: Structured Simplicity

After the restructure, sharing my git configuration became:

“Check out modules/home/git.nix

The file is self-contained, documented, and under 100 lines. Everything became easier:

  • Finding settings: “Git stuff? Look in git.nix
  • Making changes: Edit one focused file
  • Understanding history: Git diffs show exactly what changed
  • Experimenting: Changes are isolated and safe

Understanding Modular Configuration

What Is Modular Configuration?

Instead of one large file containing everything, you split your configuration into multiple files, each handling a specific concern.

Monolithic: 663 lines, everything mixed

home.nix
663 lines
Lines 1-150
Packages
Lines 151-230
Git
Lines 231-430
Shell
Lines 431-530
Editors
Lines 531-663
Scripts

Modular: Focused, self-contained files

default.nix
30 lines
packages.nix
170 lines
git.nix
80 lines
shell.nix
200 lines
editors.nix
100 lines
development.nix
83 lines

Why This Matters

1. Cognitive Load Reduction

Before: Open 663-line file to change git. See packages, shell, editors, everything. Your brain must process and ignore 583 irrelevant lines.

After: Open git.nix. 80 lines. All git. Nothing else. Your brain focuses entirely on git.

2. Git History Becomes Useful

modified: modules/home/shell.nix
@@ -45,1 +45,1 @@
-  gs = "git status";
+  gs = "git status -s";

Perfect clarity. You immediately know it’s shell-related.

3. Fearless Experimentation

Edit shell.nix. If something breaks, you know it’s shell-related. Rollback: git checkout modules/home/shell.nix.

Pro tip: Create feature branches for experiments. Try new configurations without fear. Merge what works, discard what doesn’t.

When NOT to Be Modular

Use a monolithic approach when:

  • You’re learning: Start simple, modularize later
  • It’s tiny: If your entire config is 50 lines, don’t split it
  • It’s temporary: Experimental setups don’t need structure

Rule of thumb: Modularize when your configuration exceeds 150 lines and handles multiple concerns (packages + shell + git + etc.).


The Structure: A Deep Dive

Directory Organization

nix-config/
├── flake.nix                      # Entry point
├── darwin-configuration.nix       # System-level config (macOS)

├── modules/home/                  # Home Manager modules
│   ├── default.nix               # Imports all modules
│   ├── packages.nix              # Package installations
│   ├── shell.nix                 # Shell configuration
│   ├── git.nix                   # Git configuration
│   ├── editors.nix               # Editor configurations
│   └── development.nix           # Custom scripts

└── scripts/
    └── rebuild.sh                # Rebuild helper

Module Design Principles

Each module follows these principles:

1. Single Responsibility - One concern per file
2. Self-Contained - Understandable independently
3. Well-Documented - Clear comments explaining choices

# modules/home/git.nix
# ============================================================================
# GIT CONFIGURATION
# ============================================================================
# This file configures Git version control settings, including identity,
# preferences, and aliases.
# ============================================================================

{ config, pkgs, ... }:

{
  programs.git = {
    enable = true;
    userName = "Alice Smith";
    userEmail = "alice@example.com";

    # Credential Management
    # Store credentials securely using macOS keychain
    credential = {
      helper = "osxkeychain";  # macOS
      # helper = "libsecret";  # Linux alternative
    };
  };
}

The Import Pattern

# modules/home/default.nix
{ config, pkgs, ... }:

{
  imports = [
    ./packages.nix      # Load package configuration
    ./shell.nix         # Load shell configuration
    ./git.nix           # Load git configuration
    ./editors.nix       # Load editor configuration
    ./development.nix   # Load custom scripts
  ];

  # Basic user information
  home.username = "alice";
  home.homeDirectory = "/Users/alice";
  home.stateVersion = "24.05";
}

Key insight: Add or remove modules by changing the import list. Want to disable editors temporarily? Comment out ./editors.nix. Want to add Kubernetes tools? Create ./kubernetes.nix and add it to imports.


Implementation Patterns

Package Organization Strategy

Organize packages by category, not alphabetically:

# modules/home/packages.nix
{ config, pkgs, ... }:

{
  home.packages = with pkgs; [
    # =========================================================================
    # MODERN CLI TOOLS
    # =========================================================================
    bat                        # Better cat with syntax highlighting
    eza                        # Better ls with colors and icons
    fd                         # Better find - faster and easier
    ripgrep                    # Better grep - blazingly fast

    # =========================================================================
    # PROGRAMMING LANGUAGES
    # =========================================================================
    nodejs_22                  # Node.js LTS version
    python3                    # Python 3
    go                         # Go language

    # =========================================================================
    # CONTAINER TOOLS
    # =========================================================================
    docker                     # Docker CLI
    docker-compose             # Multi-container applications
    kubectl                    # Kubernetes CLI
  ];
}

Why categories work: Easier to find, clearer intent, natural organization.

Shell Configuration Patterns

# modules/home/shell.nix
{ config, pkgs, ... }:

{
  programs.zsh = {
    enable = true;
    enableCompletion = true;

    oh-my-zsh = {
      enable = true;
      theme = "robbyrussell";
      plugins = [ "git" "docker" "kubectl" ];
    };

    # Aliases organized by category
    shellAliases = {
      # Git shortcuts
      gs = "git status";
      ga = "git add";
      gc = "git commit";

      # Docker shortcuts
      d = "docker";
      dc = "docker-compose";

      # Navigation shortcuts
      ".." = "cd ..";
      "..." = "cd ../..";
    };

    # Custom functions
    initExtra = ''
      mkcd() {
        mkdir -p "$1" && cd "$1"
      }

      export EDITOR="nvim"
      export VISUAL="$EDITOR"
    '';
  };
}

Custom Script Integration

One of the most powerful features of Nix is creating custom commands:

# modules/home/development.nix
{ config, pkgs, ... }:

{
  home.packages = with pkgs; [
    # Create custom commands available in PATH
    (writeShellScriptBin "sysinfo" ''
      #!/usr/bin/env bash
      echo "System: $(uname -s)"
      echo "Architecture: $(uname -m)"
      echo "User: $(whoami)"
      echo "Home: $HOME"
    '')

    (writeShellScriptBin "quick-commit" ''
      #!/usr/bin/env bash
      git add .
      git commit -m "$1"
      git push
    '')
  ];
}

After rebuilding, these commands are available:

$ sysinfo
System: Darwin
Architecture: arm64
User: alice
Home: /Users/alice

Practical Migration Guide

Migration Flow

Success
Failure
Yes
No
Monolithic
Config
Backup to Git
Extract One Module
Test Build
Git Commit
Fix Issues
More
Modules?
Done!

Step-by-Step Migration

Before you begin: Commit your current working configuration to git. This is your safety net!

Step 1: Identify Module Boundaries

Look at your monolithic configuration and mark distinct sections:

  • Packages: home.packages =
  • Shell: programs.zsh = or programs.bash =
  • Git: programs.git =
  • Editors: programs.neovim =, programs.vim =
  • Custom scripts: writeShellScriptBin

Step 2: Extract One Module at a Time

Start with the simplest (usually git):

mkdir -p modules/home
vim modules/home/git.nix

Add boilerplate and copy your git config:

{ config, pkgs, ... }:

{
  programs.git = {
    # ... your existing git config
  };
}

Step 3: Test Immediately

# Create default.nix with import
echo '{ imports = [ ./git.nix ]; }' > modules/home/default.nix

# Test build
home-manager switch --flake .

Crucial: Test after each extraction. Don’t extract everything and then test. If something breaks, you’ll know exactly which module caused it.

Step 4: Repeat for Remaining Modules

Extract one at a time: packages.nix, shell.nix, editors.nix, development.nix

Step 5: Document and Optimize

  • Add header comments to each module
  • Explain non-obvious choices
  • Remove duplication
  • Clean up commented code

Rollback Strategies

If something breaks:

Option 1: Git rollback

git checkout HEAD -- modules/home/shell.nix
home-manager switch --flake .

Option 2: Home Manager rollback

home-manager generations  # List previous builds
home-manager switch --flake . --rollback

Decision Tree: Should You Modularize?

false

Lessons Learned

After months of using modular configuration:

What Worked Well

1. Category-based organization: Organizing packages by category (not alphabetically) made finding things intuitive.

2. Extensive commenting: Future me is grateful past me added comments explaining why things are configured a certain way.

3. Helper scripts: The rebuild.sh script made applying changes effortless.

Unexpected Challenges

1. Over-modularization: Initially, I created too many tiny modules. Balance is key—modules should be substantial enough to justify separate files.

Avoid: Creating a separate module for every single program. Group related configurations (e.g., all editors together).

2. Cross-module dependencies: Sometimes configuration in one module needs to reference another. The config parameter solves this, but it adds complexity.

Recommendations

Do:

  • Start when your config exceeds 150-200 lines
  • Migrate incrementally, testing after each step
  • Document as you go
  • Use Git religiously
  • Share your configuration

Don’t:

  • Modularize too early (premature optimization)
  • Create too many tiny modules (balance is key)
  • Forget to test after each change
  • Skip documentation (future you will regret it)

Conclusion

Modular Nix configuration isn’t just about organization—it’s about making your development environment manageable, understandable, and maintainable.

Key Takeaways

  1. Modular configuration reduces cognitive load - Focused files are easier to understand
  2. Git history becomes useful - Clear diffs show exactly what changed
  3. Experimentation becomes safe - Isolated changes mean limited blast radius
  4. Sharing becomes effortless - Self-contained modules are easy to share
  5. Maintenance becomes sustainable - Well-structured configuration grows with you

When to Modularize

  • Configuration exceeds 150-200 lines
  • Managing multiple concerns (packages, shell, git, etc.)
  • Want to share parts of your configuration
  • Need clear change history
  • Planning long-term maintenance

Your Turn: Whether you’re just starting with Nix or have a monolithic configuration that needs restructuring, the patterns in this article can help. Start small, migrate incrementally, and most importantly—make your configuration work for you, not the other way around.

Your future self will thank you.


Resources

Happy configuring! 🚀

Comments