🔍 Previewing nix-managed dotfiles

Some time ago I wrote an article ⚙️ Managing dotfiles with Nix. Since then not many things have changed in my repository, but recently I managed to implement a feature, which I’ve been aware of for quite a long time: previewing dotfiles.

How does it look like?

Better to see something once than to hear about it a thousand times, so here is how it works for my dotfiles:

Preview

There are numerous ways to implement it, but I believe the most correct ones are:

  • Run directly via nix develop in temporary HOME directory.
  • Build docker container using nix, make it take care of dependencies and stuff, then run.

Implementing previewing via nix-shell (nix develop)

Here I describe a case when you manage your configuration with a standalone home-manager installation, not as a part of NixOS module. It’s still possible to implement for NixOS case too, but probably minor tweaks are required.

I assume you have configured flake.nix and home.nix files and you can initialize your configuration using a command like:

home-manager init --switch $HOME/.dotfiles/ --flake $HOME/.dotfiles/

Now let’s see how to make your configuration nix develop-friendly.

First, you have to make your home.nix parameterized by two arguments, homeDirectory and username. We need this to be able to initialize our dotfiles in a non-hardcoded home directory. We can do it like this, flake.nix:

{
  # ...
  outputs = { self, nixpkgs, home-manager, ... }@inputs:
    let
      myHomeManagerConfiguration = { useSymlinks, homeDirectory, username, dotfilesDirectory }@extraSpecialArgs:
        home-manager.lib.homeManagerConfiguration {
          inherit pkgs extraSpecialArgs;

          modules = [
            ./home.nix
          ];
        };
    in {
      # ...

      homeConfigurations = {
        "seroperson" = myHomeManagerConfiguration {
          useSymlinks = true;
          homeDirectory = "/home/seroperson/";
          username = "seroperson";
          dotfilesDirectory = "/home/seroperson/.dotfiles";
        };
      };
    };
}

And home.nix:

{
  config,
  pkgs,
  useSymlinks,
  homeDirectory,
  username,
  dotfilesDirectory,
  ...
}:
let
  inherit (import ./nix/utils.nix { inherit config useSymlinks dotfilesDirectory; })
    fileReference;
in {
  home = {
    inherit homeDirectory username;
    stateVersion = "24.05";
  };

  xdg.mime.enable = false;
  xdg.configFile = {
    "git" = {
      source = fileReference ./git;
      recursive = true;
    };

    # ...
  };

  # https://github.com/nix-community/home-manager/issues/2995
  programs.man.enable = false;

  # ...
}

And also we need to define function fileReference in nix/utils.nix, which returns relative path in dotfilesDirectory or path in /nix/store/ depending on useSymlinks value. It allows us to build both immutable and mutable (via symlinking) dotfiles.

{
  dotfilesDirectory,
  config,
  useSymlinks
}: rec {
  baseName = p:
    let
      removeNixStorePrefix = nsp:
        let
          m = builtins.match "/nix/store/[^-]+-(.*)" ( toString nsp );
        in if m == null then
          nsp
        else
          (builtins.head m);
    in builtins.baseNameOf (removeNixStorePrefix p);

  fileReference = path:
    if useSymlinks
      then config.lib.file.mkOutOfStoreSymlink "${dotfilesDirectory}/${baseName path}"
      else path;
}

Be careful with this implementation, as it doesn’t support nested directories. For example:

{
  xdg.configFile = {
    # Works
    "git" = {
      source = fileReference ./git;
      recursive = true;
    };

    # Works
    "zsh" = {
      source = fileReference ./zsh;
      recursive = true;
    };

    # ...
  };

  # Works
  home.file.".zshenv" = {
    source = fileReference ./.zshenv;
  };

  # Doesn't work
  home.file.".zshenv" = {
    source = fileReference ./zsh/.zshenv;
  };
}

So everything you’re going to reference must be in the git root directory.

Okay, now re-initialize your configuration and make sure everything works. After you have done this, our next step is to define our shell output in flake.nix:

