termal.in

← Blog

SSH reverse tunnels (ssh -R): expose a local port through a remote server

· Termalin team sshport-forwardingtunnelingtutorial

You have something running on your laptop — a dev server, an API, a machine at home behind NAT — and something on the other side of the internet needs to reach it. No public IP, no router access, no time to set up a VPN. This is the job of the SSH reverse tunnel, ssh -R: it makes a port appear on a server you can reach, and quietly pipes everything that hits it back to your machine.

It’s the flag people find backwards, so let’s fix the mental model first, then work through the real cases.

The mental model: -L, mirrored

Every SSH tunnel opens a listening port on one machine and ships whatever arrives to somewhere reachable from the other machine. With local forwarding (-L), the port opens on your machine and traffic exits on the server’s side. A reverse tunnel is the exact mirror image:

With -R, the listening port opens on the remote server, and connections to it come out on your side.

That’s the entire trick. The machine that can’t accept inbound connections makes an outbound SSH connection instead — and rides connections back in over it. If the two still blur together, the full family portrait is in SSH port forwarding, explained.

The syntax, read left to right

ssh -R [bind_address:]remote_port:localhost:local_port user@server

Read it as: on the server, listen on remote_port; forward whatever arrives to localhost:local_port — as resolved from my machine. So:

ssh -R 8080:localhost:3000 [email protected]

opens port 8080 on the VPS, and every connection to it lands on port 3000 of your laptop. The destination doesn’t have to be your laptop itself — -R 8080:192.168.1.50:80 forwards to another machine on your LAN, using your laptop as the exit.

One flag worth knowing: -R 0:localhost:3000 lets the server pick any free port, and prints Allocated port 43022 for remote forward so you know which one you got.

Three things people actually build with it

Show a local dev server to a client. Your work-in-progress runs on localhost:3000. Instead of deploying it somewhere just for a demo:

ssh -N -R 8080:localhost:3000 [email protected]

Anyone who can reach demo.example.com:8080 is now looking at the app on your laptop, live. (-N skips the remote shell — you only want the tunnel. Whether anyone or only the server itself can connect depends on GatewayPorts — next section.)

Reach a home machine behind NAT. Your home box can’t accept connections, but it can make them. Have it hold a tunnel out to a small VPS:

# on the home machine
ssh -N -R 2222:localhost:22 [email protected]

Now the VPS’s port 2222 is the home machine’s SSH port, and from anywhere:

ssh -J vps.example.com -p 2222 you@localhost

jumps through the VPS straight into the box behind NAT. This is the classic self-hosted answer to “how do I SSH into my home server.”

Deliver webhooks to your laptop. A payment provider or git host needs to POST to your in-development handler. Tunnel their reachable endpoint back to you:

ssh -N -R 9000:localhost:9000 [email protected]

Point the webhook at the VPS. If you keep the tunnel bound to the server’s loopback (the default), put nginx or any reverse proxy on the VPS in front of 127.0.0.1:9000 — you get TLS termination for free and never expose the raw tunnel port.

GatewayPorts: why nobody else can reach your tunnel

The number-one surprise: the tunnel works when you test it from the server (curl localhost:8080 succeeds) but nobody outside can connect. That’s by design — by default, sshd binds remote-forwarded ports to 127.0.0.1 only, no matter what bind address you asked for.

To open a tunnel to the world, the server’s /etc/ssh/sshd_config needs:

GatewayPorts clientspecified

then restart sshd, and request the bind explicitly: ssh -R 0.0.0.0:8080:localhost:3000 .... (GatewayPorts yes binds every remote forward to all interfaces, which is broader than you usually want.) This is a server-side decision you cannot override from the client — a deliberate safety default, since a reverse tunnel is a hole punched from the inside, and whatever it exposes is now effectively internet-facing. Treat it that way.

Two related server-side switches can bite you: AllowTcpForwarding no disables tunnels entirely, and PermitListen can restrict which ports -R may claim.

Keeping it alive

A reverse tunnel is one TCP connection, and idle TCP connections die. For anything longer than a demo:

ssh -N -R 2222:localhost:22 \
    -o ServerAliveInterval=30 \
    -o ExitOnForwardFailure=yes \
    [email protected]
  • ServerAliveInterval 30 keeps traffic flowing so NAT entries and idle timeouts don’t reap the connection.
  • ExitOnForwardFailure yes makes ssh die loudly if the remote port can’t be bound, instead of connecting anyway and forwarding nothing.
  • autossh wraps the command and restarts the tunnel whenever it drops — the standard tool for tunnels that should survive reboots and flaky networks, usually run as autossh -M 0 -N -R ... under a systemd unit.

And once a tunnel earns a permanent place, move it into ~/.ssh/config with a RemoteForward line so it travels with the host entry — structure covered in the ssh_config guide:

Host homelink
    HostName vps.example.com
    User tunnel
    RemoteForward 2222 localhost:22
    ServerAliveInterval 30
    ExitOnForwardFailure yes

-L vs -R vs -D at a glance

FlagPort opens onTraffic exits onTypical job
-Lyour machineserver’s sidepull a remote DB/dashboard to localhost
-Rthe serveryour sideexpose local service; reach a NATed box
-Dyour machineserver’s side, any destinationSOCKS proxy into a network

When a reverse tunnel is the wrong tool

A reverse tunnel is a superb temporary answer: demos, webhook debugging, rescuing a machine behind NAT. It’s a poor permanent one. If the thing you’re exposing needs to be up for months, serve real users, or survive your laptop sleeping — that’s ingress, and ingress deserves real infrastructure: a reverse proxy on a server, a VPN or overlay network, an actual deployment. The tunnel’s charm is that it’s one command with no standing footprint; the moment you’re writing monitoring for it, you’ve outgrown it.

Troubleshooting the usual failures

  • Warning: remote port forwarding failed for listen port 8080 — the port is taken on the server. Often the culprit is your own previous tunnel: the old sshd process holds the port until the dead connection times out. Keepalives prevent the zombie; -R 0:... sidesteps the collision; ExitOnForwardFailure turns the warning into an error you can’t miss.
  • Works from the server, refused from outside — the listener is on the server’s loopback. That’s the GatewayPorts default doing its job; either configure it or front the port with a reverse proxy.
  • Tunnel connects but the service doesn’t answer — check the local half: is anything actually listening on the local port you named? -R 8080:localhost:3000 with nothing on your :3000 yields connection refused for every visitor.
  • The SSH connection itself won’t establish — that’s a different problem with its own checklist: connection refused and timed out, decoded.

When tunnels become furniture

The reverse tunnel you run once, you type. The one that carries your home lab, your demo box and your webhook endpoint is state you rebuild every morning after every laptop sleep — and lose track of by Thursday. Termalin keeps local, remote and dynamic forwards saved as part of the host they belong to: start them with a click, see at a glance which are live, and let them re-establish with the connection instead of being retyped from shell history. The host list — keys, jump hosts, keepalives and tunnels included — syncs across your machines end-to-end encrypted. The command line teaches you what -R means; a client keeps the tunnels you rely on from depending on your memory.


Termalin is a free, cross-platform SSH client with built-in tunnels, SFTP and a key manager — download it, or see how it handles your keys safely.

Try it on one host.

Termalin is a fast SSH client for you — and your agents.

Free tier · 14-day Pro trial · pricing