Skip to content

The systemd service surface

Composing a stack covers the slim Service (name/package/binary/args/dependsOn/environment). A real service needs more: a custom User, a StateDirectory, Restart=always, a oneshot on a timer, a system user created before it starts. Punix exposes those as a typed passthrough to systemd — not a curated subset, and not raw unit fragments.

serviceConfig[Service] directives, typed

Any [Service] directive can be set via serviceConfig. Keys are freeform (systemd validates them at unit load; Punix validates only that they render); values are Str, Bool (→ yes/no), or Int.

services = [{
  name = "appview"
  package = "Appview"
  binary = "appview"
  serviceConfig = {
    DynamicUser     = true
    StateDirectory  = "appview"
    Restart         = "always"
    RestartSec      = 5
    LimitNOFILE     = 65536
  }
}]

renders into the unit as:

[Service]
ExecStart=/store/<hash>-appview/bin/appview
Type=simple
Restart=always
DynamicUser=yes
LimitNOFILE=65536
RestartSec=5
StateDirectory=appview

serviceConfig is merged over the defaults Type=simple, Restart=on-failure (the caller wins). Setting Type=oneshot drops the default Restart (systemd forbids it there). Keys are emitted sorted, so the unit is byte-identical across permuted source.

Two keys are reservedserviceConfig rejects them with a located error:

  • ExecStart — computed from the package store path + binary + args (the point of content-addressing).
  • Environment — use the dedicated environment field, so secret-resolution and hash-exclusion stay enforced.

A directive value with a raw newline (or a key with =/whitespace) is rejected — each directive is one Key=Value line. Encode such values at the source.

enable — omit a service

{ name = "litestream"  package = "Litestream"  binary = "litestream"  enable = false }

enable = false (default true) drops the service entirely: no unit file, not started, and not a valid dependsOn target (a dependency on a disabled service is a compose error). There is no render-but-mask.

tmpfilestmpfiles.d rules

Per-service tmpfiles rules are collected stack-wide (deduplicated, sorted) into /etc/tmpfiles.d/punix-<stack>.conf. systemd-tmpfiles applies them idempotently.

{ name = "knot"  package = "Knot"  binary = "knot"
  tmpfiles = ["d /var/lib/knot 0750 knot knot -"] }

systemUsers / systemGroupssysusers.d

Stack-level user/group creation renders to /etc/sysusers.d/punix-<stack>.conf (systemd-sysusers, idempotent, applied before services start). A user's group is its primary group; every referenced group gets an explicit declaration.

module MyStack {
  backend = "systemd"
  systemGroups = ["git"]
  systemUsers  = [{ name = "git"  group = "git"  home = "/var/lib/git" }]
  services = [ ... ]
}

Names are validated as plain unix names ([a-z_][a-z0-9_-]*, ≤32); home must be an absolute path with no whitespace or quotes.

Timers and oneshots

A service with a timer record becomes timer-activated: the generator emits a sibling <name>.timer unit (WantedBy=timers.target) and the <name>.service drops its [Install] block (it is pulled in by the timer, not at boot). A oneshot is just serviceConfig = { Type = "oneshot" } — a timer + oneshot is the canonical periodic job.

{ name = "store-gc"  package = "Punix"  binary = "punix"  args = ["store", "gc"]
  serviceConfig = { Type = "oneshot" }
  timer = { OnCalendar = "daily"  Persistent = true } }

Unit= is reserved in timer — a foo.timer triggers foo.service by naming convention. OnCalendar values are passed through verbatim (systemd validates them).

Heterogeneous services: the module form

PCL lists are homogeneous, so a services = [ ... ] of inline records must all have the same field shape — two services with different serviceConfig keys won't unify. To mix shapes, reference services by module name (like packages):

module Api    { package = "Api"     binary = "api"   serviceConfig = { DynamicUser = true } }
module Worker { package = "Worker"  binary = "worker"  serviceConfig = { Restart = "always" } }

module MyStack {
  backend = "systemd"
  services = ["Api", "Worker"]   # module names, not inline records
}

Each service module is typed independently. A module's name defaults to the module name. A list is all-strings or all-records — never mixed (which PCL homogeneity already enforces).

Where in the code

  • src/punix/deploy/stack.py — the Service surface + compose-time validation.
  • src/punix/deploy/generators/systemd.pyrender(stack) (units, timers, sysusers.d, tmpfiles.d).