Day 10 — HM Files/XDG, HM Services & direnv

Updated

July 30, 2025

Day 10 — HM Files/XDG, HM Services & direnv

Day 26 — HM files & XDG

Stage III · ~4h
Goal: Manage real user files with Home Manager—home.file, xdg.configFile, and XDG directories—while understanding store-backed links, permissions, and what must never be checked into the flake as plaintext secrets.

Why this day exists

Day 25 enabled programs modules. Day 26 is the file layer: most of a personal environment is text under ~/.config, ~/.local, and a few classic dotfiles. HM makes those files declarative and rebuildable—or, if misused, makes secrets world-readable in the Nix store.


Theory 1 — How HM places files

Typical mechanism:

  1. File contents (or a path) enter the Nix store as part of the HM generation
  2. Activation creates symlinks from the home directory into the store (or manages mutable copies depending on options)
  3. Next activation may replace links when content changes

Implications:

Property Consequence
Store paths are world-readable No passwords, tokens, private keys in HM file content
Links point at immutable content Editing the live file in ~ may be wrong layer—edit the flake
Rebuild required for changes Dotfile tweak → rebuild (or temporary escape hatch)

Escape hatches (know, use sparingly)

  • Manual files outside HM for truly mutable scratch
  • mkOutOfStoreSymlink patterns (advanced; can reintroduce impurity)
  • Secrets tooling (Stage IV) for sensitive values

Theory 2 — home.file vs xdg.configFile

API Default target idea Example use
home.file."<path>" Relative to $HOME home.file.".npmrc", home.file.".config/acme/x"
xdg.configFile."<path>" Under XDG config home (usually ~/.config) xdg.configFile."foot/foot.ini"
xdg.dataFile / xdg.mimeApps Data / MIME Desktop entries, default apps

Prefer xdg.configFile for modern tools that honor XDG.

Minimal examples

# homes/alice/files.nix
{ pkgs, ... }:
{
  xdg.enable = true;

  xdg.configFile."demo/hello.txt".text = ''
    managed by home-manager
    rebuilt with the host flake
  '';

  home.file.".config/demo/from-home-file.txt".text = ''
    same idea via home.file
  '';
}

Import from homes/alice/default.nix:

{
  imports = [ ./files.nix ];
}

Theory 3 — Content sources: text, source, templates

Field Use
.text Inline small configs
.source Path to a file in the repo (./config/gitconfig sibling)
.source = pkgs.writeText ... Generated in Nix
Executable bit .executable = true when needed

Repo layout option:

homes/alice/
  default.nix
  files.nix
  config/
    starship.toml
    htoprc
xdg.configFile."starship.toml".source = ./config/starship.toml;

Benefit of source: normal editor diffs on real files; Nix only wires them.


Theory 4 — XDG directories

xdg.enable = true;
# xdg.configHome / dataHome / cacheHome / stateHome can be customized
# defaults are usually correct — only override with a reason
Variable Typical path
XDG_CONFIG_HOME ~/.config
XDG_DATA_HOME ~/.local/share
XDG_CACHE_HOME ~/.cache
XDG_STATE_HOME ~/.local/state

Tools differ in XDG discipline. Your job: put managed files where the tool reads them, not where tradition says.


Theory 5 — Permissions and clobber behavior

HM will not always happily overwrite unexpected pre-existing files—activation may fail if a real file blocks a symlink.

Situation Approach
Old imperative dotfile in the way Backup and remove, then rebuild
You need a secret file mode 0600 Do not put secret content in store; use runtime secret install (Stage IV)
Public config Store-backed symlink is fine

Check links:

ls -la ~/.config/demo
readlink -f ~/.config/demo/hello.txt

Theory 6 — Theming (optional, bounded)

Theming (GTK, fonts, pointer cursors) is infinite rabbit hole. If you care:

  • Prefer HM modules that exist (gtk, qt, fonts.fontconfig at system level, etc.)
  • Bound the experiment: one terminal theme + one font—not a full desktop redesign today

If you run a server-only lab: skip theming; migrate shell/git configs instead.


Theory 7 — Multi-machine user splits (pattern)

