# Standalone Forwarder (https://portunus.bybee.dev/en/docs/configuration/standalone)



`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.

<Callout>
  New here? [Installation → Standalone install](/en/docs/getting-started/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.
</Callout>

## Install [#install]

Two supported install paths. Pick **Docker** for container hosts and
**One-click installer** for plain Linux servers with systemd.

### Docker [#docker]

The GHCR image `ghcr.io/zingerlittlebee/portunus-standalone:<version>` is
published on every release (multi-arch: `linux/amd64`, `linux/arm64`).

```sh
# 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`](https://github.com/ZingerLittleBee/Portunus/blob/main/crates/portunus-standalone/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/`](https://github.com/ZingerLittleBee/Portunus/tree/main/crates/portunus-standalone/contrib).

### One-click installer (binary + systemd) [#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.

```sh
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`](https://github.com/ZingerLittleBee/Portunus/blob/main/crates/portunus-standalone/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:

```sh
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 [#config-schema]

```toml
# [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 [#port-ranges]

Use `listen_ports` with a `"lo-hi"` string to map a contiguous listen
window to the same-offset target window:

```toml
[[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 [#multi-target-failover]

Use the `targets` list form for priority-ordered failover:

```toml
[[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 [#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 [#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](#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 [#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 [#example-configs]

### Minimal [#minimal]

The smallest config that the binary will accept: one rule, no `[global]`
or `[defaults]` blocks (every optional field falls back to its default).

```toml
[[rule]]
name        = "ssh"
protocol    = "tcp"
listen_port = 2222
target      = "10.0.0.5:22"
```

### Full [#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.

```toml
# ─────────── 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:

```sh
# 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 [#verifying-the-installation]

Confirm the rule is active and traffic actually flows.

**systemd install:**

```sh
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:**

```sh
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:

```json
{"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):

```sh
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 [#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.

```sh
# 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:

```sh
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 [#configuration]

```toml
[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 [#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 [#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 [#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 [#upgrade-and-uninstall]

If you installed via the **one-click installer**:

```sh
# 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**:

```sh
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
```
