I’ve been managing ⚙️ my dotfiles for quite a while now. Initially, it was just a collection of configuration files that required manual setup, with the hope that everything would function properly. I’ve always been in search of a better way to organize them, and I believe I’ve finally found one.

At first glance, managing dotfiles seems like an easy task. But as you dive deeper, you may encounter many problems. Picture this: you’re a novice developer who has decided to share his dotfiles (which include zsh, vim, and other items) by creating a GitHub repository. Everything goes smoothly until you have to “install” these dotfiles on another machine. Here are some potential problems you might face after cloning the repository:

  • Each configuration file must be placed (or “installed”) in corresponding location. For example, dotfiles/.vimrc should go to $HOME/.vimrc, dotfiles/.zshrc to $HOME/.zshrc and so on.
  • All the dependencies must be installed.
  • (optionally) Should be OS-independent.
  • (optionally) Be able to uninstall dotfiles and all its’ dependencies.
  • (optionally) It should be easy to try / to install.

Initially, I managed my dotfiles repository manually within a bare git repository, without using any management tool. Although this approach worked, it required significant manual effort. Every time I deployed dotfiles, I faced new challenges, which prompted me to improve my dotfiles management. That’s how I started using Nix for all tasks.

It’s worth noting that Nix can be challenging to learn and use. Coding and debugging can be frustrating. However, its features make the effort worthwhile, which is why we’re using it here. If you decide to continue with it, you might find it beneficial in other applications such as NixOS, reproducible CI builds, and isolated project environments.

Let’s examine step-by-step how we can address the mentioned problems using Nix, and why we should choose it for these tasks. I won’t detail every point as, to be honest, I’m still in the process of implementing them myself, but rest assured, all of them are certainly feasible.

Nix vs. well-known solutions

There are numerous solutions designed to alleviate the issues of “dotfiles hell”, but none of them are perfect. Some options include chemzoi, ydm, GNU Stow, dotbot. Using any of these tools is preferable to using none at all, as a bare git repository requires even more manual effort. While these tools can simplify the process, none of them is a silver bullet.

Using Nix, specifically home-manager, for dotfiles management has several advantages over other tools:

  • Your environment is defined by code, not configuration files or manual interactions.
  • Nix includes a package manager, allowing you to describe not only how to install your dotfiles, but also their dependencies. The repository is extensive and should cover all your needs. If a package isn’t available, there are numerous ways to install it from a git repository or another source.
  • Everything is isolated so your system isn’t cluttered with dotfiles dependencies.

However, to use Nix correctly, you’ll still need to write a fair amount of code. Despite this, it is less error-prone and less complicated than manual shell scripting.

Presequences

This article doesn’t aim to duplicate existing guides for installing Nix; instead, I recommend referring to the following well-curated guides:

Also, after installing Nix be sure to create file $HOME/.config/nix/nix.conf with the following content to be comfortable while using Nix’s CLI:

experimental-features = nix-command flakes

Managing dotfiles with Nix

After following all the instructions provided, you will have a folder with home-manager’s configuration that looks like this:

$ tree $HOME/.dotfiles -a -I .git
/home/seroperson/.dotfiles
├── .config/     # My actual dotfiles (zsh, nvim, git etc confs)
├── flake.lock
├── flake.nix
└── home.nix

In this example, we’re storing all our dotfiles in the $HOME/.dotfiles/.config folder (you may want to rename it) which we will link to their actual locations. The flake.nix contains some flake configurations, but currently, the home.nix is the most interesting to look at. It contains the home-manager configuration where we will define all our packages and other settings.

My home.nix looks like so:

{ callPackage, config, pkgs, ... }:

{
  # ...
  # Some default configuration
  # ...

  home.packages = [
    pkgs.git
    pkgs.neovim
    pkgs.zsh
    # ...
  ];

  home.file.".zshenv" = {
    source = config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/.dotfiles/.config/zsh/.zshenv";
  };

  xdg.configFile = {
    "zsh" = {
      source = config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/.dotfiles/.config/zsh";
      recursive = true;
    };
    # ...
  };
}

So, let’s check step-by-step what do we see here:

home.packages = [
  pkgs.git
  pkgs.neovim
  pkgs.zsh
  # ...
];

This is where we describe our packages to be installed from the nixpkgs repository. If a package is not available in the repository (which is rare), you can define your own. However, this requires some advanced Nix knowledge. If you plan to do so, the following guides are a good starting point: Our First Derivation, Derivations.

Let’s look at next snippet:

home.file.".zshenv" = {
  source = config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/.dotfiles/.config/zsh/.zshenv";
};

xdg.configFile = {
  "zsh" = {
    source = config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/.dotfiles/.config/zsh";
    recursive = true;
  };
  # ...
};

This section of the code links our configuration files to their expected paths. Specifically, it links $HOME/.dotfiles/.config/zsh/.zshenv (which contains my zsh configuration) to $HOME/.zshenv and the $HOME/.dotfiles/.config/zsh folder to $XDG_CONFIG_HOME/zsh (or $HOME/.config/zsh).

This basic setup is sufficient for configuring simple dotfiles. To incorporate all your *.nix changes, execute the following command (this command may vary depending on how you followed the previous guides):

nix run home-manager/release-23.11 -- init --switch $HOME/.dotfiles/

Configuring things in Nix-way