Same user, different hosts:

# homes/alice/default.nix
{ lib, ... }:
{
  imports = [
    ./common.nix
    # ./hosts/lab.nix   # optional per-host HM slice
  ];
}

Or select in the flake:

home-manager.users.alice = {
  imports = [
    ./homes/alice/common.nix
    ./homes/alice/hosts/lab.nix
  ];
};

Do not copy entire homes per host if 90% is shared—common + overlay modules.


Worked example — Migrate a real git ignore + editor config

# homes/alice/config/git-ignore-global
# *.env
# .direnv/
# result

# homes/alice/git.nix
{
  programs.git = {
    enable = true;
    userName = "Alice";
    userEmail = "alice@example.invalid";
    ignores = [
      "*.env"
      ".direnv/"
      "result"
      "result-*"
    ];
  };

  xdg.configFile."nvim/init.lua".text = ''
    -- minimal managed nvim config
    vim.opt.number = true
    vim.opt.relativenumber = true
  '';
}

No API tokens. No extraConfig with github.token.


Lab 1 — Inventory live dotfiles

As your lab user:

ls -la ~ | head -50
ls -la ~/.config 2>/dev/null | head -50

Pick 3–5 real configs to migrate (git, shell rc fragments, one tool). Skip secrets and browser profiles.

Journal: path → HM attribute choice (programs.* vs xdg.configFile vs home.file).


Lab 2 — First managed XDG file

Add xdg.enable = true and a demo xdg.configFile. Rebuild. Verify symlink into store:

ls -la ~/.config/demo
readlink -f ~/.config/demo/hello.txt
stat $(readlink -f ~/.config/demo/hello.txt)

Note permissions on the store path (world-readable). That observation is the point.


Lab 3 — Migrate via source

Place a real config file next to the HM module and set .source. Rebuild. Confirm tool behavior.


Lab 4 — Conflict drill

# while HM is managing a path, try creating a conflicting real file after backup
# e.g. remove link, replace with a regular file, rebuild, observe activation error

Restore by removing the blocker and rebuilding. Document the error text for future you.


Lab 5 — Secret anti-pattern (do not commit real secrets)

Create a fake example only on a branch you will not push, or purely local:

# BAD — educational only, use fake value
# home.file.".config/acme/token".text = "super-secret";

Show yourself:

# after a bad activation, store path is readable
# cat $(readlink -f ~/.config/acme/token)

Then delete the pattern, rebuild, and write: “secrets need Stage IV tooling.”

Never commit real tokens. If you did: rotate them.


Lab 6 — Structure the home tree

homes/alice/
  default.nix      # imports
  programs.nix     # git, bash, ...
  files.nix        # xdg / home.file
  config/          # source files

Rebuild once more to prove the split.


Common gotchas

Symptom / mistake What to do
Activation fails on existing file Backup/remove blocker; rebuild
Edited ~ file, change vanished You edited a symlink target or lost it on rebuild—edit flake
Secret in store Remove from Nix; rotate credential
Wrong path (~/.foo vs XDG) Check tool docs; fix attribute
Huge binary blobs in HM Don’t; keep media out of the flake
text with nested quotes hell Prefer .source files
Assuming 0600 store file is private Store is not a private vault

Checkpoint

  • xdg.enable (or conscious decision not to)
  • At least two real configs managed via HM files or programs
  • Can show a managed path is store-backed
  • Home tree split into programs/files modules
  • Written note: no secrets in HM file content
  • Conflict + recovery once

Commit

git add .
git commit -m "day26: HM files and XDG migrations"

Write three personal gotchas before continuing.


Tomorrow

Day 27 — HM user services: user-level systemd services and timers for work that should not be system daemons.


Day 27 — HM user services

Stage III · ~4h
Goal: Run user-level systemd services and timers through Home Manager—work that belongs to a login user, not to the system-wide daemon set.

Why this day exists

Day 17 used system systemd via NixOS. Not everything should be system-wide:

Better as user service Better as system service
Personal backup of ~/docs PostgreSQL, Caddy, sshd
Wait-for-graphical clipboard tool Network-facing APIs
User-specific sync agent Multi-user shared daemons
Periodic git fetch of notes CI runners for a team

