Managing mutable files in NixOS

Some weeks ago, I’ve made a post describing how to combine traditional dotfiles and NixOS config with Nix flakes. That seems all well and good but it comes with a few problems.

  • Changes from the dotfiles have to be pushed first to the remote repo.

  • Then, the dotfile flake input have to be updated. After pushing changes from the dotfiles, you have to update it as depicted from Workflows and its caveats. While managing with flakes is less tedious than managing it with fetchers, you’re essentially just reducing the source of updates from two to one.

While it is reproducible, it’s a tedious process especially if you need immediate changes from your dotfiles.

One of the responses I got from the aforementioned post is an alternative solution for managing dotfiles.

I’m more likely to use home-manager’s mkOutOfStoreSymlink if I find myself in situations where I need non-generational or mutable file content. I have a trivial personal HM module that git-clones things to a desired path as part of the activation script if they’re absent and then manage them out of band from then on.

— /u/mtndewforbreakfast

Long story short, I tried this approach and I found it to be a better solution overall. It is more flexible and lends itself as a great solution for managing mutable files — files that are not meant to be managed by Nix. This also reduces the things I need to do post-installation which is already contained in a script so that’s a nice benefit. Anyways, here’s my take on the posted solution.

A better way to manage traditional dotfiles in home-manager

As hinted from the quoted statement, mkOutOfStoreSymlink is a home-manager function that accepts a path string and returns a derivation. This derivation contains a builder script that links the given path to a store path.

Using it should be simple. The following listing simply links my dotfiles located on /home/foo-dogsquared/dotfiles and creates a path on the Nix store.

An example of using the function
mkOutOfStoreSymlink "/home/foo-dogsquared/dotfiles"

This pretty much allows us to interact with various options from home-manager that normally accepts a store path. In my case, I mainly use it for linking various files with home.file.<name>.source, xdg.configFile.<name>.source, and so forth. To give some more context, here’s an example usage of the function with my use case.

What is dotfiles yet again?

Just to continue the tradition from the last post, dotfiles is now a derivation from mkOutOfStoreSymlink. The very same type as to what fetchFromGitHub returns. Since it is a derivation, it will evaluate to the output path if coerced into a string which should be a store path that is symlinked to the dotfiles. This is why the code works still unchanged for the most part.

Compared to the approach of making the dotfiles as a flake input, this reduces the reproducibility of our home-manager configuration a little bit. Instead of fully including the dotfiles, we only assume we have the dotfiles at the given location. However, this does remove the workflow of managing the flake input and its caveats altogether.

You don’t have to do nix flake update or anything else in your NixOS config and manage them separately. We’re compromising reproducibility with this but it is worth it considering I want the changes immediately.

Adding a declarative interface for fetching mutable files

Take note with the above method, we did reduce from fully including the dotfiles to only assuming we have the dotfiles. I still want to include the dotfiles declared somewhere in the configuration. The closest we’ll ever get is to create a module that accepts a list of files to download and put it in the filesystem which is exactly what I did. Anyhoo, here’s how I would use the imaginary module.

{
  home.mutableFile = {
    "${config.xdg.userDirs.documents}/dotfiles" = {
      url = "https://github.com/foo-dogsquared/dotfiles.git";
      type = "git";
    };

    "${config.xdg.userDirs.documents}/top-secret" = {
      url = "https://example.com/file.zip";
      type = "fetch";
    };
  };
}

This module is meant to be used for fetching mutable files. It would have different methods for fetching the file indicated by the type attribute. For the initial version of this module, we’ll consider two use cases: cloning the Git repos and downloading the file. Let’s first create the skeleton for the module.

modules/home-manager/fetch-mutable-files.nix
{ config, options, lib, pkgs, ... }:

let
  cfg = config.home.mutableFiles;
  file = { config, name, ... }: {
  };
in
{
  options.home.mutableFile = lib.mkOption {
    type = with lib.types; attrsOf (submodule file);
    default = { };
    description = lib.mkDoc ''
      An attribute set of mutable files and directories to be fetched into the
      home directory.
    '';
    example = lib.literalExpression ''
      "''${config.xdg.userDirs.documents}/dotfiles" = {
        url = "https://github.com/foo-dogsquared/dotfiles.git";
        type = "git";
      };

      "''${config.xdg.userDirs.documents}/top-secret" = {
        url = "https://example.com/file.zip";
        type = "fetch";
      };
    '';
  };

  config = {
    systemd.user.services.fetch-mutable-files = {
    };
  };
}

We have yet to define certain parts including what each attribute could contain. Each of the attribute in the home.mutableFile.<name> expects at least two attributes: the URL to be downloaded and the download method. The file should only be downloaded if the path doesn’t exist.

At this point, updates to the code are shown as diffs. It is meant to be used with git apply and similar tools.