An important thing to note about our method is that it isn’t the true-Nix-style, often referred to as “impure”. We link our actual dotfiles to their locations via Nix, which allows for direct and easy editing, much like traditional methods. For example, vim $HOME/.zshenv will open our $HOME/.dotfiles/.config/zsh/.zshenv. We can then edit, save, close, then open a new zsh instance or reload the current one and voilà - your changes have been applied.

Doing things in Nix-way, all configuration content should be “immutable”. Ideally, all your configuration should reside inside .nix files. This eliminates the need for standalone files such as tmux.conf, .zshrc, git/config, and so on. However, having everything inside .nix files means you’ll need to rebuild home-manager (run the command above) after each edit. This process removes the convenience of hot-reloads within a shell or editor, making edits more cumbersome. Despite this, there are some advantages.

Let’s look how to define something in Nix-way. For example, some popular tools have built-in DSL to build a config:

# home.nix
programs.git = {
  enable = true;
  userName = "seroperson";
  userEmail = "seroperson@gmail.com";
};

It results in file $HOME/.config/git/config:

[user]
    email = "seroperson@gmail.com"
    name = "seroperson"

Sometimes it may come in handy, as this config now can be literally coded. As an example, you can code how to resolve userName variable and it won’t be static. Of course, you also will have to rebuild home-manager after each edit to make everything applied.

There are numerous predefined programs available, including zsh, fish, vim, neovim, tmux and others. You can search for them here and here (right inside of home-manager git repository).

Configuring tmux

Let’s say we need to set up the tmux.conf configuration file, which has some plugins installed via the tmux plugin manager. If you’re using a bare git repository to manage your dotfiles, you’ll likely have some shell code to git clone the plugin manager repository. You’ll also need code to check if it’s already been cloned. Ideally, you’d want to automatically install all the plugins defined in tmux.conf, however, it isn’t easy to accomplish with shell code.

That’s where using the Nix API to configure things becomes a preferable option. Here’s what the result might look like:

# nix/tmux.nix
{ pkgs, ... }:
let
  # tmux-yank = pkgs.tmuxPlugins.mkTmuxPlugin {
  #   pluginName = "tmux-yank";
  #   version = "2.3.0";
  #   src = pkgs.fetchFromGitHub {
  #     owner = "tmux-plugins";
  #     repo = "tmux-yank";
  #     rev = "acfd36e4fcba99f8310a7dfb432111c242fe7392";
  #     sha256 = "";
  #   };
  # };
in {
  programs.tmux = {
    enable = true;
    historyLimit = 100000;
    keyMode = "vi";
    escapeTime = 0;
    baseIndex = 1;
    plugins = with pkgs;
      [
        {
          plugin = tmuxPlugins.catppuccin;
          extraConfig = ''
set -g @catppuccin_flavour 'mocha'
# ...
set -g @catppuccin_date_time_text "%H:%M:%S"
          '';
        }
      ];
    extraConfig = ''
set -g prefix C-t
bind n next-window
bind p previous-window
# ...
    '';
  };
}

# home.nix

# ...
{
  imports = [
    ./nix/tmux.nix
  ];
}

You can now safely remove tmux.conf. It will be automatically built, and all plugins will also be installed automatically. The only uncomfortable things now are that you have to rebuild home-manager after each edit and also it is not so comfortable to edit .nix file with bundled configuration comparing to plain tmux.conf.

Configuring AstroNvim

This section is outdated a little bit as it was written for AstroNvim < v4.

Next, we’ll examine how to configure the community-driven nvim distribution, AstroNvim, which I personally use. In short, the installation involves cloning the distribution repository to $HOME/.config/nvim/ and then adding your custom options to $HOME/.config/astronvim/lua/user/. If you are manually managing your dotfiles, you will most likely need to clone the repository via shell code and then check again if it has already been cloned or perhaps use a git submodule, which I don’t particularly favor. To do everything with Nix, you simply have to:

# home.nix
{
  # ...
  xdg.configFile = {
    # ...
    "nvim" = {
      source = pkgs.fetchFromGitHub {
        owner = "AstroNvim";
        repo = "AstroNvim";
        rev  = "271c9c3f71c2e315cb16c31276dec81ddca6a5a6";
        sha256 = "h019vKDgaOk0VL+bnAPOUoAL8VAkhY6MGDbqEy+uAKg=";
      };
    };
    # AstronVim allows you to separate custom configuration from repository itself
    # docs.astronvim.com/configuration/manage_user_config/#setting-up-a-user-configuration
    "astronvim/lua/user" = {
      source = config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/.dotfiles/.config/astronvim";
      recursive = true;
    };
  };
}

My $HOME/.dotfiles/.config/astronvim directory contains the init.lua, options.lua, and mappings.lua files, which hold all the custom configuration for AstroNvim. The distribution will automatically be cloned by Nix during the next home-manager rebuild.

It’s worth noting that there’s also a nixvim distribution, a nixified version of nvim. However, it’s entirely up to you whether to use it or not.

Conclusion

So, that’s it. Nix can be tricky sometimes, but as stated earlier, the effort pays off with its features. My dotfiles are far from ideal as they require quite a bit of time to perfect, still there are points (which I described in the beginning of article) to be implemented, but even in their current state, everything feels much better than manual management.

Thank you for reading this little article, I hope it will be useful to someone. Feel free to reach me if you have something to say.

And also take a look on the posts on similar topics: