SSH Port Forwarding: Local, Remote and Dynamic Tunnels With Real Examples

2026-11-03来源 dev.to DevOpsEnglish

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.0 opens a real hole. Anyone on your network can use that forwarded port, and GatewayPorts is disabled by default on the server for exactly this reason. Prefer 127.0.0.1 unless 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:

  1. -v on the SSH command. Look for channel 2: open failed — that means the server could not reach the host:port you asked for.
  2. Resolution side. db.internal resolves on the server, not locally. Test with ssh bastion getent hosts db.internal.
  3. Local port already taken. lsof -iTCP:13306 -sTCP:LISTEN. SSH only warns, and only if you read the output.
  4. AllowTcpForwarding in the server config. Many hardened servers set it to no, and the error is a generic channel failure.
  5. PermitOpen restricting 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.

原文:https://www.a8q9.cn/en/article/ssh-port-forwarding-local-remote-and-dynamic-tunnels-with-real-examples-af9228.html

相关文章