HM exposes a structured way to declare user units so they survive reinstalls.


Theory 1 — Systemd user vs system

Bus Unit location idea Starts when
System /etc/systemd/system (generated) Boot / system targets
User ~/.config/systemd/user (generated by HM) User session / lingering

Lingering

If you need user services without an interactive login (headless user timers):

sudo loginctl enable-linger alice
loginctl show-user alice | grep Linger

Without linger, user services may only run while logged in (depending on setup). For a server lab admin user, lingering is often desirable; for a laptop, maybe not.


Theory 2 — HM service options

Home Manager provides:

  • systemd.user.services.<name>
  • systemd.user.timers.<name>
  • services.<hm-module> for popular user apps (where modules exist)

Raw unit example:

# homes/alice/services.nix
{ pkgs, ... }:
{
  systemd.user.services.lab-heartbeat = {
    Unit = {
      Description = "Write a heartbeat timestamp";
    };
    Service = {
      Type = "oneshot";
      ExecStart = "${pkgs.bash}/bin/bash -c 'date -Is >> %h/lab-heartbeat.log'";
    };
  };

  systemd.user.timers.lab-heartbeat = {
    Unit = {
      Description = "Run lab-heartbeat every 15 minutes";
    };
    Timer = {
      OnBootSec = "2min";
      OnUnitActiveSec = "15min";
      Persistent = true;
    };
    Install = {
      WantedBy = [ "timers.target" ];
    };
  };
}

%h is systemd specifier for the user home directory.

Import from homes/alice/default.nix.


Theory 3 — Mapping to system Day 17 knowledge

Concept System (NixOS) User (HM)
Service attrset systemd.services.* systemd.user.services.*
Timer systemd.timers.* systemd.user.timers.*
journal journalctl -u foo journalctl --user -u foo
restart systemctl restart systemctl --user restart

Permissions: user services cannot bind privileged ports (<1024) without capabilities setup; they should not replace system network daemons.


Theory 4 — Simple vs oneshot vs long-running

Type Use
oneshot Task that exits; good with timers
simple / default Long-running process
exec systemd newer behavior; fine when you know it

For a periodic script: oneshot service + timer.

For a personal agent: long-running service with Restart = "on-failure".


Theory 5 — Paths and packages in units

Always use store paths from pkgs, not assumed global binaries:

ExecStart = "${pkgs.restic}/bin/restic snapshots";
# not: ExecStart = "restic snapshots";

Environment:

Service = {
  Environment = [
    "FOO=bar"
  ];
  # EnvironmentFile must not point at secrets in the flake/store
};

Sensitive env files belong to Stage IV secret deployment paths with tight permissions—not home.file with tokens.


Theory 6 — Prefer HM modules when they exist

Examples (availability depends on HM version):

  • services.syncthing (user)
  • services.gpg-agent
  • Various desktop helpers

Pattern: search HM options first; fall back to raw systemd.user.services for custom scripts.


Worked example — Notes fetcher timer

{ pkgs, ... }:
{
  home.packages = [ pkgs.git ];

  systemd.user.services.fetch-notes = {
    Unit.Description = "Fetch personal notes repo";
    Service = {
      Type = "oneshot";
      WorkingDirectory = "%h/notes";
      ExecStart = "${pkgs.git}/bin/git fetch --all --prune";
    };
  };

  systemd.user.timers.fetch-notes = {
    Unit.Description = "Timer for fetch-notes";
    Timer = {
      OnCalendar = "hourly";
      Persistent = true;
    };
    Install.WantedBy = [ "timers.target" ];
  };
}

Requires ~/notes to be a git repo. Create it in the lab if missing (empty repo is fine).


Lab 1 — User systemd baseline

systemctl --user status
systemd-analyze --user blame 2>/dev/null | head || true
loginctl show-user "$USER" | grep -E 'Linger|State'

Decide whether to enable linger for your lab user; document the choice.


Lab 2 — Heartbeat oneshot + timer

Add the heartbeat service from Theory 2 (or equivalent). Rebuild:

