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.servicehasReadWritePaths=/var/lib/peart(database, evidence, renditions, reports, work).peart-portal.servicehasReadWritePaths=/var/lib/peart/dbonly. It needs write access to the database directory purely because SQLite in WAL mode writes companion files for readers; read-only enforcement is themode=roconnection string, not the filesystem. It never gets the evidence directory or the key.peart-render.serviceworks out of/var/lib/peart/workand 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.