Combining traditional dotfiles and NixOS configurations with Nix flakes

If you’re in a similar situation like mine where…​

  • You still use other distributions that are not NixOS alongside NixOS.

  • You want your most important parts of your configuration (dotfiles) to be separate from your NixOS configurations for reasons (e.g., easier to manage, you just find modularization satisfying).

  • You want a way to integrate them together nicely.

…​then you’re in luck because I’ll share a nice way to do exactly those.

In my case, I still have my "traditional" dotfiles in a separate repository instead of combining them under a monorepo. Fortunately in NixOS, there are a variety of ways to combine them but in this post, I’m focusing on doing this with Nix flakes which is a newfangled way to manage dependencies in a Nix environment.

This is also a nice use case for those who are still in edge using NixOS which has the impression of requiring you to fully commit the configuration only with NixOS which isn’t always the case. I hope to show that it is possible to meet the middle ground.

As of Nix v2.14, Nix flakes are still considered experimental so treat it as if disruptive changes are the norm (e.g., nix flake subcommands and options may change). However, this feature has been in place for at least years now which gained some parts of the Nix ecosystem to use it (e.g., nixpkgs, home-manager, digga).

I chose to feature flakes since I have used it for the past year. Also, I just think it’s better compared to the traditional way which I’ve also delved into more details at Why flakes anyways?.

Prerequisites

For this post, I’m assuming that you have already set your home-manager and NixOS configurations with Nix flakes. This means you would have enabled flakes and the new Nix CLI (i.e., experimental-features = nix-command flakes in nix.conf).

Specifically, we’ll be assuming that you have something like the following configuration where you have one NixOS configuration (i.e., nixosConfigurations.desktop) and a home-manager configuration (i.e., homeConfigurations.foodogsquared).

You don’t need the exact same setup, it is just for us to get on the same page.

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";

    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { nixpkgs, home-manager, ... }@inputs: {
    nixosConfigurations.desktop = nixpkgs.lib.nixosSystem {
      modules = [
        # The NixOS configuration.
        ./configuration.nix
      ];
    };

    homeConfigurations.foodogsquared = home-manager.lib.homeManagerConfiguration {
      # The home-manager configuration.
      modules = [ ./home.nix ];
    };
  };
}

If you’re not familiar with flakes and only looking for an example, I recommend to look into this flake template by Misterio77. I recommend especially that it has a minimal and standard NixOS and home-manager setup with flakes and it gives you starting points for using flakes. Pretty nifty.

Adding the dotfiles in the flake

Flakes essentially takes in inputs and exports outputs which we usually define in a file named flake.nix at the project root. These inputs are commonly other flakes such as nixpkgs and home-manager.

flake.nix
inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";

  home-manager = {
    url = "github:nix-community/home-manager";
    inputs.nixpkgs.follows = "nixpkgs";
  };

  flake-utils.url = "github:numtide/flake-utils";
};

However, flake inputs can also be non-flakes which is what enables me to include my "traditional" dotfiles.

flake.nix
inputs = {
  # ...

  dotfiles = {
    url = "github:foo-dogsquared/dotfiles";
    flake = false;
  };
}

Integrating with home-manager and NixOS

Now that the dotfiles is included in the flake, it is just a matter of using it. In this case, I want to include part of my dotfiles such as my Wezterm, Neovim, and Doom Emacs configuration alongside the home-manager environment.

First, we’ll have to include the dotfiles flake input as part of the home-manager profile. In the case of enabling it in the home-manager configurations, we have to pass it through the extraSpecialArgs attribute from the function that creates a home-manager profile (i.e., home-manager.lib.homeManagerConfiguration).

flake.nix
{
  # ...

  outputs = { nixpkgs, home-manager, ... }@inputs: {
    homeConfigurations.foodogsquared = home-manager.lib.homeManagerConfiguration {
      # The home-manager configuration.
      modules = [ ./home.nix ];

      extraSpecialArgs = {
        inherit (inputs) dotfiles;
      };
    };
  };
}

