Recipe for a hardened systemd service unit.
The Alpine/OpenRC sibling is recipe_alpine_openrc.ts's
hardened_openrc_service — a different artifact with a different security
model (the two share only the broad options shape, not content).
hardened_systemd_unit builds the unit-file text for a long-running network
service (a compiled Rust daemon, a Deno process, or any comparable service that
talks to a local PostgreSQL). It captures a security-hardening directive set
that server configs otherwise copy by hand —
NoNewPrivileges, ProtectSystem=strict, ProtectHome=read-only, the
kernel/namespace/realtime restrictions, an empty capability bounding set, the
@system-service syscall filter, UMask=0077, and
SystemCallArchitectures=native — as one reusable string builder so every
service converges on the same baseline.
Loosening the baseline
Three defaults are tight enough that a service may need one opened, each
through its own option, so the loosening is written where it is reviewed:
capabilities — the bounding set is empty unless the service names the
capabilities it holds, which it is then also granted as ambient (a
CAP_NET_BIND_SERVICE listener on a port below 1024).system_call_filter — @system-service with EPERM for anything outside
it, so a refused call fails where the program can report it instead of
killing the process. false drops the filter.umask — 0077, so what the service creates is its own. A service whose
files another account reads (an nginx X-Accel-Redirect or alias over a
store the service writes) passes the mode that account needs, e.g. '0022'.
The unit refuses root as its user or group, and either given as a number: the
hardening block assumes an unprivileged account, and root would make most of it moot.
Recipes are imported at config eval time inside zap's sandboxed config eval
(fgro eval), which has no network, no writes, and no environment, and where
the clock and randomness throw. This module is therefore a pure function: it only assembles a
string from its arguments — no I/O, no clock, no randomness, no imports
beyond types. The output is deterministic for given inputs (stable ordering,
trailing newline), so it stays a reliable content-addressed plan artifact.
The caller wraps the result in a file resource, e.g.
zap.file({id: 'myapp-unit', path: '/etc/systemd/system/myapp.service', content: {type: 'inline', content: hardened_systemd_unit({...})}}).
Environment injection: env_file vs --env
Two ways to get env into the service, mutually exclusive in practice:
- Set
env_file to emit an EnvironmentFile= directive — systemd loads the
file and the process reads its env (the compiled-binary path, where the
daemon reads process env, not a file). - Omit
env_file and bake --env=<path> (or equivalent) into exec_start
— the runtime loads the file itself (the Deno path), so no
EnvironmentFile= line is emitted at all.