sudo nixos-rebuild switch --flake .#lab

As user:

systemctl --user daemon-reload   # usually handled by activation; run if needed
systemctl --user list-timers --all | grep -i heart || systemctl --user list-timers --all
systemctl --user start lab-heartbeat.service
systemctl --user status lab-heartbeat.service
cat ~/lab-heartbeat.log

Enable timer if not already wanted:

systemctl --user enable --now lab-heartbeat.timer
systemctl --user list-timers | grep lab-heartbeat

Lab 3 — Journal debugging

journalctl --user -u lab-heartbeat.service -n 50 --no-pager

Break ExecStart deliberately, rebuild, read the failure, fix.


Lab 4 — One service you actually want

Ideas:

  • git fetch on a notes repo
  • rsync dry-run of a directory to a backup path (no secrets in unit)
  • Generate a dated file in ~/lab/

Implement, enable, prove timer or manual start works twice.


Lab 5 — Boundary check

Write four lines:

  1. Why this is not a system service
  2. What user it runs as
  3. Whether linger is required
  4. What happens on logout without linger

Common gotchas

Symptom / mistake What to do
Timer never fires while logged out Enable linger or accept session-bound life
systemctl without --user You queried the system bus
Binary not found Use ${pkgs.foo}/bin/foo
Service works manually, not on timer Check WantedBy, enable timer, list-timers
Putting nginx in user systemd Use NixOS system module instead
Secrets in Environment= in flake Stage IV; remove from git
Activation OK but unit missing Confirm HM user matches logged-in user

Checkpoint

  • At least one user service defined via HM
  • At least one timer or a long-running user service
  • Can use systemctl --user and journalctl --user
  • Linger decision documented
  • No secrets in unit text
  • Clear system-vs-user boundary write-up

Commit

git add .
git commit -m "day27: HM user services and timers"

Write three personal gotchas before continuing.


Tomorrow

Day 28 — Dev shells & direnv: project-scoped toolchains with flakes + direnv, so the system/HM profiles stay lean.


Day 28 — Dev shells & direnv

Stage III · ~4h
Goal: Keep project toolchains in flake devShells, load them automatically with direnv (use flake), and stop stuffing every language toolchain into system or HM profiles.

Why this day exists

Two failure modes dominate Nix workstations:

  1. Profile bloat: Go, Node, Rust, Python, JDK all in home.packages forever
  2. Snowball shells: random nix-shell -p with no lock, unreproducible on Monday

The modern fix:

Layer Holds
NixOS / HM OS, editors, generic CLI, user services
Project flake devShells Compilers, linters, project CLIs
direnv Enters/exits the shell when you cd

This matches Day 8’s first project flake—and now integrates with the Stage III host.


Theory 1 — devShells in a flake

Minimal project flake (can be separate from the NixOS flake):

# ~/lab/demo-app/flake.nix
{
  description = "demo app toolchain";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

  outputs = { self, nixpkgs }:
  let
    system = "x86_64-linux";
    pkgs = nixpkgs.legacyPackages.${system};
  in {
    devShells.${system}.default = pkgs.mkShell {
      packages = with pkgs; [
        go_1_24  # adjust to versions available on your pin
        gopls
        git
      ];
      shellHook = ''
        export DEMO_APP=1
        echo "entered demo-app devShell"
      '';
    };
  };
}

Enter manually:

cd ~/lab/demo-app
nix develop

Why not always put shells in the NixOS flake?

You can expose devShells from the host flake, but project repos usually own their own flake so clones on other machines work without your entire OS config. Host flake remains for nixosConfigurations (+ later checks).


Theory 2 — direnv + nix-direnv

direnv loads environment variables when you enter a directory with .envrc.

nix-direnv (recommended) caches Nix flake shells efficiently so every cd is not a full re-eval disaster.

HM installation pattern

# homes/alice/direnv.nix
{ pkgs, ... }:
{
  programs.direnv = {
    enable = true;
    nix-direnv.enable = true;
    enableBashIntegration = true;
    # enableZshIntegration / fish as needed
  };
}

