Home Manager user services

Updated

July 30, 2026

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 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—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 timers

Graphical-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 --all

Break → 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 .#lab

As 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.timer

Exercise 3 — Journal debugging

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

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

Exercise 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.

Exercise 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

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.service

Confirm 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; rebuild

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 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 --user and journalctl --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.