systemd via Nix

Updated

July 30, 2026

systemd via Nix

Goal: Express systemd services and timers in NixOS modules—ship one custom service + one timer, and verify units with standard systemd tools.

Why this day exists

NixOS does not replace systemd; it generates units. If you only know systemctl on mutable distros, you will fight activation. Today connects module options → unit files → runtime.


Theory 1 — Module options generate units

systemd.services.hello-lab = {
  description = "Day17 hello service";
  wantedBy = [ "multi-user.target" ];
  serviceConfig = {
    Type = "oneshot";
    ExecStart = "${pkgs.coreutils}/bin/echo hello-from-nix";
  };
};

On rebuild, NixOS materializes unit files under the system closure and activates them.

You write systemd sees
systemd.services.<name> name.service
systemd.timers.<name> name.timer
systemd.user.services… user units (later/HM)

Theory 2 — Service essentials

Field Role
description Human text
wantedBy Targets that pull this in
after / requires / wants Ordering/deps
serviceConfig.Type simple, oneshot, notify, …
serviceConfig.ExecStart Command (prefer absolute store paths)
path Extra PATH packages for the unit
script Convenience instead of ExecStart in some cases
enable / wantedBy Whether it starts

Absolute paths matter

ExecStart = "${pkgs.hello}/bin/hello";

Not bare hello—units may not share your interactive PATH.


Theory 3 — Timers

systemd.services.lab-tick = {
  serviceConfig.Type = "oneshot";
  script = ''
    echo "tick $(date -Is)" >> /var/log/lab-tick.log
  '';
};

systemd.timers.lab-tick = {
  wantedBy = [ "timers.target" ];
  timerConfig = {
    OnBootSec = "1min";
    OnUnitActiveSec = "5min";
    Unit = "lab-tick.service";
  };
};
Timer idea Example
Calendar OnCalendar = "daily"
After boot OnBootSec
Periodic OnUnitActiveSec

Prefer timers over classic cron on NixOS unless you have a reason.


Theory 4 — State directories and permissions

Services that write state need:

  • Writable paths (/var/lib/…)
  • Correct User= / Group=
  • Sometimes StateDirectory= in serviceConfig
serviceConfig = {
  User = "labbot";
  StateDirectory = "labbot"; # → /var/lib/labbot
};

Do not write into the Nix store.


Theory 5 — Inspecting generated units

systemctl status lab-tick.timer
systemctl cat lab-tick.service
journalctl -u lab-tick.service -n 50
systemctl list-timers --all | head

systemctl cat shows the unit text—great for verifying Nix rendered what you meant.


Theory 6 — Relationship to services.* high-level modules

High-level modules (services.nginx, services.postgresql) wrap systemd units with options, hardening, and firewall hooks. Custom systemd.services is the escape hatch and learning tool.

Pattern:

prefer services.<known> when it exists
else systemd.services.<custom>

Theory 7 — Failure and rollback

A bad unit can fail activation. Generations still help:

  1. Fix config
  2. Or rollback generation
  3. journalctl -xe for clues

test mode is your friend for unit experiments.


Worked examples bank

Example A — oneshot + timer writing a log

See Theory 3.

Example B — long-running simple service

systemd.services.lab-http-static = {
  wantedBy = [ "multi-user.target" ];
  after = [ "network.target" ];
  serviceConfig = {
    Type = "simple";
    ExecStart = "${pkgs.python3}/bin/python3 -m http.server 8000 --bind 127.0.0.1";
    Restart = "on-failure";
  };
};
# open firewall only if you intentionally expose it

Bind to localhost for lab safety unless you mean to expose.

Example C — path for tools in script

systemd.services.lab-path-demo = {
  path = [ pkgs.jq pkgs.coreutils ];
  script = ''
    echo '{"ok":true}' | jq .
  '';
  serviceConfig.Type = "oneshot";
};

Lab 1 — Ship lab-tick timer

Add service+timer from Theory 3 (adjust log path permissions—use StateDirectory or a path you own).

sudo nixos-rebuild switch --flake .#lab
systemctl status lab-tick.timer
systemctl start lab-tick.service
journalctl -u lab-tick.service -n 20

Lab 2 — systemctl cat literacy

systemctl cat lab-tick.service
systemctl cat lab-tick.timer

Annotate in notes: which lines came from your Nix options.


Lab 3 — One long-running service (localhost)

Deploy Example B or simpler. Verify:

curl -sS http://127.0.0.1:8000/ | head
systemctl status lab-http-static

Ensure firewall does not expose 8000 unless intentional.


Lab 4 — Break ExecStart on purpose

Point ExecStart at a missing path, rebuild, watch failure, fix, rebuild. Note whether generation advanced.


Lab 5 — Document units in README

## Custom units
- lab-tick.timer: …
- lab-http-static.service: …


Theory 8 — Hardening starters for custom units

Even lab units benefit from a few defaults:

systemd.services.lab-http-static.serviceConfig = {
  Type = "simple";
  ExecStart = "${pkgs.python3}/bin/python3 -m http.server 8000 --bind 127.0.0.1";
  Restart = "on-failure";
  # hardening starters (tune per workload)
  NoNewPrivileges = true;
  PrivateTmp = true;
  ProtectSystem = "strict";
  ProtectHome = true;
  # ReadWritePaths = [ "/var/lib/lab-http" ]; # if needed
};

Do not cargo-cult every sandbox flag onto a unit that must write state—pair with StateDirectory / ReadWritePaths.


Theory 9 — systemd.tmpfiles for paths units need

systemd.tmpfiles.rules = [
  "d /var/log/lab-tick 0755 root root -"
];

Creates directories at boot/activation. Prefer StateDirectory= when the unit’s runtime user owns the state under /var/lib.


Theory 10 — Activation vs live systemctl edit

Path SoT?
Flake systemd.services.* Yes
systemctl edit --full drop-ins Drift; vanishes from mental model
Hand unit in /etc/systemd/system Fights NixOS activation

If you must debug live, capture the working unit into the flake before the next rebuild.


Worked example D — oneshot with RemainAfterExit

systemd.services.lab-seed = {
  wantedBy = [ "multi-user.target" ];
  serviceConfig = {
    Type = "oneshot";
    RemainAfterExit = true;
    ExecStart = "${pkgs.coreutils}/bin/touch /var/lib/lab-seed/ok";
    StateDirectory = "lab-seed";
  };
};

Useful for “run once to prepare state” patterns without a long-running daemon.


Lab 6 — Timer calendar form

Replace interval timer with:

timerConfig.OnCalendar = "*-*-* *:0/15:00"; # every 15 minutes — verify syntax
systemctl cat lab-tick.timer
systemd-analyze calendar '*-*-* *:0/15:00'

Document which calendar expression you settled on.


Lab 7 — Unit dependency map

For your long-running service, write:

network.target → lab-http-static.service → (curl clients)

Add after / wants only when real. Over-requires makes boot fragile.

Common gotchas

Symptom Theory
Executable not found Need store absolute path or path
Timer never fires not wantedBy timers.target
Permission denied writing log state dir / user
Service works manually not under systemd PATH / env differences
Forgotten firewall the networking chapter + service ports

Checkpoint

  • Custom service in flake
  • Custom timer in flake
  • systemctl cat inspected
  • journalctl read for your unit
  • Localhost service verified

Commit

cd ~/nixos-config
git add .
git commit -m "day17: systemd services and timers"