Day 27 — HM user services

Updated

July 30, 2026

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.