-L forwards a port from your machine into the remote network. -R forwards from the server back to you. -D opens a SOCKS proxy. The failure that eats afternoons is -R: sshd binds remote forwards to loopback because GatewayPorts defaults to no, and a client-side bind address cannot override that. The server rebinds rather than refusing.
By LK Wood IV · 2026-08-15 · ~10 min read · St. Louis County, MO
Three flags. The third is where the afternoon goes.
ssh -L 8080:internal-host:80 user@server # reach IN
ssh -R 8080:localhost:80 user@server # expose OUT
ssh -D 1080 user@server # SOCKS proxy
-L opens a listener on your machine. -R opens one on the server. That is the entire difference. Inverting it is the beginner mistake, and it announces itself immediately, which makes it the cheap one.
The expert mistake is different, and it is the reason this page exists: -R does not fail when it does not work. It succeeds into a state that is not the one you asked for.
The -R trap
You run this on a home machine, against a VPS with a public IP:
ssh -R 8080:localhost:80 user@vps
You expect http://vps-public-ip:8080 to reach the web server at home. From the VPS itself, curl localhost:8080 works perfectly, which is the detail that sends people off debugging their home firewall, their router, and eventually their ISP, when none of those were ever in the path. From anywhere else: connection refused.
Nothing errored. The tunnel is up. It is bound to 127.0.0.1 on the VPS, which is a perfectly good place for a listener to be if that is what you asked for, and you did not.
That is sshd’s documented default. Remote forwards bind loopback unless GatewayPorts is enabled, and it defaults to no.
Then comes the second half, which is the part that costs the extra hour. You reasonably try to force it from the client:
ssh -R 0.0.0.0:8080:localhost:80 user@vps # still loopback-only
It does not help.
ssh(1) is explicit: specifying a remote bind_address will only succeed if the server’s GatewayPorts option is enabled. Your bind address is a request, not an instruction — the server is free to override it, and by default it does exactly that, binding loopback anyway rather than refusing the forward and saying why. A refusal would have cost you thirty seconds. The override costs you the evening.
This is documented in OpenSSH’s own tracker as bug 1297, filed by someone who noticed the socket was always bound to 127.0.0.1 and [::1] and that -L had no such problem. That is the shape of the confusion: local forwards obey you, remote forwards negotiate with a server whose defaults you did not read.
Fixing it, on the correct side
On the server, in sshd_config:
GatewayPorts clientspecified
Then reload sshd. Not your session. The daemon.
I recommend clientspecified over yes deliberately. yes forces remote forwards to bind the wildcard address for every client on that server — a blunt, global change that removes the option of a loopback-only forward from everyone. clientspecified gives each client the choice, so -R 0.0.0.0:8080:... binds wide and a plain -R 8080:... stays local.
The two GatewayPorts
Here is the detail that turns a five-minute fix into a long one, and it is purely a naming accident.
There are two GatewayPorts options with near-identical wording:
| File | Governs |
|---|---|
sshd_config (server) | -R listeners the server opens on your behalf |
ssh_config (client) | your own -L and -D listeners |
They live in different files, on different machines, and do different jobs. Editing the client one, reloading nothing, and then wondering for forty minutes why the reverse tunnel is still loopback-only is an experience I would rather you skip.
Debugging -R? Server file. Every time.
-L, properly
ssh -L [bind_address:]port:host:hostport destination
host:hostport is resolved from the server’s perspective, and that is the entire point of the flag: you are borrowing the server’s view of the network for the duration of the session, which is why it reaches things your own machine has no route to. This reaches a NAS at 192.168.1.50 that your laptop cannot route to:
ssh -L 8080:192.168.1.50:80 user@homelab-gateway
Then browse localhost:8080. That is it.
By default that listener binds loopback on your machine, so nobody else on the coffee-shop wifi can use your tunnel. That default is correct. Leave it.
-L also accepts Unix sockets on either end, which is how you reach a socket-only service like a database or a Docker daemon without publishing a TCP port for it.
-D, the one people underuse
ssh -D 1080 user@server
That is a local SOCKS 4/5 listener. Point a browser’s SOCKS proxy at localhost:1080 and its traffic emerges from the server, with DNS resolved there too if the client is configured for remote DNS.
I reach for this more than -L when I am poking at a homelab, because it needs no per-service forwards. One flag, and the whole network is reachable by its internal names for as long as the session lives.
It is not a VPN. It carries only what an application deliberately sends through the proxy, so anything not SOCKS-aware ignores it completely and silently, which is a distinction people discover at the worst possible moment. If you want everything routed, you want WireGuard or Tailscale, not this.
-J, which is not a tunnel at all
ssh -J bastion.example.com user@internal-host
ProxyJump reaches a final destination through an intermediate. Nothing is published, no listener opens, and the bastion does not see your session in cleartext — your client negotiates end-to-end with the real target.
Keep the distinction clean.
- Administrative access to a machine behind a bastion is a
-Jjob. - Exposing a service is a
-Lor-Rjob.
I have watched people build an elaborate -L chain for something -J does in one flag, in ~/.ssh/config, permanently:
Host internal-*
ProxyJump bastion.example.com
“administratively prohibited”
channel 3: open failed: administratively prohibited
Two things about this message. The first one saves you a search.
First: the 3 is meaningless. It is OpenSSH’s local channel id from a format string. Searching the literal message with the number in it is why people find nothing.
Second: it means the server refused. Almost nothing more. There are at least three distinct causes, all server-side:
AllowTcpForwardinginsshd_configdisallowing that direction. It takeslocalandremoteas values, not just yes and no, so a server can permit-Land refuse-R.- A key option in
authorized_keys—no-port-forwarding, orrestrict, which turns everything off and then re-enables only what you name after it. permitopen/PermitOpenrestricting forwards to a specific host and port list that does not include the one you asked for.
Check them in that order. The authorized_keys case catches people out most often, because the server’s global config reads permissive and the restriction is sitting on the key itself, which is not where anyone looks second.
Privileged ports behave differently by direction
ssh(1) gives two different rules, and they are easy to conflate:
- For
-L: only the superuser can forward privileged ports. That is on your side. - For
-R: privileged ports can be forwarded only when logging in as root on the remote machine. That is on the server’s side.
So -R 80:localhost:8080 to a VPS fails unless you are logging in as root there, which you should not be. Bind something above 1024 and put a reverse proxy in front of it.
Keeping a tunnel alive
An SSH tunnel is a process. Processes die. For anything you depend on, put it under supervision rather than in a terminal you will eventually close:
# /etc/systemd/system/tunnel.service
[Service]
ExecStart=/usr/bin/ssh -N -T -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes \
-R 8080:localhost:80 user@vps
Restart=always
RestartSec=10
-N runs no remote command, -T allocates no TTY. ExitOnForwardFailure=yes earns its place: without it, ssh happily stays connected when a forward could not be established, and systemd sees a healthy process fronting a tunnel that carries nothing.
ServerAliveInterval matters because an idle tunnel crossing a NAT gateway is exactly the kind of connection a middlebox reaps without telling either end, leaving you with a socket that looks established on both sides and moves nothing.
Where this stops being the right tool
Reverse tunnels are the classic answer to “my home connection has no public IP”, and for one or two services they remain a genuinely good one, because the alternative is standing up infrastructure to solve a problem that a single flag already solves. They work. I still keep one for a couple of things.
But if you find yourself maintaining several, plus a systemd unit for each, plus GatewayPorts on a server whose config you now have to remember, you have hand-built a worse mesh VPN. That is the point to look at Tailscale or WireGuard, or at running the control plane yourself.
The honest dividing line I use is this. One or two forwards, occasional, and -R is fine.
More than that, or anything a household depends on, and the tunnel collection has quietly become technical debt with a systemd unit attached to each piece.
What I have not tested
I have not verified whether a current client surfaces the loopback downgrade under -v. OpenSSH added a debug message for this case in the 6.4 era, and I could not establish from the sources I read whether that reliably reaches a modern user’s terminal. Assume it is silent and check the listener yourself with ss -tlnp on the server.
I have not tested any of the authorized_keys restriction interactions on a live sshd during this write-up, so treat the ordering advice as a plan of attack rather than a measured result.
What getting it wrong costs
The -R trap costs an evening. That is the good outcome: you conclude nothing works, and go read the docs.
The bad outcome is GatewayPorts yes set globally on a server, forgotten, on a box that also hosts something else. Now every remote forward any account opens binds the wildcard address by default. Somebody tunnels an admin panel out to test something, leaves it up, and there is a listener on a public IP that nobody remembers creating.
clientspecified costs nothing extra and does not accumulate that debt.
Frequently asked questions
What is the difference between ssh -L and ssh -R?
Why does my ssh -R tunnel only work from the server itself?
Can I fix that by specifying a bind address like -R 0.0.0.0:8080?
Which GatewayPorts do I edit?
Should I set GatewayPorts yes?
What does 'channel 3: open failed: administratively prohibited' mean?
What is ProxyJump and how is it different from a tunnel?
Can I forward a privileged port under 1024?
Evidence ledger
- Last updated
- Methodology
- This networking guide was written and edited by Lowell K. Wood IV in St. Louis County, MO. Specs and prices verified against vendor and project documentation current on the date above. Full editorial standard: methodology.
- Update log
- 2026-08-15 — Last reviewed and updated.
- Corrections
- Spotted an error or a stale number? Email hello@techfuelhq.com. Confirmed corrections are added to the update log above.