A single-binary, high-throughput TCP/UDP port forwarder with load balancing.
It accepts connections on a local port and relays them to a service on another
machine — a remote API, a Redis instance, a database — so it looks like it runs
on localhost. Your host never becomes a routing gateway; it only shuttles bytes
between two sockets.
[ your app ] ──▶ localhost:6379 ──[ proxy-port ]──▶ 192.168.1.10:6379 [ remote redis ]
proxy-port -L :6379=192.168.1.10:6379
# redis-cli -p 6379 now talks to the remote Redis| Single static binary | No runtime, no dependencies. Two Go modules total. |
| Fast TCP path | splice(2) on Linux moves bytes in-kernel — zero userspace copies. TCP_NODELAY keeps small request/response payloads unbuffered. |
| UDP with NAT sessions | Per-client upstream sockets, idle eviction after 60s. Practical for DNS and similar. |
| Scales across cores | SO_REUSEPORT opens N sockets per rule so the kernel spreads TCP accepts and UDP receives across CPUs; each UDP loop owns a private session map (no shared locking). |
| Load balancing | weighted round-robin, least_conn, or iphash affinity across multiple upstreams. Selection is lock-free and allocation-free. |
| Passive health + failover | A backend that fails to dial is parked for a cooldown; traffic fails over to the rest and the backend is retried automatically. |
| Hot reload | SIGHUP applies config changes without dropping in-flight connections. A bad edit is logged and the previous config keeps serving. |
| Bounded shutdown | SIGINT/SIGTERM stops accepting, drains in-flight connections up to drain_timeout, then force-closes stragglers. |
| Load shedding | Optional per-rule connection cap closes excess connections immediately instead of exhausting file descriptors. |
Homebrew (macOS and Linux):
brew install nuumz/tap/proxy-portAPT (Debian / Ubuntu) — trust the signing key once, then install and upgrade like any other package:
curl -fsSL https://nuumz.github.io/proxy-port/public.key | sudo gpg --dearmor -o /usr/share/keyrings/proxy-port.gpg
echo "deb [signed-by=/usr/share/keyrings/proxy-port.gpg] https://nuumz.github.io/proxy-port stable main" | sudo tee /etc/apt/sources.list.d/proxy-port.list
sudo apt-get update
sudo apt-get install proxy-portOther Linux packages — .rpm and .apk (amd64 / arm64) ship with every
release, as does a standalone .deb if you would rather not add the repository:
sudo rpm -i proxy-port_VERSION_linux_amd64.rpm # RHEL/Fedora
sudo apk add --allow-untrusted proxy-port_VERSION_linux_amd64.apk # Alpine
sudo dpkg -i proxy-port_VERSION_linux_amd64.deb # Debian/Ubuntu, one-offPrebuilt binaries — Linux / macOS / Windows on amd64 and arm64, attached to
every release with a
checksums.txt:
# example: Linux amd64 (replace VERSION with the release you want)
curl -fsSLO https://github.com/nuumz/proxy-port/releases/latest/download/proxy-port_VERSION_linux_amd64.tar.gz
tar xzf proxy-port_VERSION_linux_amd64.tar.gz
sudo install -m 0755 proxy-port /usr/local/bin/proxy-portGo install — needs a Go 1.25+ toolchain:
go install github.com/nuumz/proxy-port@latestFrom source:
git clone https://github.com/nuumz/proxy-port.git && cd proxy-port
make build # CGO_ENABLED=0, stripped static binary# One forward, from the command line
proxy-port -L :6379=192.168.1.10:6379
# Several at once; UDP needs an explicit prefix
proxy-port -L 127.0.0.1:8080=10.0.0.5:80 -L udp://:53=8.8.8.8:53
# Load-balance across upstreams (#N sets a relative weight)
proxy-port -L :8080=10.0.0.1:80,10.0.0.2:80,10.0.0.3:80#2
# Or keep a config file and reload it in place
proxy-port init # writes ~/.config/proxy-port/config.yaml
proxy-port -c ~/.config/proxy-port/config.yaml
kill -HUP $(pgrep proxy-port) # apply edits without dropping connectionsA minimal config:
rules:
- name: redis
listen: ":6379"
remote: "192.168.1.10:6379"
- name: api # balanced across three backends
listen: ":8080"
balance: least_conn
remotes:
- "10.0.0.1:80"
- "10.0.0.2:80"
- "10.0.0.3:80#2" # weight 2 under the weighted strategy
- name: dns
proto: udp
listen: ":53"
remote: "8.8.8.8:53"Every tunable — timeouts, buffers, reuseport, connection caps, balancing —
has a documented default and can be overridden globally or per rule. See the
configuration reference.
| Document | What's in it |
|---|---|
| Configuration reference | Every CLI flag and YAML key, defaults, precedence, hot-reload semantics |
| Architecture | Internals, concurrency model, invariants, and how to extend it (new balancing strategy, new protocol) |
| Contributing | Build, test, benchmark, and PR conventions |
| Releasing | Cutting a release; enabling the Homebrew and APT channels |
| Stable hostname over VPN | Guide for reaching a proxy host whose VPN IP changes (Thai) |
The forwarding engine is a self-contained package, but it currently lives under
internal/, so Go will not let you import it from another module yet. To
build on it today, fork or vendor the repository; if you would rather import it
directly, open an issue — moving the package to a public path is a small change
and a welcome request.
Once vendored, the whole engine is three calls:
import "github.com/nuumz/proxy-port/internal/forward"
rule, err := forward.ParseRule(":6379=10.0.0.5:6379")
if err != nil {
return err
}
sup := forward.NewSupervisor(false) // verbose logging off
go sup.Run(ctx, []forward.Rule{rule}) // blocks until ctx is done and the drain finishes
err = sup.Reload(updatedRules) // swap rules without dropping live connections
sup.Stop() // stop everything and drainforward.Rule is a plain struct, so rules can also be built programmatically
instead of parsed from a spec string — including multi-upstream rules with
weights and a balancing strategy. See Architecture for
the types and lifecycle, and Configuration
for what each field means.
Measured on loopback with an in-process echo upstream (see Contributing → benchmarks):
- TCP relay: 0 allocations per round-trip — the data path is
splice(2), so payload bytes never enter userspace. - UDP relay: 2 allocations per round-trip, with recycled 64 KiB buffers so memory tracks concurrent sessions rather than cumulative traffic.
- Upstream selection: 0 allocations, lock-free — ~18 ns (
iphash) to ~50 ns (weighted) per new connection under full parallelism, and it runs once per connection, not per packet. - GC is not on the critical path: the heap stays flat, so
GOGC=offleaves p50/p99/p99.9 unchanged within noise. Tail spikes come from OS scheduling.
Numbers depend on hardware and kernel tuning — reproduce them locally rather than treating them as guarantees.
Issues and pull requests are welcome. Please read CONTRIBUTING.md first — it covers the test/benchmark commands and the invariants (non-disruptive reload, bounded shutdown, non-blocking load shedding) that changes need to preserve.
MIT © nuumz