Now, we have to do what we want to do: include part of the dotfiles in the home directory. Fortunately, this part is already handled for us since home-manager has several options to include files inside of the home directory (i.e., home.file, xdg.configFile). The following code listing is one way to do what I want to do.

Don’t forget to add the additional dotfiles attribute we passed in extraSpecialArgs to make this work.

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

{
  # The rest of your home-manager configuration.
  # ...

  # Putting the dotfiles in their rightful place.
  xdg.configFile = {
    doom.source = "${dotfiles}/emacs";
    wezterm.source = "${dotfiles}/wezterm";
    nvim.source = "${dotfiles}/nvim";
  };
}
What exactly is inputs.dotfiles?

inputs.dotfiles is one of the inputs from our flake. Each input is an attribute set containing some metadata including the path of the input on the store directory.

{
  lastModified = 1668655246;
  lastModifiedDate = "20221117032046";
  narHash = "sha256-V7Js99Pyg0UvP6RNg3Isv3MgCKZO9cqVxiiVa9ZZiFU=";
  outPath = "/nix/store/lgflzj8grdxpyp1inil6c96253c06b24-source";
  rev = "5862afecaf045175891550c1020c09cd2dbb32ed";
  shortRev = "5862afe";
}

While we can easily create a string value of the output path with inputs.dotfiles.outPath, the flake input will simply evaluate to the output path when coerced into a string. In other words, "${inputs.dotfiles.outPath}" and "${inputs.dotfiles}" are equivalent. This is why the code from home.nix works just fine.

If you want to be explicit about your intent of dotfiles as a path to the dotfiles, you could also just pass dotfiles to the extraSpecialArgs like the following.

{
  extraSpecialArgs = {
    dotfiles = inputs.dotfiles.outPath;
    # or...
    # dotfiles = ./path/to/my/dotfiles;
  };
}

If you have a NixOS configuration that makes use of home-manager, don’t forget to set home-manager.extraSpecialArgs somewhere in it.

flake.nix
{
  # ...

  outputs = { nixpkgs, home-manager, ... }@inputs: {
    nixosConfigurations.desktop = nixpkgs.lib.nixosSystem {
      modules = [
        # The NixOS configuration.
        ./configuration.nix

        # Make sure to import the home-manager NixOS module somewhere.
        home-manager.nixosModules.home-manager

        {
          # Make sure to import the home-manager configuration somewhere.
          home-manager.users.foodogsquared = { ... }:
            imports = [ ./home.nix ];
          };

          # Make sure to not forget the extra arguments.
          home-manager.extraSpecialArgs = {
            inherit (inputs) dotfiles;
          };
        }
      ];
    };
  };
}

You can also make use of this other than putting part of the dotfiles in the home directory. For instance, NixOS has options to put files in /etc/ with environment.etc which is where system-wide configurations usually live if you have part of the dotfiles that are meant to be system-wide.

Say, you have an i3 window manager configuration in your traditional dotfiles that you want to be used system-wide (for whatever reason).

Similarly to setting it in the home-manager configuration, you have to pass the flake input through a similar attribute, specialArgs, in the function that creates a NixOS configuration (i.e., inputs.nixpkgs.lib.nixosSystem).

flake.nix
{
  # ...

  outputs = { nixpkgs, home-manager, ... }@inputs: {
    # ...

    nixosConfigurations.desktop = nixpkgs.lib.nixosSystem {
      # ...

      specialArgs = {
        inherit (inputs) dotfiles;
      };
    };
  };
}

Then you can use it in your NixOS configuration file somewhere.

Again, take note of the dotfiles as one of the function attribute in the following code.

configuration.nix
{ config, lib, pkgs, dotfiles, ... }:

{
  # ...

  # System-wide i3 configuration from our dotfiles...
  # HOORAH!
  environment.etc.i3.source = "${dotfiles}/i3";
}