Rebuild host/HM, then new shells pick up the hook (eval "$(direnv hook bash)" is usually wired by the module).


Theory 3 — .envrc with use flake

In the project:

# ~/lab/demo-app/.envrc
use flake

Allow once:

direnv allow

Behavior:

Action Result
cd into project Loads devShell env
cd out Unloads
flake/lock change direnv reloads (may rebuild)

.envrc is not for secrets

Do not put API tokens in .envrc committed to git. Use:

  • Untracked .envrc.local patterns (with care)
  • Secret managers / sops (Stage IV)
  • Runtime direnv private files excluded by git
.envrc.local
.env

Theory 4 — System packages vs project shells

Put in HM/system Put in devShell
git, neovim, htop go, cargo, node, python venv tools
direnv itself project linters pinned to repo
SSH client DB client only for one service’s repo

Test: if two projects need different major versions, it belongs in the project shell.


Theory 5 — Pinning discipline for project flakes

Project flake.lock should be committed for apps you ship or share. For private throwaways, still locking saves future you.

Update project nixpkgs independently of the host flake—on purpose. Host OS pin ≠ every app pin.


Theory 6 — nix develop vs nix shell vs HM packages

Command Role
nix develop Flake devShell (hooks, packages, inputsFrom)
nix shell nixpkgs#foo Ad-hoc package on PATH
home.packages Always-on user profile

direnv automates nix develop-class environments.


Worked example — Multi-tool shell

devShells.${system}.default = pkgs.mkShell {
  packages = with pkgs; [
    python3
    python3Packages.pytest
    ruff
  ];
  shellHook = ''
    export PYTHONPATH="$PWD/src''${PYTHONPATH:+:$PYTHONPATH}"
  '';
};
# .envrc
use flake

Lab 1 — Install direnv via HM

Add programs.direnv with nix-direnv.enable = true. Rebuild. Verify:

command -v direnv
direnv version

Open a new login shell if hooks are missing.


Lab 2 — Create a project flake

mkdir -p ~/lab/90daysofx/02-nixos/day28/demo-app
cd ~/lab/90daysofx/02-nixos/day28/demo-app
# write flake.nix with mkShell and 2–3 tools
nix flake lock
nix develop -c which git

Lab 3 — Wire direnv

echo 'use flake' > .envrc
direnv allow
cd ..
cd demo-app
echo "DEMO or PATH check: $PATH" | head -c 200; echo
type python3 2>/dev/null || type go 2>/dev/null || true

Confirm leaving the directory drops the env (compare which before/after).


Lab 4 — Prove version isolation

If available, make two tiny projects with different tools (e.g. one with hello only, one with cowsay only—or different language tools). cd between them; show different PATH entries.


Lab 5 — Thin the profile (careful)

List home.packages / system packages. Move one language toolchain from always-on profile into a project shell. Rebuild HM. Confirm the tool is gone from bare shells but present under direnv.

Do not remove your editor mid-day without a plan.


Lab 6 — Document the workflow

Journal template:

cd project → direnv loads flake lock pin → tools available
change flake → direnv reload → maybe download/build
secrets → never in .envrc committed file

Common gotchas

Symptom / mistake What to do
direnv not hooking New shell; enable integration for your shell
use flake blocked direnv allow
Slow every cd Ensure nix-direnv enabled
Wrong system arch in flake Match system to the machine
Secrets in .envrc committed Rotate; use gitignored local files / sops
Host flake-only shells Fine for mono-lab; projects should still travel
shellHook prints annoyingly Keep hooks quiet in shared repos

Checkpoint

  • direnv + nix-direnv via HM
  • At least one project with devShells.default + lock
  • .envrc with use flake allowed
  • Enter/leave directory toggles tools
  • One toolchain removed from always-on profile (or plan written)
  • Secret policy for env files stated

Commit

# host flake
git add .
git commit -m "day28: direnv and project devShell workflow"

# also commit inside demo-app if it is its own git repo

Write three personal gotchas before continuing.


Tomorrow

Day 29 — flake-parts or discipline: reduce multi-output flake boilerplate—or refuse the dependency and keep a clean hand-rolled structure. Pick one house style.