This is exactly how it is in use on my machines now. It has accumulated quite a bit of debugging by now, so excuse the amount of code.
That setup does not do exactly what you describe, though. Here the user environment is set up before first login, because in my case otherwise you’d log into basically nothing. Therefore the first boot may get lengthy (depending on what you need to get installed) and will have no feedback. One could split this up into stuff necessary for login and all the other stuff to run within the user environment after login (in a similar fashion, but managed entirely by home-manager
), but this would obviously make it even more intricate.
# home-config.nix
{ config, lib, pkgs, utils, ... }:
with pkgs;
let
users = config.users.users;
# submodule for per-user settings for remote repository
home-config = { lib, ... }: {
options.config = with lib; mkOption {
description = "user's home configuration repository";
default = null;
type = with types; nullOr (submodule ({config, ...}: {
options = {
fetch = mkOption {
type = str;
description = "fetch URL for git repository with user configuration";
};
push = mkOption {
type = str;
default = config.fetch;
description = "push URL for git repository, if it differs";
};
branch = mkOption {
type = str;
default = "master";
description = "branch in repository to clone";
};
path = mkOption {
type = str;
default = ".config";
description = "clone path for configuration repository, relative to user's $HOME";
};
install = mkOption {
type = str;
default = "./install";
description = "installation command";
};
};
}));
};
};
in
{
# extend NixOS user configuration module
options = with lib; with types; {
users.users = mkOption {
type = attrsOf (submodule home-config);
};
};
config = with builtins; with lib;
let
check = user: "home-config-check-${utils.escapeSystemdPath user.name}";
initialise = user: "home-config-initialise-${utils.escapeSystemdPath user.name}";
service = unit: "${unit}.service";
in {
# set up user configuration *before* first login
systemd.services = mkMerge (map (user: mkIf (user.isNormalUser && user.config != null) {
# skip initialisation early on boot, before waiting for the network, if
# git repository appears to be in place.
"${check user}" = {
description = "check home configuration for ${user.name}";
wantedBy = [ "multi-user.target" ];
unitConfig = {
# path must be absolute!
# <https://www.freedesktop.org/software/systemd/man/systemd.unit.html#ConditionArchitecture=>
ConditionPathExists = "!${user.home}/${user.config.path}/.git";
};
serviceConfig = {
User = user.name;
SyslogIdentifier = check user;
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${coreutils}/bin/true";
};
};
"${initialise user}" = {
description = "initialise home-manager configuration for ${user.name}";
# do not allow login before setup is finished. after first boot the
# process takes a long time, and the user would log into a broken
# environment.
# let display manager wait in graphical setups.
wantedBy = [ "multi-user.target" ];
before = [ "systemd-user-sessions.service" ] ++ optional config.services.xserver.enable "display-manager.service";
# `nix-daemon` and `network-online` are required under the assumption
# that installation performs `nix` operations and those usually need to
# fetch remote data
after = [ (service (check user)) "nix-daemon.socket" "network-online.target" ];
bindsTo = [ (service (check user)) "nix-daemon.socket" "network-online.target" ];
path = [ git nix ];
environment = {
NIX_PATH = builtins.concatStringsSep ":" config.nix.nixPath;
};
serviceConfig = {
User = user.name;
Type = "oneshot";
SyslogIdentifier = initialise user;
ExecStart = let
script = writeShellScriptBin (initialise user) ''
set -e
mkdir -p ${user.home}/${user.config.path}
cd ${user.home}/${user.config.path}
git init
git remote add origin ${user.config.fetch}
git remote set-url origin --push ${user.config.push}
git fetch
git checkout ${user.config.branch} --force
${user.config.install}
''; in "${script}/bin/${(initialise user)}";
};
};
}) (attrValues config.users.users));
};
}
This is how it’s used:
# configuration.nix
{ config, ... }:
{
imports = [
../modules/home-config.nix
];
# ...
users.users.fricklerhandwerk = {
isNormalUser = true;
shell = pkgs.fish;
config = {
fetch = https://github.com/fricklerhandwerk/.config;
push = "git@github.com:fricklerhandwerk/.config";
install = "./install ${config.networking.hostName}";
};
};
}
Installation amounts to this (stripped the command line handling fluff for brevity here):
# ~/.config/install
nix-shell -E \
"with import <nixpkgs> {}; mkShell { buildInputs = [ (callPackage ./modules/home-manager.nix {}) ]; }" \
--run "home-manager -f $1 switch"
The trick is that the same home-manager
is part of the instantiated environment. There may be a more elegant trick to wire this up, but this is what I came up with back then, and it works:
# ~/.config/modules/machine.nix
# machine-specific wrapper to `home-manager`, such that `home-manager switch`
# automatically uses the correct configuration files
{ config, pkgs, lib, ... }:
with pkgs;
with lib;
let
home-manager = writeShellScriptBin "home-manager" ''
exec ${callPackage ../home-manager.nix {}}/bin/home-manager -f ${toString config.machine} $@
'';
in
{
options = {
machine = mkOption {
type = types.path;
description = "machine to use with `home-manager` wrapper";
};
};
config = {
home.packages = [ home-manager ];
};
}
# ~/.config/modules/home-manager.nix
{ pkgs ? import <nixpkgs> {} }:
with pkgs;
let
version = "20.09";
src = builtins.fetchGit {
name = "home-manager-${version}";
url = https://github.com/rycee/home-manager;
ref = "release-${version}";
};
in
callPackage "${src}/home-manager" { path = "${src}"; }
Here is a sample from the environment:
# ~/.config/machines/mymachine/default.nix
{ pkgs, ... }:
{
imports = [
./machine.nix
# ...
];
# pick up location of this machine's configuration
machine = ./.;
home.packages = with pkgs; [
# ...
];
}
I’d love to just point you to a GitHub repository for more details on the NixOS side, but I now have the same problem as the people where I originally found conceptual inspiration: there is so much private stuff in there that I never find time to separate out the educationally relevant components for public display.