systemd via Nix
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=inserviceConfig
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 | headsystemctl 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:
- Fix config
- Or rollback generation
journalctl -xefor 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 itBind 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 20Lab 2 — systemctl cat literacy
systemctl cat lab-tick.service
systemctl cat lab-tick.timerAnnotate 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-staticEnsure 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 syntaxsystemctl 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 catinspected
- journalctl read for your unit
- Localhost service verified
Commit
cd ~/nixos-config
git add .
git commit -m "day17: systemd services and timers"