git apply file.patch
diff --git b/modules/home-manager/fetch-mutable-files.nix a/modules/home-manager/fetch-mutable-files.nix
index 9c66e05..aa00cac 100644
--- b/modules/home-manager/fetch-mutable-files.nix
+++ a/modules/home-manager/fetch-mutable-files.nix
@@ -2,12 +2,47 @@

 let
   cfg = config.home.mutableFiles;
-  file = { config, name, ... }: {
+  file = baseDir: { config, name, ... }: {
+    options = {
+      url = lib.mkOption {
+        type = lib.types.str;
+        description = lib.mkDoc ''
+          The URL of the file to be fetched.
+        '';
+        example = "https://github.com/foo-dogsquared/dotfiles.git";
+      };
+
+      path = lib.mkOption {
+        type = lib.types.str;
+        description = lib.mkDoc ''
+          Output path of the resource relative to ${baseDir}.
+        '';
+        default = name;
+        apply = p:
+          if lib.hasPrefix "/" p then p else "${baseDir}/${p}";
+      };
+
+      type = lib.mkOption {
+        type = lib.types.enum [ "git" "fetch" ];
+        description = lib.mkDoc ''
+          Type that configures the behavior for fetching the URL.
+
+          This accept only certain keywords.
+
+          - For `fetch`, the file will be fetched with `curl`.
+          - For `git`, it will be fetched with `git clone`.
+
+          The default type is `fetch`.
+        '';
+        default = "fetch";
+        example = "git";
+      };
+    };
   };
 in
 {
   options.home.mutableFile = lib.mkOption {
-    type = with lib.types; attrsOf (submodule file);
+    type = with lib.types; attrsOf (submodule (file config.home.homeDirectory));
     default = { };
     description = lib.mkDoc ''
       An attribute set of mutable files and directories to be fetched into the

Take note we also added the path attribute that comes with a function to handle the path. It’s for cleanly passing absolute paths and relative paths when it needs to.

{
  # Absolute paths should be acceptable.
  home.mutableFile."${config.xdg.userDirs.documents}/top-secret" = { };
  home.mutableFile."${config.xdg.configHome}/doom" = { };
  home.mutableFile."${config.home.homeDirectory}/hello" = { };
  home.mutableFile."/home/foo-dogsquared/writings" = { };

  # So does relative paths...
  home.mutableFile."dotfiles" = { };
  home.mutableFile."library" = { };
}

With the interface done, we can then proceed with the implementation which is just a shell script managed by systemd. Let’s first build the systemd service before we proceed with the shell script.

Remember, we’re using systemd to manage the service. The service is run in a completely new environment and isn’t in a shell with programming features like Bash and zsh. This means you cannot run the following command on Service.ExecStart directive like how one would expect on the shell.

curl "https://example.org" || echo "ERROR"
diff --git b/modules/home-manager/fetch-mutable-files.nix a/modules/home-manager/fetch-mutable-files.nix
index aa00cac..3d75414 100644
--- b/modules/home-manager/fetch-mutable-files.nix
+++ a/modules/home-manager/fetch-mutable-files.nix
@@ -63,6 +63,25 @@ in

   config = {
     systemd.user.services.fetch-mutable-files = {
+      Unit = {
+        Description = "Fetch mutable files from home-manager";
+        After = [ "default.target" "network-online.target" ];
+        Wants = [ "network-online.target" ];
+      };
+
+      Service = {
+        # We'll assume this service will download lots of files. We want the
+        # temporary files to only last along with the service.
+        PrivateUsers = true;
+        PrivateTmp = true;
+
+        Type = "oneshot";
+        RemainAfterExit = true;
+        # TODO: Complete this
+        ExecStart = "";
+      };
+
+      Install.WantedBy = [ "default.target" ];
     };
   };
 }

Creating the shell script should be trivial. We could generate the entire script by iterating each of the file from home.mutableFile.<name> and mapping the methods from it. Here’s one way to let Nix generate our shell script featuring writeShellScript builder.

diff --git b/modules/home-manager/fetch-mutable-files.nix a/modules/home-manager/fetch-mutable-files.nix
index 3d75414..c3e349b 100644
--- b/modules/home-manager/fetch-mutable-files.nix
+++ a/modules/home-manager/fetch-mutable-files.nix
@@ -77,8 +77,22 @@ in

         Type = "oneshot";
         RemainAfterExit = true;
-        # TODO: Complete this
-        ExecStart = "";
+        ExecStart = let
+          curl = "${lib.getBin pkgs.curl}/bin/curl";
+          git = "${lib.getBin pkgs.curl}/bin/git";
+          fetchCmds = lib.mapAttrsToList (file: value:
+            let
+              inherit (value) type;
+              path = lib.escapeShellArg value.path;
+              url = lib.escapeURL value.url;
+            in ''
+              ${lib.optionalString (type == "git") "[ -d ${path} ] || ${git} clone ${url} ${path}"}
+              ${lib.optionalString (type == "fetch") "[ -d ${path} ] || ${curl} ${url} --output ${path}"}
+            '') cfg;
+          shellScript = pkgs.writeShellScript "fetch-mutable-files" ''
+            ${lib.concatStringsSep "\n" fetchCmds}
+          '';
+        in builtins.toString shellScript;
       };

       Install.WantedBy = [ "default.target" ];

For those who want a complete version of the module directly without applying all of the above patches, you can see it with this link.

With the module being complete for the most part, we just have to include it to our home-manager configuration…​

flake.nix
{
  outputs = { nixpkgs, home-manager, ... }@inputs: {
    homeConfigurations.foodogsquared = home-manager.lib.homeManagerConfiguration {
      modules = [
        ./modules/home-manager/fetch-mutable-files.nix
        ./home.nix
      ];
    };
  };
}

…​and finally use it. Like I said previously, the nice thing with this module for me is allowing me beyond fetching my dotfiles. I could, for example, fetch Doom Emacs alongside my home-manager configuration. Very nice!

home.nix
{ config, lib, pkgs, ... }:

let
  dotfiles = config.lib.file.mkOutOfStoreSymlink config.home.mutableFile."dotfiles".path;
in
{
  home.mutableFile = {
    "dotfiles" = {
      url = "https://github.com/foo-dogsquared/dotfiles.git";
      type = "git";
    };

    "${config.xdg.configHome}/emacs" = {
      url = "https://github.com/doomemacs/doomemacs";
      type = "git";
    };
  };

  # Putting the dotfiles in their rightful place.
  xdg.configFile = {
    doom.source = "${dotfiles}/emacs";
    wezterm.source = "${dotfiles}/wezterm";
    nvim.source = "${dotfiles}/nvim";
  };
}
Some room for improvement

The given code from this post is just one minimal starting point for this module. In my NixOS configuration, my version of the module has expanded with the ability to declare archived files which is already extracted in the filesystem. I use it for fetching several things including Doom Emacs (even automatically installing it).

Starting from the version of the module featured here, there are room for improvement. You could implement the following suggestions as an exercise.

  • Add a type for fetching archived files. The archive should already be extracted into the path. Additionally, you could add an option for extracting a single file or directory.

  • Each resource to be fetched may require different tweaks. For example, you may want to shallow clone Doom Emacs since the repo history is too large. You might want to add an option (e.g., home.mutableFile.<name>.extraArgs) to set extra arguments to each file.

  • Add the option to allow changing the package to be used for the shell script. This would also require restructuring (and possibly renaming) of the module though.

  • Add an attribute that links to a store path (e.g., home.mutableFile.<name>.outPath) for each of the given URL. You could also add an attribute (e.g., home.mutableFile.<name>.dontLinkToStore) that either opts-in/-out of including the file in the store directory. Take note this value should be automatically applied and shouldn’t be set by the user.

Mutable files in NixOS

So far, we only manage them mutable files in home-manager. I cannot find an equivalent in NixOS but it should be pretty trivial to recreate it especially that the things that made mkOutOfStoreSymlink possible is readily available in nixpkgs. All we have to do is to recreate them.

For this case, we’ll use the runCommandLocal builder typically used for cheap commands. This also what mkOutOfStoreSymlink uses in the source code.

let
  path = lib.escapeShellArg "/etc/dotfiles";
in
pkgs.runCommandLocal "nixos-mutable-file-${builtins.baseNameOf path}" { } ''ln -s ${path} $out''

Similarly, this can be used for various NixOS options that normally accepts a store path (e.g., environment.etc.<name>.source). For example, if you have a minimal i3 setup that you want to link from a non-NixOS-managed folder, all you have to do is to link it from the dotfiles.

{ config, lib, pkgs, ... }:

let
  path = lib.escapeShellArg "/etc/dotfiles";
  dotfiles = pkgs.runCommandLocal "nixos-mutable-file-${builtins.baseNameOf path}" { } ''ln -s ${path} $out'';
in
{
  enviroment.etc = {
    "i3".source = "${dotfiles}/i3";
    "polybar".source = "${dotfiles}/polybar";
    "zathurarc".source = "${dotfiles}/zathura/zathurarc";
  };
}
Implementing a similar module for NixOS

If you want to create a declarative interface similar to the featured module for NixOS, you can create your implementation based from that. However, there’s more moving parts that you have to worry about since we’re using NixOS where the scope includes the whole system unlike with home-manager where it focuses on the home environment. Here are some things you may need to pay attention to.

  • Since we’re using system-level services with systemd, the fetched files will be owned the user of the fetching service (which is root by default). You may want to add an option for the group and owner for each files.

  • Reject (or at least discourage) relative paths since it will be confusing to use. It’s best if the module encourages the user to use absolute paths instead.

  • Making sure the fetching service does not modify the generated files from NixOS. Even though this is already handled by the previous module, it is something to keep in mind now that we’re modifying the whole operating system.