Workflow and its caveats

Once you got it running, it is just a matter of managing flake inputs.

For example, to update your dotfiles to its latest revision, you can run the following command.

nix flake lock --update-input dotfiles

You could also just update the dotfiles along the rest of the inputs with…​

nix flake update

You could also add --commit-lock-file flag to the above commands to automatically commit changes to the lockfile which is handy if you want to track your lockfile properly.

There are caveats to this, of course, such as the "What if?"-situation of you wanting only the rest of the flake inputs except your dotfiles (for reasons) to be updated next time you run nix flake update. In this case, you have to change the dotfile flake URL to github:foo-dogsquared/dotfiles/$REVHASH to lock it in. By the time it is ready to update, change the URL again then run nix flake lock --update-input dotfiles.

Overall, this depends on how much your dotfiles is integrated with the rest of the configuration and how much your dotfiles interacts with outside of it. If your "traditional" dotfiles is ever-changing and conflicts with the systems you’re interacting (e.g., using NixOS unstable branch and Debian stable where the version between packages may largely differ and more likely to break), it may be time to do something about it such as creating branches for each of them.

In my experience, the potential problems with this setup isn’t that much that of a problem since my traditional dotfiles barely changes nowadays. In fact, my NixOS configurations change more often. If there’s a change that is coming from my dotfiles, I just push the changes to the (dotfiles) remote repo and update my NixOS configuration with the update.

Appendix A: Why flakes anyways?

It is previously stated that it is possible to do what we want to do other than flakes. Out of all solutions, I chose flakes since it is better than the traditional way.

To show it clearly, we’ll first show how to do it with the classical way which uses a fetcher function. nixpkgs has a lot of fetchers including one for GitHub, GitLab, Sourcehut, and even just any Git repo. But in this case, we’ll use the appropriate fetcher fetchFromGitHub.

Most fetchers require a hash of the thing to be stored in advance. You can refer to the appropriate chapter on how to get hashes from nixpkgs manual for details.

home.nix, this time with fetchers instead of flakes
{ config, lib, pkgs, ... }:

let
  dotfiles = pkgs.fetchFromGitHub {
    owner = "foo-dogsquared";
    repo = "dotfiles";
    rev = "5862afecaf045175891550c1020c09cd2dbb32ed";
    hash = "sha256-V7Js99Pyg0UvP6RNg3Isv3MgCKZO9cqVxiiVa9ZZiFU=";
  };
in
{
  # ...

  # Putting the dotfiles in their rightful place.
  xdg.configFile = {
    doom.source = "${dotfiles}/emacs";
    wezterm.source = "${dotfiles}/wezterm";
    nvim.source = "${dotfiles}/nvim";
  };
}
What is dotfiles this time?

As previously stated from What exactly is inputs.dotfiles?, dotfiles from home.nix is a flake input which is an attribute set that evaluates into the output path when coerced into a string. This time, fetchFromGitHub is a function that returns a derivation containing build instructions for downloading the GitHub repo.

