Home Manager user services
Home Manager user services
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 chapter exists
System systemd via NixOS covers host daemons. 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 and rebuilds.
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—document the choice.
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.
HM attrset shape
HM’s user unit options often mirror unit file sections as nested attrsets (Unit, Service, Install). Exact schema can vary slightly by HM version—when in doubt, check HM option docs for your 26.05-aligned pin.
Theory 3 — Mapping system knowledge to user bus
| 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 |
| list timers | systemctl list-timers |
systemctl --user list-timers |
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".
systemd.user.services.notes-agent = {
Unit.Description = "Long-running personal agent";
Service = {
ExecStart = "${pkgs.coreutils}/bin/sleep infinity"; # placeholder
Restart = "on-failure";
RestartSec = "5s";
};
Install.WantedBy = [ "default.target" ];
};(Replace the placeholder with a real agent binary from pkgs.)
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 secret deployment paths with tight permissions—not home.file with tokens.
WorkingDirectory and paths
Service = {
WorkingDirectory = "%h/notes";
ExecStart = "${pkgs.git}/bin/git fetch --all --prune";
};Ensure the directory exists (create once imperatively or with a oneshot).
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.
services.gpg-agent = {
enable = true;
enableSshSupport = true;
};Theory 7 — Targets, wants, and session lifecycle
| Target | Role |
|---|---|
default.target |
User session default |
timers.target |
Timers |
graphical-session.target |
Needs graphical session |
Install.WantedBy = [ "default.target" ];
# or timers.target for timersGraphical-only tools should not use headless linger assumptions without testing.
Theory 8 — Debugging workflow
systemctl --user daemon-reload # usually activation handles this
systemctl --user status lab-heartbeat.service
systemctl --user start lab-heartbeat.service
journalctl --user -u lab-heartbeat.service -n 50 --no-pager
systemctl --user list-timers --allBreak → read journal → fix flake → rebuild → retest. Avoid editing generated unit files under ~/.config/systemd/user as source of truth.
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 if missing (empty repo is fine).
Worked example — Calendar timer variants
Timer = {
OnCalendar = "daily";
# OnCalendar = "Mon..Fri *-*-* 09:00:00";
Persistent = true;
RandomizedDelaySec = "10m";
};Persistent = true catches up missed runs after downtime (within reason).
Exercises
Exercise 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.
Exercise 2 — Heartbeat oneshot + timer
Add the heartbeat service from Theory 2. Rebuild:
sudo nixos-rebuild switch --flake .#labAs user:
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
systemctl --user enable --now lab-heartbeat.timerExercise 3 — Journal debugging
journalctl --user -u lab-heartbeat.service -n 50 --no-pagerBreak ExecStart deliberately, rebuild, read the failure, fix.
Exercise 4 — One service you actually want
Ideas:
git fetchon a notes reporsyncdry-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.
Exercise 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
Exercise 6 — Import services.nix
Ensure homes/alice/services.nix is imported from default.nix. Rebuild once after the split.
Exercise 7 — list-unit-files literacy
systemctl --user list-unit-files | head -40
systemctl --user cat lab-heartbeat.serviceConfirm the unit content matches your flake (store paths visible).
Exercise 8 — Failure restart policy
Add a long-running service with Restart = "on-failure" (even a simple script). Kill it, observe restart behavior, then disable if you do not need it.
Exercise 9 — OnCalendar experiment
Change heartbeat to OnCalendar = "*:0/10" (every 10 minutes) or similar; reload; list-timers.
Exercise 10 — Wrong bus drill
Run systemctl status lab-heartbeat without --user. Note the confusion. Always use --user for HM units.
Exercise 11 — Secret audit of unit text
Read your service modules: no passwords in Environment=. Plan secrets paths if needed later.
Exercise 12 — Graphical vs headless note
If this host is headless, list any user services that would need a desktop session—and do not enable them.
Exercise 13 — Comparison table in docs
Add to docs/layout.md: one system service vs one user service you run, with rationale.
Exercise 14 — Clean disable
Disable and remove a throwaway timer after testing:
systemctl --user disable --now lab-heartbeat.timer 2>/dev/null || true
# remove from flake; rebuildCommon 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 |
Secrets chapters; remove from git |
| Activation OK but unit missing | Confirm HM user matches logged-in user |
| Edited generated unit by hand | Revert; change flake only |
| WorkingDirectory missing | Create dir or unit fails |
| Port 80 in user service | Use system proxy/service |
| Linger enabled globally without thought | Document per-user choice |
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
- Units imported from
services.nix(or equivalent split) - Know how to read a failed user unit journal
Journal (optional)
Write three personal gotchas before continuing.