🔍 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:
There are numerous ways to implement it, but I believe the most correct ones are:
- Run directly via
nix develop
in temporaryHOME
directory. - Build docker container using
nix
, make it take care of dependencies and stuff, then run.
Implementing previewing via nix-shell (nix develop)
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
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: