SSH Port Forwarding: Local, Remote, and Dynamic Tunnels
What You'll Learn
- The difference between
-L(local),-R(remote), and-D(dynamic) forwarding - How to reach a database behind a bastion, expose a local app, and run a SOCKS proxy
- How to troubleshoot when a tunnel "won't connect"
Quick Summary
- Reach a remote service from your machine →
-L(local forward) - Expose a local service to a remote host →
-R(remote forward) - Send a whole app's traffic through SSH →
-D(dynamic / SOCKS)
Assumptions
- OS: Ubuntu (OpenSSH client / server)
- You can already log in over SSH
- Port numbers are examples; adapt them to your setup
What is SSH Port Forwarding?
Conclusion: It tunnels arbitrary TCP connections inside the encrypted SSH session, letting you reach ports you can't hit directly as long as you can SSH in.
Services behind a firewall or inside a private network can't be reached directly from outside. But if you can SSH in, you can carry another TCP connection inside that SSH session. That is port forwarding (tunneling).
There are three kinds. They differ only in direction and which side listens; the idea is the same.
| Type | Option | Listens on | Use case |
|---|---|---|---|
| Local | -L |
Your side | Use a remote service from your machine |
| Remote | -R |
Remote | Expose a local service to the remote |
| Dynamic | -D |
Your side | Send a whole app through the tunnel |
How Do You Use Local Forwarding (-L)?
Conclusion: The form is
-L localport:desthost:destport. Connections to your local port are forwarded to the destination as seen from the SSH server. Reaching a DB through a bastion is the classic case.
Basic form
$ ssh -L 8080:localhost:80 user@server
Now a connection to your localhost:8080 reaches localhost:80 as seen from server. Open http://localhost:8080 in a browser and it behaves like hitting port 80 on the server.
The desthost is resolved from the SSH server's point of view. localhost means the server itself; another name means a host the server can reach.
Example: reach a DB through a bastion
The database (db.internal:5432) sits behind a bastion and isn't directly reachable from your machine.
$ ssh -L 15432:db.internal:5432 user@bastion
In another terminal, treat your local port 15432 as if it were the real DB.
$ psql -h localhost -p 15432 -U dbuser appdb
Tunnel only (-N / -f)
If you don't need a login shell and only want the tunnel, add -N; to push it to the background, add -f.
$ ssh -fN -L 15432:db.internal:5432 user@bastion
-N: do not run a remote command (forwarding only)-f: go to the background after authentication
A backgrounded tunnel keeps running. Stop it when you're done.
$ ps aux | grep "ssh -fN" $ kill <PID>
How Do You Use Remote Forwarding (-R)?
Conclusion: The form is
-R remoteport:desthost:destport. The remote side listens and forwards to a destination as seen from your machine. Sharing a local dev server temporarily is the common use.
This is the reverse direction. A connection to the remote port reaches the destination as seen from your machine (the one running ssh).
Basic form
$ ssh -R 8080:localhost:3000 user@server
A connection to localhost:8080 on server is forwarded to your localhost:3000. Your local dev server (port 3000) becomes reachable from the server.
The gotcha for public access: GatewayPorts
By default, -R listens only on the remote loopback (127.0.0.1). The server itself can reach it, but nothing outside the server can. To listen on all interfaces, the server's /etc/ssh/sshd_config needs:
GatewayPorts yes
Reload after changing it: sudo systemctl reload ssh.
GatewayPorts yes exposes your local service to outside networks. Limit the scope, authentication, and duration, and revert both the tunnel and the setting as soon as you're done.
What is Dynamic Forwarding (-D)?
Conclusion:
-D portopens a local SOCKS proxy. Instead of one tunnel per destination, an app's traffic exits through the SSH server.
-L is "one port, one destination," but -D doesn't fix a destination. It creates a SOCKS proxy locally; any app pointed at it sends traffic out through the SSH server.
$ ssh -D 1080 user@server
Now localhost:1080 is a SOCKS5 proxy. Point your browser or curl at it.
$ curl --socks5-hostname localhost:1080 https://example.com
--socks5-hostname resolves names on the proxy side. Use it to reach hosts that only the SSH server's DNS can resolve. Plain --socks5 resolves locally instead.
Why Won't My Tunnel Connect?
Conclusion: Most failures are a port clash, a misunderstanding of where the destination name resolves, or missing remote-exposure settings. The error text pinpoints which.
bind: Address already in use
The listening port on your side (or the remote, for -R) is already taken. Use another port or find the process holding it.
$ ss -tlnp | grep :8080
channel ... open failed: connect failed
The tunnel is up, but the SSH server can't reach the destination. Verify the destination host, port, and reachability from the server.
$ ssh user@server $ nc -vz db.internal 5432
-R works but outside hosts can't connect
Usually the missing GatewayPorts above. Even -R 0.0.0.0:8080:... won't help if the server config is still no.
Common confusion: which side resolves the destination name?
- In
-L 15432:db.internal:5432,db.internalresolves on the SSH server - In
-R 8080:localhost:3000,localhost:3000resolves on your machine
When "it resolves locally but won't connect," you've usually mixed up the resolving side.
How Do You Save Tunnels in ~/.ssh/config?
Conclusion: Instead of long options every time, put
LocalForward/RemoteForward/DynamicForwardin your config and just runssh host.
Host db-tunnel
HostName bastion.example.com
User user
LocalForward 15432 db.internal:5432
Host socks
HostName server.example.com
User user
DynamicForward 1080
From then on, just name the host.
$ ssh -fN db-tunnel
For more on the config file, see SSH config Tips.
Summary and Safe Templates
Conclusion: Pick
-Lor-Rby direction, and-Dwhen the destination isn't fixed. Don't leave tunnels open; stop them when you're done.
Copy-paste templates
# Local forward (remote DB to your machine) ssh -fN -L 15432:db.internal:5432 user@bastion # Remote forward (your dev server to a remote host) ssh -R 8080:localhost:3000 user@server # Dynamic forward (SOCKS proxy) ssh -D 1080 user@server