SSH Port Forwarding Explained: Local, Remote and Dynamic
The three kinds of SSH tunnel and what each does to your traffic — with the mental model that makes the -L, -R and -D flags obvious instead of memorised.
Tunnels · Bartłomiej Żabicki · updated 2026-07-26 · 11 min read
SSH port forwarding is one of those features that everyone copies from a Stack Overflow answer and nobody quite remembers. The flags look interchangeable, the documentation is terse, and getting the direction wrong produces a connection that hangs instead of an error that explains itself.
There is a single mental model that makes all three forms obvious. Learn it once and you will never look up the flags again.
The model: the flag names where the door opens
Every SSH tunnel opens a listening port somewhere and forwards whatever arrives there to a destination reached from the other end of the connection.
The letter tells you which side gets the listening port:
-L— Local. The port opens on your machine. Traffic goes out through the server. You reach something the server can reach.-R— Remote. The port opens on the server. Traffic comes back to your machine. The server's network reaches something you can reach.-D— Dynamic. A SOCKS proxy port opens on your machine, and the destination is decided per-connection by the application. You reach anything the server can reach.
That is the whole thing. -L and -R differ only in where the door is; -D is -L without
committing to one destination in advance.
Local forwarding (-L): reach into a private network
The most common case by far. Your production database listens on 127.0.0.1:5432 — as it
should — so nothing outside the server can connect to it. You want your laptop's database
client to talk to it anyway.
ssh -L 5432:localhost:5432 deploy@web1.example.com
Read the argument as three parts, [bind:]localport:desthost:destport:
5432— open this port on my machinelocalhost:5432— and connect it to this address as resolved from the server
That second point is the one people trip over. localhost in a -L argument does not
mean your laptop. It is resolved on the server side, so it means the server's own loopback.
Point your client at localhost:5432 locally and you land on the server's Postgres.
Two more useful variants:
# a database on a different host, reachable only from the server
ssh -L 5432:db.internal:5432 deploy@bastion.example.com
# an internal dashboard, on a local port that doesn't collide
ssh -L 8081:grafana.internal:3000 deploy@bastion.example.com
By default the local port binds to 127.0.0.1 only, so nobody else on your café Wi-Fi can
use your tunnel. If you deliberately want to share it, -L 0.0.0.0:5432:localhost:5432 binds
to every interface — think carefully before doing that.
Remote forwarding (-R): let the server reach you
The mirror image, and much rarer. A webhook provider needs to POST to the app running on your laptop, or a server needs to pull from a service only your machine can see.
ssh -R 9000:localhost:3000 deploy@web1.example.com
Now port 9000 on the server forwards to port 3000 on your machine. Something on
the server running curl localhost:9000 hits your local dev server.
One gotcha: by default the server binds that port to its own loopback, so only processes on the server can use it. Exposing it to the server's network requires the server's sshd to allow it:
# /etc/ssh/sshd_config on the server
GatewayPorts clientspecified
Without that, -R 0.0.0.0:9000:... silently downgrades to loopback-only. If a remote forward
"works but nothing can reach it", this is why.
Remote forwarding is also the trick behind reverse tunnels for machines with no public address — see the homelab guide for that pattern.
Dynamic forwarding (-D): a SOCKS proxy
Instead of naming one destination, hand your applications a proxy and let them decide.
ssh -D 1080 deploy@bastion.example.com
Port 1080 on your machine is now a SOCKS5 proxy. Configure a browser to use it and every
request is made from the server, so internal hostnames resolve and internal dashboards work
as if you were sitting in that network.
Two limits worth knowing:
- It only carries TCP. No ICMP, no plain UDP — so
pingwill not work through it, and neither will DNS unless the client sends DNS through the proxy (browsers configured for SOCKS5 generally can, with remote DNS enabled). - Applications must opt in. A SOCKS proxy is not a VPN; anything not configured to use it ignores it entirely. That is sometimes exactly what you want — one browser profile inside the network, everything else normal.
Choosing between them
| You want to… | Use | Example |
|---|---|---|
| Query a DB that listens on the server's loopback | -L | -L 5432:localhost:5432 |
| Open an internal dashboard in your browser | -L | -L 8081:grafana.internal:3000 |
| Let a webhook reach your laptop | -R | -R 9000:localhost:3000 |
| Give a server access to something only you can see | -R | -R 8080:intranet.local:80 |
| Browse a whole internal network | -D | -D 1080 |
| Reach a machine with no public IP | -R from that machine | reverse tunnel to a VPS |
Making tunnels survive real life
A forward exists only as long as its SSH connection. Three things make that less fragile.
Keep the connection warm. Add to ~/.ssh/config:
Host *
ServerAliveInterval 30
ServerAliveCountMax 3
ExitOnForwardFailure yes
ExitOnForwardFailure yes is the underrated one: without it, SSH happily connects even when
the forward could not be established — usually because the local port was already in use —
and you spend ten minutes debugging a client that was never talking to the tunnel.
Declare forwards in the config, not the command line. Then they are the same every time, and they are importable by other tools — see the SSH config file:

