Creating a declarative workspace with home-manager

A bit of context

Nix Explained Simply:

Nix is a tool that installs and manages software in a way that avoids conflicts and ensures reproducibility. Here’s how it works:

No More Conflicts: Every software package (like Python, Node.js, etc.) is stored in a unique folder with a special ID. This means you can have multiple versions of the same software without them interfering. Imagine each app living in its own isolated "bubble" so they never clash.

Reproducible Setups: You define exactly what software and versions you need in a simple config file (like a recipe). Nix uses this to install everything correctly. Share this file with others, and they’ll get the exact same setup—great for teamwork or moving between computers.

Rollbacks Made Easy: If an update breaks something, Nix lets you instantly revert to a previous working version, like a time machine for your software.

Example: Need both Python 2 and 3? Nix installs them side-by-side. Share your config, and your friend’s setup will match yours perfectly.

Think of it as a declarative, bulletproof package manager that keeps your system clean and predictable. 🛠️✨

Thanks deepseek.

You can use Nix as a "normal" package manager like apt but its true power shines when using it to write .nix files to create easy to deploy declarative and reproducible environments.

Nix has a tool called home-manager, that allows users to describe their user-environment declaratively (aka. "define-once, reuse everywhere"). You can install packages, programs, systemd services (Background processes, like auto-starting apps) and more by simply writing them into a configuration file. Furthermore, you can customize and generate configs for programs and services from within this single file.

This setup uses two files to ensure customizability and avoid reliance on preinstalled Nix channels.

But why?

For some this question might have already come up, so let me give you an example. You have a laptop and a workstation. You use both to do the exact same or similar tasks. So you need your "core" Software. (Discord, firefox, codium,... )

I will assume you use Linux because why else would you read about a niche and "hard" (steep learning-curve) package manager setup.

So you spent time to customize your workstation, maybe even build your own dotfiles for your window manager. installed all your software, configured it and then you go to your laptop and do basically the exact same thing. You may have put your dotfiles in a git repo to at least not have to write all that again but your packages? Maybe some software puts its configs in a weird folder that's not .config and you forgot it. you make all your theme choices again and you spend up to an hour doing that.

The next day you make an update and the laptop bricks.

No rollback.

Just start over.

Sounds annoying? Yes.

Is it preventable on arch? If you update late. Probably.

Nix gives you the ability to declare all setup once and you can take it everywhere. (also on Mac!) From the home.nix it creates config files from the declaration you made. Leading to an easy setup and basically no config time on subsequent devices.

you could write setup scripts in bash, but those can break by updates to packages renamed packages and they are annoying to debug.

It gives you rollbacks, test builds that don't go into effect instantly and can be run in a vm with just a flag. And for me it fixes one of my biggest problems. Having packages installed that I:

  • don't know I installed
  • i forgot to uninstall
  • the constant feeling of having bloated my entire system within two days

enable flakes and the nix command (if you haven't already)


echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf

or if you if you have NixOS you can declare it in the /etc/nixos/configuration.nix by adding nix.settings.experimental-features =[ "nix-command" "flakes"].


Configuration

Flake setup

We'll start with the shorter and easier file: flake.nix.

Flakes are Nix’s way to define reproducible builds with explicit dependencies, ensuring your setup works identically everywhere. Or its a "it just works." file

This file sets up the environment for my actual home.nix (the home-manager configuration file).

Explaining

Let's examine this from the top:

These are my inputs. They describe what flakes/channels I need and where to get them from. Zen browser doesn't have a package in the standard channels, so I added it via a flake from another GitHub repository. This ensures that wherever I run this configuration, it always knows what it needs.

inputs = {
    # Stable channel
    nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
    
    # Home Manager
    home-manager = {
      url = "github:nix-community/home-manager/release-24.11";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    # Zen Browser flake
    zen-browser = {
      url = "github:MarceColl/zen-browser-flake";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    # Unstable channel
    nixpkgs-unstable.url = "github:nixos/nixpkgs/nixos-unstable";
};

Next, I output the declarations from above and combine them with the inputs using @:

think of it like pipe-ing outputs into the inputs for the next section.

outputs = { nixpkgs, home-manager, zen-browser, nixpkgs-unstable, ... } @ 
inputs:

In the let section, I configure the packages to allow unfree and broken packages. I also configure home.packages to install zen-browser.

Don't forget the target system specification, which allows the config to be evaluated for one or multiple targets. While I only need Linux, others might use this to maintain one configuration for both their Linux and Mac systems.

let
      # System configuration
      system = "x86_64-linux";
      lib = nixpkgs.lib;

      # Package sets
      pkgs = import nixpkgs {
        inherit system;
        config = {
          allowUnfree = true;
          allowBroken = true;
        };
      };

      unstablePkgs = import nixpkgs-unstable {
        inherit system;
        config = {
          allowUnfree = true;
          allowBroken = true;
        };
      };

      # Common configuration for Home Manager
      baseConfig = {
        home.packages = with pkgs; [
          inputs.zen-browser.packages.${system}.default
        ];

        nixpkgs.config = {
          allowUnfree = true;
          allowBroken = true;
        };
      };

Then I pass the arguments from above to my home.nix. You can create multiple home.nix files for different purposes (e.g., gaming or programming) to change your setup accordingly.

in {
      homeConfigurations.myprofile = home-manager.lib.homeManagerConfiguration {
        inherit pkgs;
        
        extraSpecialArgs = {
          inherit inputs unstablePkgs;
        };

        modules = [
          ({ config, ... }: baseConfig)
          ./home.nix
        ];
      };
    };

In full:

{
  description = "My Home Manager configuration";

  inputs = {
    # Stable channel
    nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
    
    # Home Manager
    home-manager = {
      url = "github:nix-community/home-manager/release-24.11";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    # Zen Browser flake
    zen-browser = {
      url = "github:MarceColl/zen-browser-flake";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    # Unstable channel
    nixpkgs-unstable.url = "github:nixos/nixpkgs/nixos-unstable";
  };

  outputs = { nixpkgs, home-manager, zen-browser, nixpkgs-unstable, ... } @ inputs:
    let
      # System configuration
      system = "x86_64-linux";
      lib = nixpkgs.lib;

      # Package sets
      pkgs = import nixpkgs {
        inherit system;
        config = {
          allowUnfree = true;
          allowBroken = true;
        };
      };

      unstablePkgs = import nixpkgs-unstable {
        inherit system;
        config = {
          allowUnfree = true;
          allowBroken = true;
        };
      };

      # Common configuration for Home Manager
      baseConfig = {
        home.packages = with pkgs; [
          inputs.zen-browser.packages.${system}.default
        ];

        nixpkgs.config = {
          allowUnfree = true;
          allowBroken = true;
        };
      };

    in {
      homeConfigurations.myprofile = home-manager.lib.homeManagerConfiguration {
        inherit pkgs;
        
        extraSpecialArgs = {
          inherit inputs unstablePkgs;
        };

        modules = [
          ({ config, ... }: baseConfig)
          ./home.nix
        ];
      };
    };
}

home.nix

Now we get to the actual home-manager configuration.

Explaining


Here I configure my username, home directory, and state version:

  # ========== Basic Configuration ==========
  home.username = "dev";
  home.homeDirectory = "/home/dev";
  home.stateVersion = "24.11";
  programs.home-manager.enable = true;

These are my packages for daily use. The unstable packages are utilities I want in their most up-to-date versions.

Note: unstable packages can create problems because they are, well, unstable. use them only if necessary or you really need the latest version.

home.packages = with pkgs; [
    unstablePkgs.jujutsu
    btop
    dwarf-fortress
    unstablePkgs.vscodium
    unstablePkgs.minecraft
    unstablePkgs.radicle-node
    gnumake
    gh
    unstablePkgs.rustup
    unstablePkgs.rustc
    zed-editor
    keepass
    tuisky
    typst
    obsidian
    tt
    nix-init

    ## filemanager dependencies
    ranger
        # Optional dependencies for better functionality
        w3m # For image previews
        ffmpegthumbnailer # For video thumbnails
        poppler_utils # For PDF previews (pdftotext)
        highlight # For syntax highlighting
        atool # For archive previews
        mediainfo # For media file info
];

Here is my file manager setup, which lets me use Ranger directly from Rofi:

  # ========== Ranger file manager ==========
  # Create desktop entry
  xdg.desktopEntries.ranger = {
    name = "Ranger";
    genericName = "File Manager";
    comment = "Terminal-based file manager with VI key bindings";
    exec = "${pkgs.kitty}/bin/kitty -e ranger %F";  # Changed %U to %F
    terminal = false;  # Important change (explained below)
    categories = [ "FileManager" "Utility" ];  # Simplified categories
    mimeType = [ "inode/directory" ];  # Removed GNOME-specific type
    icon = "utilities-terminal";
    startupNotify = false;  # Added for better behavior
    noDisplay = false;  # Ensures it shows in menus
  };
  xdg.mimeApps = {
      enable = true;
      defaultApplications = {
        "inode/directory" = [ "ranger.desktop" ];
      };
    };

    # Set MIME associations manually
    xdg.configFile."mimeapps.list".text = ''
      [Default Applications]
      inode/directory=ranger.desktop
      application/x-gnome-saved-search=ranger.desktop
    '';
    home.activation.setRangerAsDefault = ''
        ${pkgs.xdg-utils}/bin/xdg-mime default ranger.desktop inode/directory
      '';

    # Ranger configuration
    xdg.configFile."ranger/rc.conf".text = ''
      set show_hidden true
      set preview_images true
      set preview_images_method w3m
      set sort natural
    '';

My Starship config is quite elaborate. It's a baduk themed shell and looks really nice It's not just for show. Starship shows you your projects git status which is really useful when coding:

  # ========== Starship Prompt ==========
    programs.starship = {
      enable = true;
      settings = {
        # Core Formatting
        add_newline = false;
        format = "$username$hostname$nix_shell$git_branch$git_commit$git_state$git_status$directory$jobs $cmd_duration$character";

        # Baduk Git Theme
        git_branch = {
          format = ''[●○ @ \($branch\)]($style)'';
          style = "bold";
          symbol = "";
        };
        git_status = {
          conflicted = "";  # Atari!
          ahead = "";       # Winning
          behind = "";      # Losing
          diverged = "";    # Seki (stalemate)
          staged = "";      # White stone (staged)
          modified = "";    # Black stone (unstaged)
          untracked = "";   # Empty intersection
          stashed = "";     # Ko fight
          style = "dimmed";
        };
        directory = {
          format = ''[\($path\)]($style)'';
          style = "blue dimmed";
          truncation_length = 3;
          substitutions = {
            "Documents" = "D";
            "Projects" = "P";
            "Downloads" = "DL";
          };
        };

        # User/System Info
        username = {
          disabled = false;
          style_user = "bright-white bold";
          style_root = "bright-red bold";
          format = "[$user]($style)";
          show_always = true;
        };
        hostname = {
          disabled = false;
          format = "[@$hostname]($style)";
          style = "bright-green bold";
        };

        # Shell/System
        shell = {
          disabled = false;
          format = "$indicator";
          fish_indicator = ''[●⋉](bold)'';
          bash_indicator = ''[🌀](bold #9a77cf)'';
        };
        cmd_duration = {
          format = ''[$duration]($style)'';
          style = "yellow";
          min_time = 5000;
        };

        # Optional: Nix Shell Indicator
        nix_shell = {
          disabled = false;
          format = "[via  $name]($style)";
          style = "bold purple";
        };
      };
    };

I'm configuring ssh-agent here so that Radicle can use rad auth properly. ssh-agent is a systemd service (background processes managed by systemd):

  # ========== SSH Agent ==========
  programs.ssh = {
    enable = true;
    extraConfig = ''
      AddKeysToAgent yes
      IdentityAgent ${config.home.homeDirectory}/.ssh/agent.sock
    '';
  };

  # Systemd service with correct syntax
  systemd.user.services.ssh-agent = {
    Unit = {
      Description = "SSH Authentication Agent";
      Documentation = "man:ssh-agent(1)";
    };
    Service = {
      Type = "simple";
      ExecStart = ''${pkgs.openssh}/bin/ssh-agent -D -a ${config.home.homeDirectory}/.ssh/agent.sock '';
      Restart = "on-failure";
    };
    Install = {
      WantedBy = [ "default.target" ];
    };
  };

  # Environment variables
  home.sessionVariables = {
    SSH_AUTH_SOCK = "${config.home.homeDirectory}/.ssh/agent.sock";
  };

  # Create directory and ensure proper permissions
  home.activation.setupSshAgent = lib.hm.dag.entryAfter ["writeBoundary"] ''
    mkdir -p ${config.home.homeDirectory}/.ssh
    chmod 700 ${config.home.homeDirectory}/.ssh
  '';

Finally, here's my Zed editor configuration. Note that the fileManager setting doesn't currently work, and I've only tested the Rust LSP (Language Server Protocol) - the others might need adjustments.

LSP provides code completion/linting. You can also create snippets in the LSP section. check the zed docs for information.

  # ========== Zed Editor ==========
  programs.zed-editor = {
    enable = true;
    extensions = ["nix" "toml" "elixir" "make" "sql"];

    userSettings = {
      fileManager = "ranger";
      assistant = {
              enabled = true;
              version = "2";
              default_open_ai_model = null;
            };

      # Node.js Configuration
      node = {
        path = lib.getExe pkgs.nodejs;
        npm_path = lib.getExe' pkgs.nodejs "npm";
      };

      # General Settings
      hour_format = "hour24";
      auto_update = false;
      vim_mode = false;
      load_direnv = "shell_hook";
      base_keymap = "VSCode";

      # Terminal Configuration
      terminal = {
        alternate_scroll = "off";
        blinking = "off";
        copy_on_select = false;
        dock = "bottom";

        detect_venv = {
          on = {
            directories = [".env" "env" ".venv" "venv"];
            activate_script = "default";
          };
        };

        env = { TERM = "kitty"; };
        font_family = "FiraCode Nerd Font";
        font_features = null;
        font_size = null;
        line_height = "comfortable";
        option_as_meta = false;
        button = false;
        shell = "system";
        toolbar = { title = true; };
        working_directory = "current_project_directory";
      };

      # Language Server Protocol (LSP) Configuration
      lsp = {
        rust-analyzer = {
          binary = {
            path = lib.getExe pkgs.rust-analyzer;
            path_lookup = true;
          };
        };

        nix = {
          binary.path_lookup = true;
        };

        elixir-ls = {
          binary.path_lookup = true;
          settings.dialyzerEnabled = true;
        };
      };

      # Language-specific Settings
      languages = {
        "Elixir" = {
          language_servers = ["!lexical" "elixir-ls" "!next-ls"];
          format_on_save = {
            external = {
              command = "mix";
              arguments = ["format" "--stdin-filename" "{buffer_path}" "-"];
            };
          };
        };

        "HEEX" = {
          language_servers = ["!lexical" "elixir-ls" "!next-ls"];
          format_on_save = {
            external = {
              command = "mix";
              arguments = ["format" "--stdin-filename" "{buffer_path}" "-"];
            };
          };
        };
      };

      # UI Configuration
      theme = {
        mode = "dark";
        light = "One Light";
        dark = "One Dark";
      };

      show_whitespaces = "all";
      ui_font_size = 16;
      buffer_font_size = 16;
    };
  };

The full file:


{ config, pkgs, unstablePkgs, lib, ... }:
{
  # ========== Basic Configuration ==========
  home.username = "dev";
  home.homeDirectory = "/home/dev";
  home.stateVersion = "25.05";
  programs.home-manager.enable = true;

  # ========== Package List ==========
  home.packages = with pkgs; [
    unstablePkgs.jujutsu
    btop
    dwarf-fortress
    unstablePkgs.vscodium
    unstablePkgs.minecraft
    unstablePkgs.radicle-node
    gnumake
    gh
    unstablePkgs.rustup
    unstablePkgs.rustc
    zed-editor
    keepass
    tuisky
    typst
    obsidian
    tt
    nix-init

    ## filemanager dependencies
    ranger
        # Optional dependencies for better functionality
        w3m # For image previews
        ffmpegthumbnailer # For video thumbnails
        poppler_utils # For PDF previews (pdftotext)
        highlight # For syntax highlighting
        atool # For archive previews
        mediainfo # For media file info
  ];

  # ========== Ranger file manager ==========
  # Create desktop entry
  xdg.desktopEntries.ranger = {
    name = "Ranger";
    genericName = "File Manager";
    comment = "Terminal-based file manager with VI key bindings";
    exec = "${pkgs.kitty}/bin/kitty -e ranger %F";  # Changed %U to %F
    terminal = false;  # Important change (explained below)
    categories = [ "FileManager" "Utility" ];  # Simplified categories
    mimeType = [ "inode/directory" ];  # Removed GNOME-specific type
    icon = "utilities-terminal";
    startupNotify = false;  # Added for better behavior
    noDisplay = false;  # Ensures it shows in menus
  };
  xdg.mimeApps = {
      enable = true;
      defaultApplications = {
        "inode/directory" = [ "ranger.desktop" ];
      };
    };

    # Set file type associations manually
    xdg.configFile."mimeapps.list".text = ''
      [Default Applications]
      inode/directory=ranger.desktop
      application/x-gnome-saved-search=ranger.desktop
    '';
    home.activation.setRangerAsDefault = ''
        ${pkgs.xdg-utils}/bin/xdg-mime default ranger.desktop inode/directory
      '';

    # Ranger configuration
    xdg.configFile."ranger/rc.conf".text = ''
      set show_hidden true
      set preview_images true
      set preview_images_method w3m
      set sort natural
    '';

  # ========== Starship Prompt ==========
    programs.starship = {
      enable = true;
      settings = {
        # Core Formatting
        add_newline = false;
        format = "$username$hostname$nix_shell$git_branch$git_commit$git_state$git_status$directory$jobs $cmd_duration$character";

        # Baduk Git Theme
        git_branch = {
          format = ''[●○ @ \($branch\)]($style)'';
          style = "bold";
          symbol = "";
        };
        git_status = {
          conflicted = "";  # Atari!
          ahead = "";       # Winning
          behind = "";      # Losing
          diverged = "";    # Seki (stalemate)
          staged = "";      # White stone (staged)
          modified = "";    # Black stone (unstaged)
          untracked = "";   # Empty intersection
          stashed = "";     # Ko fight
          style = "dimmed";
        };
        directory = {
          format = ''[\($path\)]($style)'';
          style = "blue dimmed";
          truncation_length = 3;
          substitutions = {
            "Documents" = "D";
            "Projects" = "P";
            "Downloads" = "DL";
          };
        };

        # User/System Info
        username = {
          disabled = false;
          style_user = "bright-white bold";
          style_root = "bright-red bold";
          format = "[$user]($style)";
          show_always = true;
        };
        hostname = {
          disabled = false;
          format = "[@$hostname]($style)";
          style = "bright-green bold";
        };

        # Shell/System
        shell = {
          disabled = false;
          format = "$indicator";
          fish_indicator = ''[●⋉](bold)'';
          bash_indicator = ''[🌀](bold #9a77cf)'';
        };
        cmd_duration = {
          format = ''[$duration]($style)'';
          style = "yellow";
          min_time = 5000;
        };

        # Optional: Nix Shell Indicator
        nix_shell = {
          disabled = false;
          format = "[via  $name]($style)";
          style = "bold purple";
        };
      };
    };

  # ========== SSH Agent ==========
  programs.ssh = {
    enable = true;
    extraConfig = ''
      AddKeysToAgent yes
      IdentityAgent ${config.home.homeDirectory}/.ssh/agent.sock
    '';
  };

  # Systemd service with correct syntax
  systemd.user.services.ssh-agent = {
    Unit = {
      Description = "SSH Authentication Agent";
      Documentation = "man:ssh-agent(1)";
    };
    Service = {
      Type = "simple";
      ExecStart = ''${pkgs.openssh}/bin/ssh-agent -D -a ${config.home.homeDirectory}/.ssh/agent.sock '';
      Restart = "on-failure";
    };
    Install = {
      WantedBy = [ "default.target" ];
    };
  };

  # Environment variables
  home.sessionVariables = {
    SSH_AUTH_SOCK = "${config.home.homeDirectory}/.ssh/agent.sock";
  };

  # Create directory and ensure proper permissions
  home.activation.setupSshAgent = lib.hm.dag.entryAfter ["writeBoundary"] ''
    mkdir -p ${config.home.homeDirectory}/.ssh
    chmod 700 ${config.home.homeDirectory}/.ssh
  '';

  # ========== Zed Editor ==========
  programs.zed-editor = {
    enable = true;
    extensions = ["nix" "toml" "elixir" "make" "sql"];

    userSettings = {
      fileManager = "ranger";
      assistant = {
              enabled = true;
              version = "2";
              default_open_ai_model = null;
            };

      # Node.js Configuration
      node = {
        path = lib.getExe pkgs.nodejs;
        npm_path = lib.getExe pkgs.nodejs "npm";
      };

      # General Settings
      hour_format = "hour24";
      auto_update = false;
      vim_mode = false;
      load_direnv = "shell_hook";
      base_keymap = "VSCode";

      # Terminal Configuration
      terminal = {
        alternate_scroll = "off";
        blinking = "off";
        copy_on_select = false;
        dock = "bottom";

        detect_venv = {
          on = {
            directories = [".env" "env" ".venv" "venv"];
            activate_script = "default";
          };
        };

        env = { TERM = "kitty"; };
        font_family = "FiraCode Nerd Font";
        font_features = null;
        font_size = null;
        line_height = "comfortable";
        option_as_meta = false;
        button = false;
        shell = "system";
        toolbar = { title = true; };
        working_directory = "current_project_directory";
      };

      # Language Server Protocol (LSP) Configuration
      lsp = {
        rust-analyzer = {
          binary = {
            path = lib.getExe pkgs.rust-analyzer;
            path_lookup = true;
          };
        };

        nix = {
          binary.path_lookup = true;
        };

        elixir-ls = {
          binary.path_lookup = true;
          settings.dialyzerEnabled = true;
        };
      };

      # Language-specific Settings
      languages = {
        "Elixir" = {
          language_servers = ["!lexical" "elixir-ls" "!next-ls"];
          format_on_save = {
            external = {
              command = "mix";
              arguments = ["format" "--stdin-filename" "{buffer_path}" "-"];
            };
          };
        };

        "HEEX" = {
          language_servers = ["!lexical" "elixir-ls" "!next-ls"];
          format_on_save = {
            external = {
              command = "mix";
              arguments = ["format" "--stdin-filename" "{buffer_path}" "-"];
            };
          };
        };
      };

      # UI Configuration
      theme = {
        mode = "dark";
        light = "One Light";
        dark = "One Dark";
      };

      show_whitespaces = "all";
      ui_font_size = 16;
      buffer_font_size = 16;
    };
  };
}

That's it!

running and troubleshooting tips

you switch to the new config with home-manager switch --flake .#myprofile.

If any errors show up and it doesn't build.

  • Google around.
  • Ask the forum (they are really nice).
  • Or just ask your AI of choice.

if your switch did work but it created a broken command or other bug run home-manager generations. Take the latest generations path. add /activate to the end. And with that you should be back at the last generation.

From there you should go and investigate the unstable packages first. With nix.shell or just wait a week and try again.

If you want to see the up-to-date version of these files, check out this Git repository. The config is constantly changing. Use at your own risk. (also there is a Makefile in there for those who dont wanna remember the whole command)

You made it. If you want to reach out consider following me on bsky @doxx.casino