{
  # ...
  outputs = { self, nixpkgs, nixpkgs-unstable, home-manager, ... }:
    let
      myHomeManagerConfiguration = { useSymlinks, homeDirectory, username, dotfilesDirectory }@extraSpecialArgs:
        home-manager.lib.homeManagerConfiguration {
          # ...
        };
    in {
      devShells.${system}.default = pkgs.mkShell rec {
        homeDirectory = builtins.getEnv "HOME";
        username = builtins.getEnv "USER";

        activationPackage = (myHomeManagerConfiguration {
          inherit homeDirectory username;
          useSymlinks = false;
          dotfilesDirectory = "";
        }).activationPackage;

        buildInputs = [
          activationPackage
          pkgs.nix
        ];

        shellHook = ''
          export HOME=${homeDirectory}
          export USER=${username}
          # Fixes `Could not find suitable profile directory` error
          mkdir -p ${homeDirectory}/.local/state/nix/profiles
          ${activationPackage}/activate
          # Run zsh and then exit
          IS_PREVIEW=1 exec $HOME/.nix-profile/bin/zsh
        '';
      };

      # ...
    };
}

Here we define a development shell, which has our dotfiles as a dependency, initializes it using HOME and USER environment variables, and calls home-manager’s /activate script in shellHook to do symlinking. I have also used the IS_PREVIEW variable to indicate that we’re in a simulated environment to be able to disable some unnecessary functionality (like ssh-agent initialization).

Now go and check the result:

mkdir -p /tmp/test
USER=seroperson-preview HOME=/tmp/test nix develop --impure github:seroperson/dotfiles

--impure flag is important as we’re accessing environment variables.

Building your dotfiles into a container

It could be enough to preview everything with nix develop, but I haven’t been calm with the idea of bundling my environment into a Docker container. Moreover, I believe such an approach has more use-cases besides previewing dotfiles, and there is really not much information on this topic.

Of course, we can bundle everything manually using plain old Dockerfile, git clone your repository and so on, but that’s not a nix way. We want to build image using nix to leverage all its’ pros.

It’s not as easy to implement correctly, as you need a totally working nix environment inside your container, and it’s not enough to just add your dotfiles as a dependency, like we did with nix develop, and do dockerTools.buildImage.

If you dig into this topic enough, you’ll probably find an issue, which helped me to implement it correctly. To be more precise, this comment by cameronraysmith, author of nixpod, pointed me in the right direction.

In short, you need to implement a wrapper around dockerTools.buildImage with complete nix environment initialization, which you can mostly take from the nix repository. I edited it just a little, but the listing is too long to paste here, so you can look at it in my repository.

Assuming we placed this file in nix/docker.nix, here is how we implement docker output:

{
  # ...
  outputs = { self, nixpkgs, nixpkgs-unstable, home-manager, ... }:
    let
      myHomeManagerConfiguration = { useSymlinks, homeDirectory, username, dotfilesDirectory }@extraSpecialArgs:
        home-manager.lib.homeManagerConfiguration {
          # ...
        };
    in {
      packages.${system}.docker = (import ./nix/docker.nix {
        pkgs = pkgs;
        name = "seroperson.me/dotfiles";
        tag = "latest";
        extraPkgs = with pkgs; [
          ps
          gnused
          coreutils
        ];

        extraContents = [
          (myHomeManagerConfiguration {
            useSymlinks = false;
            homeDirectory = "/root";
            username = "root";
            dotfilesDirectory = "";
          }).activationPackage
        ];
        extraEnv = [ "IS_PREVIEW=1" ];
        rootShell = "/root/.nix-profile/bin/zsh";
        cmd = [ "/bin/sh" "-c" "/activate && exec /root/.nix-profile/bin/zsh" ];
        nixConf = {
          allowed-users = [ "*" ];
          experimental-features = [
            "nix-command"
            "flakes"
          ];
          max-jobs = [ "auto" ];
          sandbox = "false";
          trusted-users = [
            "root"
          ];
        };
      });

      # ...
    };
}

What we do here is initialize our dotfiles for the root user, copy all dotfiles content to the container, and then run /activate and our zsh shell.

Now you can try to build this and run:

nix build .#docker
docker load < ./result
docker run --rm -it seroperson.me/dotfiles

The result will be probably a gif which you’ve seen above.

Conclusion

That’s it. Again, reference to my repository to see how everything works.

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: