Skip to content

Running as a service (systemd)

A bare-metal install runs the three processes as systemd services. The units are in deploy/systemd/: peart.service, peart-portal.service and peart-render.service, plus peart-render.socket. Install them, then set the per-service environment files under /etc/peart (peart.env, portal.env, render.env).

peart-render.service runs /opt/peart/render/.venv/bin/python /opt/peart/render/app.py, so the sidecar has to be in place before you enable it. The steps are in Direct install.

sudo cp deploy/systemd/*.service deploy/systemd/*.socket /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now peart-render.socket
sudo systemctl enable --now peart.service
sudo systemctl enable --now peart-portal.service

Enable the socket, not peart-render.service. The service has no network of its own (PrivateNetwork=yes, below): the socket opens 127.0.0.1:8090 outside that isolation and hands the listening descriptor over, which is what makes the sidecar reachable without giving it a network. The service starts on the first connection and has no [Install] section, so enabling it directly fails rather than quietly producing a renderer that listens where nobody calls.

peart.service declares Wants=peart-render.socket, so the socket is listening before the internal surface comes up. Each unit runs as its own user (peart, peart-portal, peart-render) and carries the standard systemd hardening (NoNewPrivileges, ProtectSystem=strict, PrivateTmp, a @system-service syscall filter, and so on).

The units' ReadOnlyPaths= and InaccessiblePaths= are one layer, and they hold only while the process runs under them. The filesystem has to state the same boundary on its own, which is what First run and configuration sets up.

What goes in each environment file

/etc/peart holds one file per unit, root:root 0600, created empty by First run and configuration. systemd reads them as PID 1 while preparing the unit, before dropping to User=, so the service user never opens them -- which is what makes them the right place for a secret, and config.yaml, read by both surfaces, the wrong one.

# /etc/peart/peart.env
PEART_RENDER_TOKEN=...      # the same value the renderer's own file carries
PEART_WEBHOOK_SECRET=...    # only if webhook.url is set in config.yaml

# /etc/peart/render.env
PEART_RENDER_TOKEN=...      # byte for byte the same as above

# /etc/peart/portal.env
# nothing yet; the file exists because the unit names it

PEART_RENDER_TOKEN is one shared secret in two files, and the two have to match exactly: the sidecar checks it on every request. Generate it once, with openssl rand -hex 32, and paste the same string in both. PEART_WEBHOOK_SECRET belongs to the internal surface alone, and only matters when the webhook block in config.yaml names a URL. Both override whatever config.yaml says, and an unset variable changes nothing, so an installation that keeps its values in the file keeps working.

portal.env stays empty on purpose. The portal takes no secret of its own, and a file with nothing in it is the honest shape of that -- the unit names it with EnvironmentFile= and no leading dash, so it has to exist.

If the data directory is on a separate volume

After=network.target is all the units declare about ordering, which is enough while /var/lib/peart lives on the root filesystem. Put it on its own volume, encrypted or not, and a unit can start before that volume is mounted: the service then writes into the empty mount point on the root filesystem, and the data you expected to find later is on a disk nobody wrote to. Unmounting afterwards hides the files rather than losing them, which is worse, because the installation looks empty and a restore looks necessary.

Declare the dependency on each unit that touches the directory, with a drop-in rather than by editing the shipped file:

sudo systemctl edit peart.service
[Unit]
RequiresMountsFor=/var/lib/peart

Repeat for peart-portal.service (its database directory) and peart-render.service (its work directory) if those live on the same volume. The directive is harmless when the path is on the root filesystem, so there is no reason to leave it out once the volume is a possibility.

The renderer's network isolation is not optional

peart-render.service carries two directives that are the bare-metal equivalent of the internal: true network in the container path:

PrivateNetwork=yes
IPAddressDeny=any

The renderer processes HTML derived from hostile content, and it must not be able to reach the internet or the cloud metadata endpoint. Removing either directive silently reopens a server-side request forgery surface. This is the third defence layer of the rendering pipeline, and it is a requirement, not a tuning knob. The renderer also runs with MemoryDenyWriteExecute=yes.

The isolation is also why the sidecar is started through peart-render.socket. A service with a network namespace of its own has its own loopback, so a server binding 127.0.0.1 inside it is not the 127.0.0.1 the internal surface dials. The socket opens the port outside the namespace and hands the listening descriptor to the service, which never binds at all.

If you ran an earlier install that worked around the unreachable sidecar with a drop-in replacing PrivateNetwork=yes by IPAddressAllow=localhost, remove it when you update: see Upgrading.

What each unit is allowed to touch

The units encode the trust boundaries as filesystem permissions:

  • peart.service has ReadWritePaths=/var/lib/peart (database, evidence, renditions, reports, work).
  • peart-portal.service has ReadWritePaths=/var/lib/peart/db only. It needs write access to the database directory purely because SQLite in WAL mode writes companion files for readers; read-only enforcement is the mode=ro connection string, not the filesystem. It never gets the evidence directory or the key.
  • peart-render.service works out of /var/lib/peart/work and reads the token from its environment.

The master key is read-only and lives outside /var/lib/peart, so a backup of the data directory can never contain it.