Deploy with systemd
Hardened systemd units, FHS layout, the install.sh that creates service users and directories.
Portunus ships hardened systemd units under deploy/systemd/ that
are ready to drop in. Both units run as a dedicated system user with
ProtectSystem=strict, a @system-service syscall filter, a restricted
address-family set, and LimitNOFILE sized to the workload.
One-shot install
On the edge host, install the client binary and the hardened unit in one step:
curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/Portunus/main/scripts/install.sh \
| sudo sh -s -- clientThis downloads + checksum-verifies the release binary, creates the
portunus-client service user, lays out /etc/portunus (mode 0750),
and installs the hardened portunus-client.service (the service is
installed by default — --systemd is a deprecated no-op). For the
server, use … | sudo sh -s -- server (creates portunus-server and
/var/lib/portunus, mode 0750). Binary acquisition and flags are
documented once in
Client Configuration.
A bare client install enables and starts portunus-client.service
immediately, but the service has no credential bundle yet, so it crash-loops
(Restart=on-failure, every 5 s) until you enroll. Either pass --enroll '<uri>' (see first-boot step 6) to install + enroll + start in one shot, or add
--no-service to install without starting, then place
/etc/portunus/client.bundle.json and run systemctl enable --now portunus-client yourself.
From a source checkout you can instead install only the units with
cd deploy/systemd && sudo ./install.sh client (the binary must already
be on PATH).
server.service
[Unit]
Description=Portunus control plane
Documentation=https://github.com/ZingerLittleBee/Portunus
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=portunus-server
Group=portunus-server
ExecStart=/usr/local/bin/portunus-server --data-dir /var/lib/portunus serve
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
LimitNOFILE=4096
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
ReadWritePaths=/var/lib/portunus
[Install]
WantedBy=multi-user.targetTimeoutStopSec=60 is kept larger than the configured drain
(shutdown_drain_timeout_secs, default 30 s) so the server's own drain
runs before SIGKILL. The server does not bind forwarding ports (the
client does), so it needs no CAP_NET_BIND_SERVICE and LimitNOFILE=4096
covers gRPC streams, operator HTTP, metrics, and persistence for hundreds
of clients. ReadWritePaths=/var/lib/portunus is the only writable path:
SQLite state, generated TLS material, and the optional server.toml.
client.service
[Unit]
Description=Portunus edge client
Documentation=https://github.com/ZingerLittleBee/Portunus
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=portunus-client
Group=portunus-client
ExecStart=/usr/local/bin/portunus-client --bundle /etc/portunus/client.bundle.json
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
LimitNOFILE=16384
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
ReadOnlyPaths=/etc/portunus/client.bundle.json
[Install]
WantedBy=multi-user.targetThe client binds the listen ports configured by pushed rules, so it
carries CAP_NET_BIND_SERVICE for privileged ports (those below 1024).
LimitNOFILE=16384 covers the default range cap of 1024 ports plus
in-flight connections (each port = one listener fd; each connection =
two fds). Operators raising range_rule_max_ports in server.toml must
scale this proportionally. The bundle is mounted read-only.
First-boot procedure
After install:
# 1. Optional: override defaults (a curl|sh install leaves no checkout — fetch
# the example from the repo; without it the server uses built-in defaults)
sudo curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/Portunus/main/deploy/server.toml.example \
-o /var/lib/portunus/server.toml
sudo nano /var/lib/portunus/server.toml
# 2. Enable and start the server
sudo systemctl enable --now portunus-server
sudo systemctl status portunus-server
# 3. Read the setup token printed by serve
sudo journalctl -u portunus-server -n 100 --no-pager \
| grep 'Portunus onboarding setup token'
# 4. Open http://127.0.0.1:7080/ over SSH tunnel or local browser,
# paste the setup token, and create the first superadmin password.
# 5. Create the first client — Web UI Clients page, or the offline CLI.
# enroll-client opens state.db directly, so stop the service first
# (otherwise it exits store_in_use against the running server):
sudo systemctl stop portunus-server
sudo -u portunus-server portunus-server \
--data-dir /var/lib/portunus enroll-client edge-01
sudo systemctl start portunus-server
# 6. On the edge host: install, enroll, and start — one command
curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/Portunus/main/scripts/install.sh \
| sh -s -- client --enroll 'portunus://HOST:7443/enroll?pin=sha256:…&code=…'
# --enroll places the bundle (root:portunus-client 0640) and enables the service.The setup token expires after 30 minutes and rotates on every server start until onboarding completes.
Smoke check
Web onboarding above creates a password-based superadmin, not an API bearer
token. To probe the operator API, first create a token on the Web UI
Credentials page and export it as PORTUNUS_OPERATOR_TOKEN:
export PORTUNUS_OPERATOR_TOKEN=<token-from-web-ui>
curl -H "Authorization: Bearer $PORTUNUS_OPERATOR_TOKEN" \
http://127.0.0.1:7080/v1/users/mePassword recovery
If the last superadmin forgets the Web password, stop the service, reset the
existing account locally, then start the service again. Use the actual
superadmin user ID (_superadmin for bootstrap-superadmin, or the ID chosen
during Web onboarding):
sudo systemctl stop portunus-server
sudo -u portunus-server portunus-server \
--data-dir /var/lib/portunus \
reset-password admin --temporary
sudo systemctl start portunus-serverThe command prints the temporary password once and requires a password change
after login. It opens the SQLite store directly, so do not run it while
portunus-server.service is active.
Hardening notes
- Ports < 1024 require
CAP_NET_BIND_SERVICE, present in the client unit (the client binds rule listen ports; the server does not). The forwarder only ever binds the listen ports configured by rules, so the capability is scoped tightly. - NFS / tmpfs / ramfs for
--data-diris rejected at startup withevent=startup.unsupported_filesystem. Use a local filesystem. - Two server processes against the same data-dir: the second exits
with
event=startup.store_in_use(exit 1). Clustering is out of scope.
Backup cron
# /etc/cron.daily/portunus-backup
#!/bin/sh
set -eu
DEST=/var/backups/portunus/$(date +%F).db
mkdir -p "$(dirname "$DEST")"
sudo -u portunus-server portunus-server \
--data-dir /var/lib/portunus \
backup --out "$DEST"
find /var/backups/portunus -name '*.db' -mtime +30 -deleteThe online backup uses the SQLite Online Backup API — no quiescing needed, safe to run while the server is serving traffic.