Host prod-db
HostName bastion.example.com
User deploy
LocalForward 5432 localhost:5432
LocalForward 6379 localhost:6379
Note the config-file syntax uses a space where the command line uses a colon:
LocalForward 5432 localhost:5432 is -L 5432:localhost:5432.
Do not tunnel to jump. If your goal is a shell on a machine behind a bastion, port forwarding is the wrong tool — that is what ProxyJump is for, and it verifies the final host's key end to end instead of trusting the bastion.
Debugging a tunnel that does not work
Work outward in this order:
- Is the local port already taken?
lsof -nP -iTCP:5432 | grep LISTEN. WithExitOnForwardFailure yesset, SSH tells you instead of pretending. - Can the server actually reach the destination? Log in and try it there:
nc -vz db.internal 5432. If that fails, the tunnel was never the problem. - Are you connecting to the right side? For
-L, your client must targetlocalhost:<localport>— not the remote hostname. - For
-R, is it loopback-only? CheckGatewayPortson the server. - Ask SSH.
ssh -vprints each forward as it is set up, and says plainly when a request is refused.
Once the model is in your head — the flag names where the door opens — the flags stop being trivia and the failures start being self-explanatory.
Written by Bartłomiej Żabicki
iOS developer writing Swift since 2015, with a homelab he administers from an iPhone more often than he would like to admit. Moray is the SSH client he wanted to exist.
Frequently asked
- What is the difference between -L and -R?
- -L (local forwarding) opens a port on your machine and sends anything arriving there out through the server — you reach something the server can reach. -R (remote forwarding) opens a port on the server and sends traffic back to your machine — the server's network reaches something you can reach. The letter names where the listening port is opened.
- How do I reach a database that only listens on localhost?
- Local forwarding. ssh -L 5432:localhost:5432 user@server makes port 5432 on your machine resolve to port 5432 as seen from the server, so a client on your laptop connects to localhost:5432 and lands on the server's Postgres — without ever exposing it to the internet.
- Is a SOCKS proxy through SSH a VPN?
- Not quite. Dynamic forwarding (-D) gives you a SOCKS5 proxy that applications must be configured to use, and it only carries TCP. A VPN captures traffic at the network layer for the whole device. For browsing an internal dashboard, SOCKS is usually enough and far simpler.
try it yourself
Moray does all of this natively
A real terminal, dual-pane SFTP, tunnels and jump hosts on iPhone, iPad and Mac. Keys stay in the Keychain, hosts sync over your own iCloud, and there is no account to make.
Keep reading
- 8 minJump Hosts and ProxyJump: Reaching Servers Behind a BastionHow to reach a server that refuses to talk to you — and why ProxyJump replaced agent forwarding.
- 11 minSSH from an iPhone or iPad: The Complete GuideWhat iOS actually permits, how to get a usable keyboard, and why your session drops when you switch apps.
- 10 minSSH Keys Explained: Ed25519, RSA, and Where They Should LiveWhich algorithm to pick, how the two halves work, and the one place a private key should ever be stored.