Pretty-printed derivation of my dotfiles with nix show-derivation $DRV
{
  "/nix/store/pxnaxvlv39b3w9rqx8ag39gfd60d52mq-source.drv": {
    "args": [
      "-e",
      "/nix/store/57620l1168piiia2bmmsxxhh7sjb2n40-builder.sh"
    ],
    "builder": "/nix/store/561wgc73s0x1250hrgp7jm22hhv7yfln-bash-5.2-p15/bin/bash",
    "env": {
      "SSL_CERT_FILE": "/no-cert-file.crt",
      "__structuredAttrs": "",
      "buildInputs": "",
      "builder": "/nix/store/561wgc73s0x1250hrgp7jm22hhv7yfln-bash-5.2-p15/bin/bash",
      "cmakeFlags": "",
      "configureFlags": "",
      "curlOpts": "",
      "curlOptsList": "",
      "depsBuildBuild": "",
      "depsBuildBuildPropagated": "",
      "depsBuildTarget": "",
      "depsBuildTargetPropagated": "",
      "depsHostHost": "",
      "depsHostHostPropagated": "",
      "depsTargetTarget": "",
      "depsTargetTargetPropagated": "",
      "doCheck": "",
      "doInstallCheck": "",
      "downloadToTemp": "1",
      "executable": "",
      "impureEnvVars": "http_proxy https_proxy ftp_proxy all_proxy no_proxy NIX_CURL_FLAGS NIX_HASHED_MIRRORS NIX_CONNECT_TIMEOUT NIX_MIRRORS_alsa NIX_MIRRORS_apache NIX_MIRRORS_bioc NIX_MIRRORS_bitlbee NIX_MIRRORS_centos NIX_MIRRORS_cpan NIX_MIRRORS_debian NIX_MIRRORS_fedora NIX_MIRRORS_gcc NIX_MIRRORS_gentoo NIX_MIRRORS_gnome NIX_MIRRORS_gnu NIX_MIRRORS_gnupg NIX_MIRRORS_hackage NIX_MIRRORS_hashedMirrors NIX_MIRRORS_ibiblioPubLinux NIX_MIRRORS_imagemagick NIX_MIRRORS_kde NIX_MIRRORS_kernel NIX_MIRRORS_luarocks NIX_MIRRORS_maven NIX_MIRRORS_mozilla NIX_MIRRORS_mysql NIX_MIRRORS_openbsd NIX_MIRRORS_opensuse NIX_MIRRORS_osdn NIX_MIRRORS_postgresql NIX_MIRRORS_pypi NIX_MIRRORS_qt NIX_MIRRORS_roy NIX_MIRRORS_sageupstream NIX_MIRRORS_samba NIX_MIRRORS_savannah NIX_MIRRORS_sourceforge NIX_MIRRORS_steamrt NIX_MIRRORS_tcsh NIX_MIRRORS_testpypi NIX_MIRRORS_ubuntu NIX_MIRRORS_xfce NIX_MIRRORS_xorg",
      "mesonFlags": "",
      "mirrorsFile": "/nix/store/23zzdk9yx6ak1cx71f4zqaxqpl6kl9rm-mirrors-list",
      "name": "source",
      "nativeBuildInputs": "/nix/store/cpwspw5jy0bmy47rf7r0rx46bg9n3p88-curl-7.87.0-dev /nix/store/dndrbc2s285di4mmf2jpja6813xcn4p1-unzip-6.0 /nix/store/wfrh70qb3syh23rrzyalb0pim41s9img-glibc-locales-2.35-224",
      "nixpkgsVersion": "23.05",
      "out": "/nix/store/lgflzj8grdxpyp1inil6c96253c06b24-source",
      "outputHash": "sha256-V7Js99Pyg0UvP6RNg3Isv3MgCKZO9cqVxiiVa9ZZiFU=",
      "outputHashMode": "recursive",
      "outputs": "out",
      "patches": "",
      "postFetch": "unpackDir=\"$TMPDIR/unpack\"\nmkdir \"$unpackDir\"\ncd \"$unpackDir\"\n\nrenamed=\"$TMPDIR/5862afecaf045175891550c1020c09cd2dbb32ed.tar.gz\"\nmv \"$downloadedFile\" \"$renamed\"\nunpackFile \"$renamed\"\nchmod -R +w \"$unpackDir\"\nif [ $(ls -A \"$unpackDir\" | wc -l) != 1 ]; then\n  echo \"error: zip file must contain a single file or directory.\"\n  echo \"hint: Pass stripRoot=false; to fetchzip to assume flat list of files.\"\n  exit 1\nfi\nfn=$(cd \"$unpackDir\" && ls -A)\nif [ -f \"$unpackDir/$fn\" ]; then\n  mkdir $out\nfi\nmv \"$unpackDir/$fn\" \"$out\"\n\n\nchmod 755 \"$out\"\n",
      "preferHashedMirrors": "1",
      "preferLocalBuild": "1",
      "propagatedBuildInputs": "",
      "propagatedNativeBuildInputs": "",
      "showURLs": "",
      "stdenv": "/nix/store/v3fn6w6kys2gyh13ism5cq1f9p3x0bv5-stdenv-linux",
      "strictDeps": "",
      "system": "x86_64-linux",
      "urls": "https://github.com/foo-dogsquared/dotfiles/archive/5862afecaf045175891550c1020c09cd2dbb32ed.tar.gz"
    },
    "inputDrvs": {
      "/nix/store/0hjmcwg5rpq8kk87ngdvbkrm6zh8k3ll-curl-7.87.0.drv": [
        "dev"
      ],
      "/nix/store/0hnjp6s8k71xm62157v37zg3qzwvl8lx-bash-5.2-p15.drv": [
        "out"
      ],
      "/nix/store/151w9968gazbxf52ijf2y7fh4f4sphi0-stdenv-linux.drv": [
        "out"
      ],
      "/nix/store/h22pscjl75ph7q0zcsn8gqc7qlizv6z5-mirrors-list.drv": [
        "out"
      ],
      "/nix/store/q9bnj7gmw8mdrssm1mzn9j0dsb32vada-glibc-locales-2.35-224.drv": [
        "out"
      ],
      "/nix/store/rrr6h7wib0c1mg3d79xas4ac6b0yblwg-unzip-6.0.drv": [
        "out"
      ]
    },
    "inputSrcs": [
      "/nix/store/57620l1168piiia2bmmsxxhh7sjb2n40-builder.sh"
    ],
    "outputs": {
      "out": {
        "hash": "57b26cf7d3f283452f3fa44d83722cbf732008a64ef5ca95c628956bd6598855",
        "hashAlgo": "r:sha256",
        "path": "/nix/store/lgflzj8grdxpyp1inil6c96253c06b24-source"
      }
    },
    "system": "x86_64-linux"
  }
}

