Day 10 — HM Files/XDG, HM Services & direnv
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:
- File contents (or a path) enter the Nix store as part of the HM generation
- Activation creates symlinks from the home directory into the store (or manages mutable copies depending on options)
- 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
mkOutOfStoreSymlinkpatterns (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.txtTheory 6 — Theming (optional, bounded)
Theming (GTK, fonts, pointer cursors) is infinite rabbit hole. If you care:
- Prefer HM modules that exist (
gtk,qt,fonts.fontconfigat 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 -50Pick 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 errorRestore 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 LingerWithout 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 .#labAs 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.logEnable timer if not already wanted:
systemctl --user enable --now lab-heartbeat.timer
systemctl --user list-timers | grep lab-heartbeatLab 3 — Journal debugging
journalctl --user -u lab-heartbeat.service -n 50 --no-pagerBreak ExecStart deliberately, rebuild, read the failure, fix.
Lab 4 — One service you actually want
Ideas:
git fetchon a notes repo
rsyncdry-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:
- Why this is not a system service
- What user it runs as
- Whether linger is required
- 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 --userandjournalctl --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:
- Profile bloat: Go, Node, Rust, Python, JDK all in
home.packagesforever
- Snowball shells: random
nix-shell -pwith 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 developWhy 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 flakeAllow once:
direnv allowBehavior:
| 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.localpatterns (with care)
- Secret managers / sops (Stage IV)
- Runtime
direnvprivate 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 flakeLab 1 — Install direnv via HM
Add programs.direnv with nix-direnv.enable = true. Rebuild. Verify:
command -v direnv
direnv versionOpen 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 gitLab 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 || trueConfirm 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
.envrcwithuse flakeallowed
- 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 repoWrite 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.