Portunus
Configuration

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:latest

Notes:

  • Pin a specific version in production (:1.7.0 rather than :latest).
  • The container runs as UID 65532 (nonroot). The host file must be readable by that UID — chmod 0644 portunus.toml is the simplest fix. If you get error: io error: Permission denied (os error 13), this is why.
  • --network host lets 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 — see contrib/docker-compose.yml.
  • The path inside the container (/etc/portunus/standalone.toml) is fixed by the image's default CMD. The host path can be anywhere.
  • docker compose and Kubernetes manifests live in crates/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 -- standalone

The installer:

  1. Detects OS/arch, downloads the latest release, verifies its SHA-256.
  2. Installs the binary to /usr/local/bin/portunus-standalone (mode 0755).
  3. Creates the portunus system user/group if missing.
  4. Writes a starter /etc/portunus/standalone.toml (mode 0640, owner root:portunus) from contrib/portunus.example.toml.
  5. Installs a hardened unit at /etc/systemd/system/portunus-standalone.service (LimitNOFILE=65535, AmbientCapabilities=CAP_NET_BIND_SERVICE, ProtectSystem=strict, NoNewPrivileges=true).
  6. 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-pager

Running 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

FlagDefaultDescription
--config <PATH>./portunus.tomlPath to the TOML config file
--checkfalseValidate config and exit (0 = valid, 2 = invalid)
--log-level <LEVEL>from configOverride log level
--log-format <FMT>from configOverride log format (json or pretty)
--no-statsfalseDisable the [stats] UDS server (daemon only)
--stats-socket <PATH>from configOverride [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

SignalBehaviour
SIGTERM / SIGINTGraceful shutdown — active connections drain for up to shutdown_drain_secs, then the process exits 0.
SIGHUPNo-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 --check

Prints 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 bound

Docker install:

docker logs portunus-standalone | head -20
docker exec portunus-standalone ss -ltn 2>/dev/null || ss -ltn | grep 2222

You 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-payload

The 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 stats

The 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..=5000

Daemon-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=portunus in 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. SIGHUP is a no-op and the shipped systemd unit refuses systemctl reload. To change rules, edit the TOML and systemctl restart portunus-standalone (or docker restart portunus-standalone). Established TCP connections drain on the way out (up to shutdown_drain_secs).
  • Config-file permissions matter.
    • systemd: the process runs as User=portunus with ReadOnlyPaths=/etc/portunus. The installer sets chown 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 0644 is the simplest fix.
  • Privileged listen ports (< 1024). The systemd unit grants AmbientCapabilities=CAP_NET_BIND_SERVICE, so listening on ports like 22, 53, 80, 443 works without running as root. The Docker image declares NET_BIND_SERVICE capability; pass --cap-add NET_BIND_SERVICE if you constrain capabilities.
  • File-descriptor limit. The binary emits a standalone.rlimit_nofile log on startup. The systemd unit sets LimitNOFILE=65535. For Docker pass --ulimit nofile=65535:65535 if 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 a proxy.splice_selected event 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 /etc will surface as No such file or directory inside the container. Keep portunus.toml under /home, or install the non-snap docker-ce/docker.io packages.

Differences from portunus-client

Featureportunus-clientportunus-standalone
Control planegRPC stream to portunus-serverNone — TOML only
Dynamic rule updatesYes (server push)No — restart required
RBAC / multi-userYesNo
Rate limiting / QoSYesNo
Prometheus metricsYes (loopback HTTP)No
Audit logVia serverStructured log to stderr
SNI routingYesNo
TCP splice fast path (Linux)YesYes (same forwarder code)
PROXY protocol outYesYes

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 --purge

If 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

On this page