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 -- client --systemdThis 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. For the server,
use … | sudo sh -s -- server --systemd (creates portunus-server and
/var/lib/portunus, mode 0750). Binary acquisition and flags are
documented once in
Client Configuration.
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
sudo cp deploy/server.toml.example /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 CLI):
portunus-server --data-dir /var/lib/portunus enroll-client edge-01
# 6. On the edge host: install + enroll, then place the bundle
curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/Portunus/main/scripts/install.sh \
| sudo sh -s -- client --systemd
portunus-client enroll 'portunus://...' --out ./client.bundle.json
sudo install -o root -g portunus-client -m 0640 \
./client.bundle.json /etc/portunus/client.bundle.json
# 7. Enable the client unit on the edge host
sudo systemctl enable --now portunus-clientThe setup token expires after 30 minutes and rotates on every server start until onboarding completes.
Smoke check
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 75). 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.