SSH port forwarding explained: -L, -R and -D
Local, remote and dynamic SSH tunnels with examples you can paste: reach a database behind a bastion, expose a local port, or run a SOCKS proxy.
On this page
The three kinds at a glanceLocal forwarding: -LRemote forwarding: -RDynamic forwarding: -DFlags worth knowingTunnels in ~/.ssh/configKeep tunnels safeWhen a tunnel does not workTunnels as cards in Gatesys SSHFAQSSH can carry any TCP connection inside its encrypted session. That one feature, port forwarding, replaces a good many VPNs and opened firewall ports: it reaches a database that only listens on a private network, shows a web console that is bound to localhost, or sends a browser’s traffic out through a server. There are three kinds. This guide shows each with a command you can paste, then the flags and the safety rules that matter.
The three kinds at a glance
| Flag | Name | Listens on | Connects out from | Typical use |
|---|---|---|---|---|
-L | Local | Your computer | The server | Reach a service on the server’s network |
-R | Remote | The server | Your computer | Let the server reach a service on your side |
-D | Dynamic | Your computer | The server, wherever each connection asks | A SOCKS5 proxy through the server |
Local forwarding: -L
ssh -L 15432:localhost:5432 deploy@db-primaryRead it as local port : destination host : destination port. While this session is open, anything that connects to port 15432 on your computer comes out on db-primary and is connected to localhost:5432 there. Point your client at it:
psql -h localhost -p 15432 -U app shopTwo things people get wrong:
- The destination is resolved on the server.
localhostin the command means the server, not your laptop. To reach another machine on the server’s network, name it:-L 16379:10.0.3.40:6379. - Pick a local port that is not the service’s own. If you forward to local 5432, a tool that meant to reach your development database reaches production instead. Adding 10000, so 15432 for PostgreSQL and 16379 for Redis, keeps them apart.
Remote forwarding: -R
ssh -R 8080:localhost:3000 deploy@stagingThis is the mirror image. The server listens on its port 8080, and connections to it come out on your computer at localhost:3000. Use it to let a staging server call a webhook receiver that is running on your laptop.
By default the server listens on its loopback address only, so the port is reachable from the server itself and not from the internet. Making it public needs GatewayPorts yes in the server’s sshd_config, and a moment’s thought.
Dynamic forwarding: -D
ssh -D 1080 deploy@bastionNow port 1080 on your computer is a SOCKS5 proxy. Each connection made through it is opened from the bastion, to wherever that connection asks. One tunnel reaches every internal web UI:
curl --socks5-hostname localhost:1080 http://grafana.internal:3000/--socks5-hostname matters: it makes the bastion resolve the name, so internal DNS names work. In a browser, set a SOCKS5 proxy of localhost:1080 and turn on remote DNS.
Flags worth knowing
A tunnel on its own, with no shell, in the background:
ssh -fN -o ExitOnForwardFailure=yes -L 15432:localhost:5432 deploy@db-primary| Flag | What it does |
|---|---|
-N | Do not run a command. The session exists only for the forward |
-f | Go to the background after logging in |
-o ExitOnForwardFailure=yes | Fail at once if the local port is taken, instead of connecting with no tunnel |
-o ServerAliveInterval=30 | Send a keepalive every 30 seconds, so idle tunnels are not dropped by a firewall |
To stop a background tunnel cleanly, give it a control socket when you start it:
ssh -fNM -S ~/.ssh/pg.sock -L 15432:localhost:5432 deploy@db-primary
ssh -S ~/.ssh/pg.sock -O exit deploy@db-primaryTunnels in ~/.ssh/config
A forward you use every day belongs in the config file:
Host db-tunnel
HostName 10.0.3.4
User deploy
ProxyJump bastion
LocalForward 15432 localhost:5432
ExitOnForwardFailure yes
ServerAliveInterval 30
Host proxy
HostName bastion.example.com
DynamicForward 1080ssh -N db-tunnel then opens the tunnel through the bastion. The forward ends on the database host itself, and the bastion only relays encrypted bytes. See the ProxyJump guide.
Keep tunnels safe
- Stay on loopback.
-L 15432:...binds 127.0.0.1. Writing-L 0.0.0.0:15432:...offers your production database to everyone on the café’s network. - Treat a tunnel to production as production. A forwarded port has no idea that it should be read-only. Open a read-only session where the service has one, for example
PGOPTIONS="-c default_transaction_read_only=on" psql .... - Close what you opened. A forgotten
-ftunnel keeps working for as long as the laptop is awake. - Limit forwarding on the server. In
sshd_config,AllowTcpForwardingtakesyes,no,localorremote.PermitOpenlimits where local forwards may go, andPermitListenlimits where remote forwards may listen.
When a tunnel does not work
| Symptom | Cause |
|---|---|
bind [127.0.0.1]:15432: Address already in use | Another program, often an older tunnel, holds the local port |
The tunnel starts, then channel 2: open failed: connect failed: Connection refused when you use it | Nothing listens on the destination. SSH checks the destination only when a connection arrives, not when the tunnel starts |
open failed: administratively prohibited | The server’s AllowTcpForwarding or PermitOpen forbids it |
Warning: remote port forwarding failed for listen port 8080 | The port is taken on the server, or it is under 1024 and you are not root |
| The tunnel dies after a few idle minutes | A firewall dropped the idle connection. Set ServerAliveInterval |
The second row is worth remembering. ssh -L succeeds even when the far end is closed. You find out on the first connection.
Tunnels as cards in Gatesys SSH
In Gatesys SSH, port forwarding is a view of its own, on the free plan. Each tunnel is a card with a switch, and it rides the host’s existing session, jump chain included.
- Describe it. Type reach postgres on db-primary, or paste
ssh -L 15432:localhost:5432 db-primary, and a draft card appears with every value labelled by where it came from. - It is tested when it starts. The app makes one test connection through the tunnel, and the card says Running only if that gets through. Otherwise it says why, for example db-primary refused 127.0.0.1:5432 — nothing listening?
- Safe by default. Tunnels listen on 127.0.0.1 only. A data service gets its port plus 10000, so a production database never answers on your local database’s port. On a production host, the suggested client command opens a read-only session first.
- On the record. Every start and stop is written to the audit log with its host.
FAQ
What is the difference between ssh -L and ssh -R?
-L listens on your computer and connects out from the server. -R listens on the server and connects out from your computer. Use -L to reach something on the server’s side, and -R to let the server reach something on yours.
Is SSH port forwarding secure?
The traffic between your computer and the SSH server is encrypted and authenticated. The last leg, from the server to the destination, is whatever that service speaks. The main risk is exposure: a tunnel bound to all interfaces is open to your whole network.
Can SSH forward UDP?
No. -L, -R and -D carry TCP connections, and Unix domain sockets. For UDP you need a VPN, or a tool that wraps UDP in TCP at both ends.
How do I keep an SSH tunnel alive?
Set ServerAliveInterval 30 so the idle connection is not dropped. For a tunnel that must come back by itself after a network change, run it under autossh or a systemd service with Restart=always.
Why does my tunnel connect but the port refuses?
The destination is not listening where you pointed. Remember that the destination is resolved on the server: localhost:5432 works only if the database listens on the server’s loopback address.
