Three flags, three completely different behaviours, and a lot of confusion because all of them are described as "tunnels". The mental model that finally made them click for me: the direction is decided by which side opens the listening socket.
-
-L— your machine listens. -
-R— the remote machine listens. -
-D— your machine listens, but as a SOCKS proxy.
Everything else follows from that.
Local forwarding (-L): reach a remote service
ssh -L 13306:db.internal:3306 bastion.example.com
Connect to localhost:13306 on your laptop and the traffic goes through bastion.example.com to db.internal:3306.
Resolution of db.internal happens on the SSH server, not locally. That is the whole point: the database has no public address, but the bastion can see it.
Common variants:
# bind on all interfaces so a container can reach it
ssh -L 0.0.0.0:13306:db.internal:3306 bastion
# pick a free local port and let ssh print it
ssh -L 0:db.internal:3306 bastion
Two things to know:
-
0.0.0.0opens a real hole. Anyone on your network can use that forwarded port, andGatewayPortsis disabled by default on the server for exactly this reason. Prefer127.0.0.1unless you consciously need otherwise. - The tunnel dies with the SSH session. If the connection drops, your local port is gone and the client sees
connection refused— which looks like a database outage but is not.
Remote forwarding (-R): expose a local service to the server
ssh -R 8080:localhost:3000 user@build.example.com
Now build.example.com:8080 reaches port 3000 on your laptop. This is how you demo a local dev server to someone who can only reach the build box, or how you scrape a staging site from a machine inside the network.
By default the remote listener binds to the server's loopback only. To bind publicly you need -R 0.0.0.0:8080:localhost:3000 and GatewayPorts yes in the server's sshd_config. Without the second part the bind silently falls back to loopback.
Dynamic forwarding (-D): a SOCKS proxy
ssh -D 1080 -N -C user@bastion.example.com
This opens a SOCKS5 proxy on localhost:1080. Any application that speaks SOCKS can now route through the bastion, resolving hostnames remotely.
Use it when you need to reach many internal hosts and do not want one -L per service:
curl --socks5-hostname 127.0.0.1:1080 http://wiki.internal/
The --socks5-hostname flag matters: it sends the hostname to the proxy instead of resolving it locally. Without it, wiki.internal has to resolve on your machine, which is often the thing you cannot do.
Chaining through a jump host
Old style, still everywhere:
ssh -L 13306:db.internal:3306 -J bastion.example.com app.internal
Modern style, cleaner, and works with keys and agent forwarding:
ssh -o ProxyJump=bastion.example.com -L 13306:db.internal:3306 app.internal
ProxyJump chains are allowed (bastion1,bastion2), and the final -L target is resolved from the last hop. If you forward on every hop instead, you will create tunnels you did not intend.
Keeping a tunnel alive
Two separate problems, two separate fixes:
1. The SSH session idles out. Add keepalives, either per-connection:
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L 13306:db.internal:3306 bastion
or permanently in ~/.ssh/config:
Host bastion
HostName bastion.example.com
ServerAliveInterval 30
ServerAliveCountMax 3
2. The SSH process dies and nothing restarts it. Either run it under a process manager, or use autossh:
autossh -M 0 -N -L 13306:db.internal:3306 bastion \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3
-M 0 disables autossh's own echo port and lets SSH keepalives do the work. That combination is the one that survives flaky Wi-Fi without false restarts.
Forwarding without opening a shell
-N tells SSH not to run a remote command:
ssh -N -f -L 13306:db.internal:3306 bastion
-f backgrounds it after authentication. Together with keepalives, this is the standard "just give me a tunnel" invocation.
Debugging a tunnel that does not work
Work down this list; it catches almost everything:
-
-von the SSH command. Look forchannel 2: open failed— that means the server could not reach the host:port you asked for. -
Resolution side.
db.internalresolves on the server, not locally. Test withssh bastion getent hosts db.internal. -
Local port already taken.
lsof -iTCP:13306 -sTCP:LISTEN. SSH only warns, and only if you read the output. -
AllowTcpForwardingin the server config. Many hardened servers set it tono, and the error is a generic channel failure. -
PermitOpenrestricting destinations. You will get "administratively prohibited" in verbose mode.
Building the command correctly
The flag order is unforgiving — put -L before the host and it is parsed as a local port, put it after and it may be ignored. The target forms (host:port, host:port:host:port, [ipv6]:port) all fail in different ways when you get the colons wrong.
I use a generator for the non-trivial ones: SSH port forward command generator. It emits the full command with key file and jump host options, which is faster than re-reading the man page for the fourth time this month.
The short version
| Flag | Who listens | Typical use |
|---|---|---|
-L 13306:db:3306 |
your machine | reach a private service |
-R 8080:localhost:3000 |
remote machine | show a local service to a remote host |
-D 1080 |
your machine (SOCKS5) | browse a whole internal network |
Add -N to skip the shell, -f to background it, and keepalives to survive an idle connection. Once you know which side listens, the rest is a checklist.
Top comments (0)