Derivations also evaluate into the output path when coerced into a string. This is why the code for setting the dotfiles into the home directory still works unchanged.

As you might have already guessed, this has the disadvantage of manually updating the tarballs alongside the flake environment (e.g., NixOS, home-manager). For example, if you want to update the dotfiles, you’ll have to go through the process of updating the URL (if applicable) and then updating the hash. Pretty tedious to deal with.

By including the dotfiles as one of the flake inputs, you’re also eliminating this tedious process of manually updating. All you have to do is nix flake update as mentioned from Workflow and its caveats and you’re done!

Not to mention, tools such as nixos-rebuild switch and home-manager switch also supports updating with flakes at recent releases.

Ways to upgrade your flake-enabled home-manager/NixOS environments
home-manager --flake .#foodogsquared switch
nixos-rebuild --flake .#desktop switch
Introduction to nurl

nixpkgs has a lot of fetchers to choose from with appropriate fetchers call for appropriate situations: fetchgit for Git repos, fetchFromGitHub for Git repos on GitHub, and so forth. An additional benefit for using the appropriate fetcher is typically easier to use.

Luckily, there is a Nix tool called nurl that easily generates Nix code with the appropriate fetchers for different forges.

Here’s an example usage to easily generate the most appropriate code for fetching my "traditional" dotfiles from my GitHub repo.

nurl https://github.com/foo-dogsquared/dotfiles

It should generate the similar output to the following listing.

fetchFromGitHub {
  owner = "foo-dogsquared";
  repo = "dotfiles";
  rev = "5862afecaf045175891550c1020c09cd2dbb32ed";
  hash = "sha256-V7Js99Pyg0UvP6RNg3Isv3MgCKZO9cqVxiiVa9ZZiFU=";
}

Very convenient tool!