Standalone Forwarder
TOML-driven TCP/UDP forwarding without a gRPC control plane — v1.4+.
portunus-standalone is a self-contained TCP/UDP forwarder configured
entirely from a TOML file. It uses the same data-plane code as
portunus-client but requires no running portunus-server — suitable for
single-host deployments, edge devices, and scenarios where a control-plane
server is unnecessary.
New here? Installation → Standalone install has the quick start and a "standalone vs server + client" decision guide. This page is the full reference: config schema, CLI flags, signals, examples, the stats TUI, and operational notes.
Install
Two supported install paths. Pick Docker for container hosts and One-click installer for plain Linux servers with systemd.
Docker
The GHCR image ghcr.io/zingerlittlebee/portunus-standalone:<version> is
published on every release (multi-arch: linux/amd64, linux/arm64).
# 1. Drop a starter config in your working directory and edit it:
curl -fsSL -o portunus.toml \
https://raw.githubusercontent.com/ZingerLittleBee/Portunus/main/crates/portunus-standalone/contrib/portunus.example.toml
chmod 0644 portunus.toml
# Edit portunus.toml with your editor (vi, nano, ${EDITOR:-vi}, …).
${EDITOR:-vi} portunus.toml
# 2. Run the container:
docker run -d --network host \
--name portunus-standalone \
--restart unless-stopped \
-v "$PWD/portunus.toml:/etc/portunus/standalone.toml:ro" \
ghcr.io/zingerlittlebee/portunus-standalone:latestNotes:
- Pin a specific version in production (
:1.7.0rather than:latest). - The container runs as UID 65532 (
nonroot). The host file must be readable by that UID —chmod 0644 portunus.tomlis the simplest fix. If you geterror: io error: Permission denied (os error 13), this is why. --network hostlets port-range and arbitrary-port rules work without per-port mapping. On Docker Desktop or when host networking is unavailable, switch to bridge mode and enumerate ports — seecontrib/docker-compose.yml.- The path inside the container (
/etc/portunus/standalone.toml) is fixed by the image's defaultCMD. The host path can be anywhere. docker composeand Kubernetes manifests live incrates/portunus-standalone/contrib/.
One-click installer (binary + systemd)
For Linux servers without Docker. This script installs the binary, creates a system user, lays down a starter config, and installs a hardened systemd unit.
curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/Portunus/main/scripts/install.sh | bash -s -- standaloneThe installer:
- Detects OS/arch, downloads the latest release, verifies its SHA-256.
- Installs the binary to
/usr/local/bin/portunus-standalone(mode0755). - Creates the
portunussystem user/group if missing. - Writes a starter
/etc/portunus/standalone.toml(mode0640, ownerroot:portunus) fromcontrib/portunus.example.toml. - Installs a hardened unit at
/etc/systemd/system/portunus-standalone.service(LimitNOFILE=65535,AmbientCapabilities=CAP_NET_BIND_SERVICE,ProtectSystem=strict,NoNewPrivileges=true). - Reloads systemd. The service is not auto-started — the initial config is a template with example targets.
After editing the config:
sudo ${EDITOR:-vi} /etc/portunus/standalone.toml
sudo systemctl enable --now portunus-standalone
sudo systemctl status portunus-standalone --no-pagerRunning bash install.sh with no arguments opens an interactive menu —
pick option 3 for the standalone role.
Config schema
# [global] section — all fields optional
[global]
label = "my-forwarder" # human label; informational only
log_level = "info" # tracing EnvFilter directive (default: "info")
log_format = "json" # "json" (default) or "pretty"
shutdown_drain_secs = 30 # graceful-drain budget on SIGTERM (default: 30)
# [defaults] section — values applied to rules that omit the field
[defaults]
protocol = "tcp" # optional; rules can still override
udp_max_flows = 1024 # per-rule UDP flow table cap (default: 1024)
udp_flow_idle_secs = 60 # idle-flow expiry in seconds (default: 60)
prefer_ipv6 = false # address-family preference (default: false)
# [[rule]] — one or more required
[[rule]]
name = "ssh-tunnel" # unique, used in log output
protocol = "tcp" # "tcp" or "udp"
listen_port = 2222 # single port } mutually exclusive
# listen_ports = "8000-8009" # port range }
target = "10.0.0.5:22" # single target } mutually exclusive
# targets = [...] # multi-target }
prefer_ipv6 = false # per-rule override (optional)
udp_max_flows = 1024 # per-rule override for UDP (optional)
udp_flow_idle_secs = 60 # per-rule override for UDP (optional)Port ranges
Use listen_ports with a "lo-hi" string to map a contiguous listen
window to the same-offset target window:
[[rule]]
name = "web-range"
protocol = "tcp"
listen_ports = "8000-8009"
target = "10.0.0.10:8000-8009"The listen and target ranges must have the same width.
Multi-target failover
Use the targets list form for priority-ordered failover:
[[rule]]
name = "ha-https"
protocol = "tcp"
listen_port = 8443
targets = [
{ host = "primary.internal", port = 443, priority = 0, proxy_protocol = "v2" },
{ host = "secondary.internal", port = 443, priority = 10, proxy_protocol = "v2" },
]Lower priority value = higher preference. The forwarder tries the
highest-priority healthy target; on connection failure it falls back in
priority order.
PROXY protocol
Set proxy_protocol = "v1" or "v2" on any target (single or inside
targets = [...]) to prepend a PROXY protocol header before the upstream
connection. Useful when the backend needs the original client address.
CLI flags
| Flag | Default | Description |
|---|---|---|
--config <PATH> | ./portunus.toml | Path to the TOML config file |
--check | false | Validate config and exit (0 = valid, 2 = invalid) |
--log-level <LEVEL> | from config | Override log level |
--log-format <FMT> | from config | Override log format (json or pretty) |
--no-stats | false | Disable the [stats] UDS server (daemon only) |
--stats-socket <PATH> | from config | Override [stats].socket_path (daemon only) |
The stats subcommand has its own flags — see Live stats dashboard.
When --config is omitted the binary loads ./portunus.toml from the
working directory. Exit 2 if that file does not resolve.
Signals
| Signal | Behaviour |
|---|---|
SIGTERM / SIGINT | Graceful shutdown — active connections drain for up to shutdown_drain_secs, then the process exits 0. |
SIGHUP | No-op in the current release (config reload is not yet supported). |
The shipped systemd unit deliberately omits ExecReload, so
systemctl reload portunus-standalone reports "Job type reload is not
applicable" rather than killing the service. Edit the TOML and run
systemctl restart portunus-standalone.
Example configs
Minimal
The smallest config that the binary will accept: one rule, no [global]
or [defaults] blocks (every optional field falls back to its default).
[[rule]]
name = "ssh"
protocol = "tcp"
listen_port = 2222
target = "10.0.0.5:22"Full
Every field the schema recognises, plus one rule per common scenario (single TCP, TCP range, multi-target failover with PROXY protocol, DNS target, single UDP, UDP range). Copy and trim as needed.
# ─────────── Global (all fields optional) ───────────
[global]
label = "edge-1" # informational label printed in logs
log_level = "info" # EnvFilter; e.g. "portunus=debug,info"
log_format = "json" # "json" (default) or "pretty"
shutdown_drain_secs = 30 # graceful-drain budget on SIGTERM
# ─────────── Defaults (inherited by rules that omit the field) ───────────
[defaults]
protocol = "tcp" # default protocol when a rule omits it
udp_max_flows = 1024 # per-rule UDP flow table cap
udp_flow_idle_secs = 60 # idle UDP flow expiry, seconds
prefer_ipv6 = false # DNS address-family preference
# ─────────── Rules ───────────
# 1) Single-port TCP
[[rule]]
name = "ssh-jump"
protocol = "tcp"
listen_port = 2222
target = "10.0.0.5:22"
# 2) TCP port range (listen and target widths must match)
[[rule]]
name = "web-range"
protocol = "tcp"
listen_ports = "8000-8009"
target = "10.0.0.10:8000-8009"
# 3) Multi-target failover + PROXY protocol v2
[[rule]]
name = "ha-https"
protocol = "tcp"
listen_port = 8443
targets = [
{ host = "primary.internal", port = 443, priority = 0, proxy_protocol = "v2" },
{ host = "secondary.internal", port = 443, priority = 10, proxy_protocol = "v2" },
]
# 4) DNS target (resolved on each connect; `prefer_ipv6` overrides defaults)
[[rule]]
name = "api"
protocol = "tcp"
listen_port = 9000
target = "api.internal.example.com:443"
prefer_ipv6 = false
# 5) Single-port UDP with bumped flow cap + idle timeout
[[rule]]
name = "game-udp"
protocol = "udp"
listen_port = 27015
target = "10.0.0.20:27015"
udp_max_flows = 4096
udp_flow_idle_secs = 120
# 6) UDP port range
[[rule]]
name = "voice-udp"
protocol = "udp"
listen_ports = "30000-30019"
target = "10.0.0.30:30000-30019"Validate any config without starting the service:
# binary install
portunus-standalone --check --config /etc/portunus/standalone.toml
# docker
docker run --rm \
-v "$PWD/portunus.toml:/etc/portunus/standalone.toml:ro" \
ghcr.io/zingerlittlebee/portunus-standalone:latest \
--config /etc/portunus/standalone.toml --checkPrints ok and exits 0 on success, exits 2 on error.
Verifying the installation
Confirm the rule is active and traffic actually flows.
systemd install:
systemctl status portunus-standalone --no-pager
journalctl -u portunus-standalone -n 20 --no-pager
ss -ltn | grep 2222 # check the listen port is boundDocker install:
docker logs portunus-standalone | head -20
docker exec portunus-standalone ss -ltn 2>/dev/null || ss -ltn | grep 2222You should see one event per rule on startup:
{"event":"standalone.rlimit_nofile","soft":65535,"hard":65535,…}
{"event":"rule.activated","rule_id":"…","listen_port":2222,…,"target":"10.0.0.5:22-22"}End-to-end TCP echo (replace 7777/7778 with your rule's ports; run the upstream first so the connect succeeds):
sudo apt-get install -y socat
nohup socat TCP-LISTEN:7778,reuseaddr,fork EXEC:/bin/cat </dev/null >/tmp/up.log 2>&1 &
( echo "verify-payload"; sleep 1 ) | socat -t 3 - TCP:127.0.0.1:7777
# → verify-payloadThe forwarder logs rule.conn_closed with non-zero bytes_in/bytes_out
for each closed connection.
Live stats dashboard
portunus-standalone stats is a TUI dashboard that streams per-rule
traffic counters from the running daemon over a Unix-domain socket.
No HTTP, no Prometheus, no extra processes — just a single binary
subcommand.
# Interactive TUI (in a terminal):
portunus-standalone stats
# One-shot JSON snapshot (scriptable):
portunus-standalone stats --once | jq '.snapshot.r[] | {id, in, out, conns_active}'For Docker installs:
docker exec -it portunus-standalone portunus-standalone statsThe dashboard shows three tabs:
- Overview — per-rule table: current in/out rate, active connections, UDP flow count.
- Detail — 60 s sparklines for the selected rule, plus cumulative
totals and (for UDP) a saturation gauge against
udp_max_flows. - Errors — non-zero cumulative failure counters per rule
(
port_in_use,upstream_connect_failed,icmp_evict,emsgsize,wouldblock,addflow_dropped,dns_failures,flows_dropped_overflow).
Default keybindings: q quit, ? help, Tab/h/l/←/→ cycle
tab, ↑↓jk select row, p pause, s cycle sort, r reverse sort,
/ filter, c reset baseline.
Configuration
[stats]
enabled = true # default true
socket_path = "/run/portunus/standalone.sock" # Linux default
refresh_ms = 1000 # 250..=5000Daemon-side overrides: --no-stats, --stats-socket <PATH>.
stats-subcommand flags: --socket <PATH> (override the socket the
client connects to) and --once (print one JSON snapshot and exit).
The TUI client picks the default socket per platform:
- Linux →
/run/portunus/standalone.sock(RuntimeDirectory=portunusin the shipped systemd unit creates this automatically) - macOS →
$TMPDIR/portunus-standalone.sock - Override with
--socket <PATH>if either default doesn't apply.
The snapshot cadence is daemon-driven; the client has no request
channel. To change the cadence, edit [stats] refresh_ms and restart
the daemon.
Observability
portunus-standalone logs to stderr in JSON format by default
(switch to log_format = "pretty" for human-readable output during
development). Every rule emits structured events on startup, and a
periodic reporter logs per-rule byte + connection counts every 60 seconds.
There is no embedded HTTP server, Prometheus endpoint, or gRPC stream —
those surfaces belong to portunus-server. Use log aggregation (e.g.
journald, docker logs, a sidecar log shipper) to collect the JSON
output.
Operational notes
- No live config reload.
SIGHUPis a no-op and the shipped systemd unit refusessystemctl reload. To change rules, edit the TOML andsystemctl restart portunus-standalone(ordocker restart portunus-standalone). Established TCP connections drain on the way out (up toshutdown_drain_secs). - Config-file permissions matter.
- systemd: the process runs as
User=portunuswithReadOnlyPaths=/etc/portunus. The installer setschown root:portunus+chmod 0640. Anything stricter (e.g.chmod 0600 root:root) will cause startup to fail. - Docker: the container runs as UID 65532. The host file must be
readable by that UID —
chmod 0644is the simplest fix.
- systemd: the process runs as
- Privileged listen ports (
< 1024). The systemd unit grantsAmbientCapabilities=CAP_NET_BIND_SERVICE, so listening on ports like 22, 53, 80, 443 works without running as root. The Docker image declaresNET_BIND_SERVICEcapability; pass--cap-add NET_BIND_SERVICEif you constrain capabilities. - File-descriptor limit. The binary emits a
standalone.rlimit_nofilelog on startup. The systemd unit setsLimitNOFILE=65535. For Docker pass--ulimit nofile=65535:65535if your daemon defaults are lower. - TCP
splice(2)zero-copy is auto-selected on Linux when no PROXY-protocol prelude is configured. You'll see aproxy.splice_selectedevent on the first connection of the rule. - Port-range rules bind atomically. A rule that declares
listen_ports = "8000-8009"binds all 10 ports up-front; if any one is unavailable the rule's startup fails and the others are released. - Snap-packaged Docker on Ubuntu confines bind-mount sources to
/home,/media,/mnt, and a few other allowlisted paths. Bind-mounts from/root,/tmp, or/etcwill surface asNo such file or directoryinside the container. Keepportunus.tomlunder/home, or install the non-snapdocker-ce/docker.iopackages.
Differences from portunus-client
| Feature | portunus-client | portunus-standalone |
|---|---|---|
| Control plane | gRPC stream to portunus-server | None — TOML only |
| Dynamic rule updates | Yes (server push) | No — restart required |
| RBAC / multi-user | Yes | No |
| Rate limiting / QoS | Yes | No |
| Prometheus metrics | Yes (loopback HTTP) | No |
| Audit log | Via server | Structured log to stderr |
| SNI routing | Yes | No |
| TCP splice fast path (Linux) | Yes | Yes (same forwarder code) |
| PROXY protocol out | Yes | Yes |
Upgrade and uninstall
If you installed via the one-click installer:
# Upgrade to the latest release (or pin --version v1.7.0):
bash install.sh upgrade standalone
# Status (binary version, service active, deploy form):
bash install.sh status standalone
# Service control passthrough:
bash install.sh service standalone restart
# Uninstall (keeps /etc/portunus by default):
bash install.sh uninstall standalone
# Purge: also wipe /etc/portunus and the portunus user:
bash install.sh uninstall standalone --purgeIf you installed via Docker:
docker pull ghcr.io/zingerlittlebee/portunus-standalone:latest
docker stop portunus-standalone && docker rm portunus-standalone
# then re-run the `docker run …` from the Install section