Portato is an SSH port-forwarding manager with a TUI. It lets you turn individual SSH tunnels on and off, restart them, and watch their status from a single screen — either running standalone, or attached to a background daemon.
The single binary works in several modes:
| Command | What it does |
|---|---|
portato |
Smart launcher: attach to a running daemon, or start standalone TUI |
portato daemon |
Background process running tunnels + an IPC server (unix socket) |
portato attach |
TUI client connected to a running daemon |
portato list |
Print status of all tunnels (stdout) |
portato enable <name> |
Enable a tunnel on the daemon |
portato disable <name> |
Disable a tunnel on the daemon |
portato restart <name> |
Restart a tunnel |
portato reload |
Reload the daemon's config from disk (also auto-reloads on change) |
portato stop |
Stop the running daemon (graceful, via SIGTERM) |
portato install |
Install system autostart (launchd / systemd --user) |
portato uninstall |
Remove system autostart |
portato add-identity <path> |
Cache a passphrase for a passphrase-protected SSH key (OS keyring) |
portato forget-identity <path> |
Forget a cached identity passphrase |
portato doctor |
Diagnose the setup (config, keys, agent, daemon, logs) |
portato version |
Print the version |
portato license |
Print license information (--full prints the full MIT text) |
All channels are built from the same release for macOS, Linux, and Windows.
Homebrew (macOS / Linuxbrew):
brew install --cask portuber/tap/portatoScoop (Windows):
scoop bucket add portuber https://github.com/portuber/scoop-bucket
scoop install portuber/portatoScoop needs git for buckets — if it reports git is required, run scoop install git first.
Binary — download the archive for your platform from the
latest release, extract
it, and put the portato binary on your PATH:
# macOS / Linux (tar.gz)
tar -xzf portato_<version>_macOS_arm64.tar.gz # or linux_amd64, linux_arm64, …
install -m 0755 portato ~/.local/bin/portato
portato version# Windows (zip)
Expand-Archive portato_<version>_Windows_x86_64.zip
# move portato.exe into a directory on your PATH
portato versiondeb / rpm (Linux) — from the latest release:
sudo dpkg -i portato_<version>_linux_amd64.deb
# or: sudo rpm -i portato_<version>_linux_amd64.rpmAlpine (apk) — from the latest release:
sudo apk add --allow-untrusted portato_<version>_linux_amd64.apk
# arm64 (e.g. Raspberry Pi): portato_<version>_linux_arm64.apkThe apk is unsigned, so --allow-untrusted is required. It is the same static
build as the tarball/deb/rpm (CGO is disabled), so it runs on musl unchanged.
go install (needs Go 1.26+):
go install github.com/portuber/portato/cmd/portato@latestThe version baked into the binary comes from the git tag at build time.
make build # produces bin/portato
make run # go run ./cmd/portato
make test # go test ./...
make vet # go vet ./...
make fmt # gofmt -w .Requires Go 1.26+.
Releases are built with goreleaser across the
darwin/linux/windows × amd64/arm64 matrix, producing per-target tarballs and a
Windows zip, a Homebrew cask, a Scoop manifest, deb/rpm/apk packages, and a
checksums.txt. To build a local snapshot (no publish, writes to dist/):
make snapshot # needs goreleaser: go install github.com/goreleaser/goreleaser/v2@latestEach tunnel has a type:
type |
SSH flag | Meaning |
|---|---|---|
local |
-L |
listen here, forward to remote on the host (→ in UI). |
remote |
-R |
listen on the host, forward back here (← in UI). |
dynamic |
-D |
a SOCKS5 proxy on local, all traffic via the host (⇄ *). |
For a remote tunnel, remote is the address listened on the SSH server, and
local is the address connections are forwarded to on this machine. A bare
port (or :port) binds all interfaces on the host (*:port) — the default,
so a reverse forward exposes your local service through the server. Use an
explicit host for loopback-only (127.0.0.1:port) or a specific interface:
tubers:
- name: pull-redis
type: remote
remote: 16379 # listened on the server on all interfaces (*:16379)
local: 6379 # forwarded to the local redis
ssh: user@bastion.example.comA non-loopback bind on the host — which now includes the bare-port default
— requires GatewayPorts yes (or clientspecified) in the server's
sshd_config, plus the port open in the host firewall. Otherwise sshd silently
binds loopback and the public address won't be reachable. For a server-internal
forward only, set remote: 127.0.0.1:16379 explicitly.
A dynamic tunnel runs a SOCKS5 proxy on local. There is no fixed remote —
each connection's destination is read from the SOCKS request and dialed on the
host side, so you can reach any internal address through the bastion without a
forward per port:
tubers:
- name: socks
type: dynamic
local: 1080 # SOCKS5 proxy -> 127.0.0.1:1080
ssh: user@bastion.example.comUse it like any SOCKS5 proxy (no auth, loopback bind):
curl --socks5 127.0.0.1:1080 http://internal-host.example.com
# or HTTP-through-SOCKS:
ALL_PROXY=socks5://127.0.0.1:1080 curl http://internal-host.example.comFor a browser, set the SOCKS5 host to 127.0.0.1 and port 1080 (enable "Proxy
DNS when using SOCKS v5" so names resolve on the bastion too). The proxy
reconnects automatically if the SSH session drops.
portato install registers the daemon with your OS's service manager so it
starts automatically at login (or boot). portato uninstall removes it.
Tunnels are disabled by default — only the control daemon comes up; enable
the ones you need from the TUI or with portato enable <name>.
Both commands take an optional --label (default dev.portato.daemon) and
honour the global --config flag. Run them from a built binary
(make build && ./bin/portato install); running from go run works but
prints a warning, since the temp binary path is unstable.
portato install writes a per-user LaunchAgent and loads it:
- plist:
~/Library/LaunchAgents/dev.portato.daemon.plist RunAtLoad=true,KeepAlive=true(the daemon is restarted after any exit)- logs:
~/Library/Logs/portato.logand.err.log
Inspect / control it directly:
launchctl print "gui/$(id -u)/dev.portato.daemon" # status
launchctl bootout "gui/$(id -u)/dev.portato.daemon" # stop (or `portato uninstall`)portato install writes a --user unit and enables it:
- unit:
~/.config/systemd/user/portato.service Restart=on-failure(restarted only on a crash, not a clean exit)- lingering is enabled (
loginctl enable-linger) so the daemon runs without an active session; logs go to the journal —journalctl --user -u portato
systemctl --user status portato # status
systemctl --user disable --now portato # stop (or `portato uninstall`)portato install writes a per-user entry in the HKCU registry Run key so the
daemon launches at login:
- key:
HKCU\Software\Microsoft\Windows\CurrentVersion\Run, valuePortato→"<binary>" daemon --config "<config>"(paths are quoted so a location with spaces survives the shell's command-line parse) - unlike launchd's
KeepAlive, the Run key only starts the daemon at login — it is not restarted after a crash (a full Windows Service / SCM is a later refinement) portato uninstallremoves the value
Inspect it directly:
reg query HKCU\Software\Microsoft\Windows\CurrentVersion\Run /v PortatoPortato runs natively on Windows (built and shipped from the same release):
- Config:
%AppData%\portato\config.yaml. - IPC: the daemon listens on a named pipe,
\\.\pipe\portato(no TCP, no socket file). The smart launcher /attachfind it automatically. - ssh-agent: a key loaded into the OpenSSH agent (the
ssh-agentservice) is reached over the agent's named pipe\\.\pipe\openssh-ssh-agent; there is noSSH_AUTH_SOCKon Windows. As elsewhere, a key in the agent is tried before identity and password. portato stop: on Windows it terminates the daemon (there is no SIGTERM), so it stops immediately rather than draining.- Autostart is a per-user Run key — see above.
- Per-tunnel logs — press
lin the TUI to open the selected tunnel's live log (scrolling with↑↓/pgup/pgdn/g/G,Ltoggles the debug level,esc/lcloses). Logs are kept in an in-memory ring buffer; on disk they go to~/Library/Logs/portato.log(macOS) or the journal (Linux). - Themes — the TUI picks a palette automatically:
NO_COLORforces monochrome,COLORFGBG="fg;bg"selects dark (bg ≤ 6) vs light, default dark. Force one explicitly withPORTATO_THEME=light|dark|mono(orautoto fall back to the automatic detection). The light theme paints a light background across the whole surface (a real light mode), so it reads as a strong inverse of dark regardless of your terminal's own background. portato doctor— checks config validity, identity keys andssh-agent,known_hosts, daemon reachability over the local IPC socket (or named pipe on Windows) and its owner-only permissions, the autostart entry (launchd plist / systemd unit / Windows Run key), and (Linux) lingering. Prints a✓/✗line per check and exits non-zero on any failure.
When a tunnel connects to a host not in ~/.ssh/known_hosts and
accept_new_hosts: false (the default), the TUI shows the key fingerprint and
offers to accept it inline (y appends it to known_hosts and restarts the
tunnel). To trust new hosts automatically instead, set:
defaults:
accept_new_hosts: true| Symptom | Check |
|---|---|
Tunnel stuck on ✗ host key not in known_hosts |
Accept the key in the TUI, or set accept_new_hosts: true. |
✗ listen ...: address already in use |
A local port is busy — lsof -i :<port> to find and stop the holder. |
portato list errors with "daemon not running" |
Start the daemon: portato daemon, or portato install to autostart it. |
✗ auth failed |
Start ssh-agent / ssh-add, or set an identity: key. Run portato doctor. |
| Tunnels die after logout (Linux) | Enable lingering: loginctl enable-linger "$USER". |
The source of truth lives in docs/:
docs/SPEC.md— technical specification (stack, architecture, config, IPC, TUI).docs/ROADMAP.md— phase status.docs/CONVENTIONS.md— how phases are planned and implemented.
Portato is licensed under the MIT License. All dependencies are
permissive (MIT / Apache-2.0 / BSD); there is no copyleft. Run portato license
(or portato license --full for the full MIT text) to see the same from the
binary; third-party notices ship in THIRD_PARTY_LICENSES.txt.
Portato uses Semantic Versioning. Releases are tagged
vX.Y.Z.