
SSH tunnels from scratch: local and remote port forwarding explained
SSH port forwarding lets you forward TCP traffic through an encrypted SSH connection, so services that are only reachable from one machine become reachable from another without a VPN. Before picking a flag, ask two questions: "Where do I want to connect from?" and "Where does the listener need to open?" The listener always opens on the flag's named side: -L opens a listening port locally, -R opens one remotely. This guide covers local (-L), remote (-R), and dynamic (-D) forwarding, plus the sshd_config settings that must be right before any of your commands will work.
Quick reference
Full syntax and common idioms in one place.
Local port forwarding (ssh -L)
-L creates a listening port on the local (client) machine. Connections to that port travel through the SSH tunnel and are forwarded to a host and port resolved from the remote machine's perspective.
Syntax
ssh -L [bind_address:]local_port:host:hostport user@server
bind_address is optional and defaults to loopback. local_port is the port you will connect to on your own machine. host:hostport is the destination, resolved by the remote server, not by your laptop.
Try it: reach a remote database
Scenario: PostgreSQL runs on port 5432 on server, bound to localhost and not reachable over the public interface. Set up a local tunnel so your laptop's database client can connect.
ssh -f -N -L 5433:localhost:5432 user@server
Port 5433 locally avoids a collision with any local Postgres instance you have running. Once the tunnel is open, point psql at your local port:
psql -h 127.0.0.1 -p 5433 -U myuser mydb
The localhost gotcha
In ssh -L 8080:localhost:80 user@server, the word localhost in the middle slot resolves from the server's perspective. It means port 80 on the remote machine, not on your laptop. The ssh man page states the connection is made "from the remote machine", which is the authoritative source for this behaviour and the reason it trips up almost everyone the first time.
Reach a service on a third machine (bastion pattern)
host and hostport do not have to be on the SSH server. They can be any machine the SSH server can reach. According to iximiuz: "It might not be obvious at first, but the ssh -L command allows forwarding a local port to a remote port on any machine, not only on the SSH server itself."
ssh -f -N -L 8080:10.0.0.5:80 user@bastion
Traffic flows: your machine, through the SSH tunnel to the bastion, then from the bastion to 10.0.0.5:80. The service at 10.0.0.5 only needs to be reachable from the bastion.
Share a local tunnel with others on your LAN
By default, -L binds the listening port to 127.0.0.1, so only your machine can connect. To let other machines on your LAN reach the forwarded port, add 0.0.0.0 as the bind address:
ssh -L 0.0.0.0:8080:localhost:80 user@server
This does not require any change to the server's sshd_config. The GatewayPorts setting controls -R binds on the server side. For -L, specifying 0.0.0.0 directly on the command line is enough.
Remote port forwarding (ssh -R)
-R is the inverse of -L. It creates a listening port on the remote (server) machine, and connections to that port tunnel back to a host and port resolved from the client machine's perspective. The typical use case is when you are behind NAT or a firewall and want to expose a local service temporarily through a public-facing server you can SSH into.
Syntax
ssh -R [bind_address:]remote_port:host:hostport user@server
bind_address here defaults to loopback on the server, meaning only the server itself can reach the forwarded port by default. To expose it publicly, GatewayPorts must be set in sshd_config, as covered in the prerequisites section below.
Try it: expose a local dev server
Scenario: a dev server runs on your laptop at localhost:3000. You want a colleague to preview it via your VPS at vps.example.com.
ssh -f -N -R 8080:localhost:3000 user@vps.example.com
On the VPS, port 8080 now forwards to your laptop's port 3000. By default, only localhost on the VPS can reach it, not the public interface. If GatewayPorts yes is set on the VPS, add an explicit bind address to expose it publicly:
ssh -f -N -R 0.0.0.0:8080:localhost:3000 user@vps.example.com
Expose a service from a private network
host:hostport in -R resolves from the client's perspective. You can expose a machine on your home network through a public VPS, using your laptop as the jump point:
ssh -f -N -R 0.0.0.0:8081:192.168.1.50:80 user@vps.example.com
Traffic flows: external user to VPS port 8081, through the SSH tunnel to your laptop, then from your laptop to 192.168.1.50:80.
Dynamic port forwarding (ssh -D)
-D creates a SOCKS4/5 proxy on your local machine. Unlike -L, which wires a local port to one fixed remote destination, -D lets the SOCKS client choose the destination per connection. The SSH server resolves and connects to whatever address the SOCKS client requests.
Use cases include:
- Calling APIs in a private network through a bastion, without a separate tunnel per service
- Browsing internal web apps in a remote network via a single jump host
- Reaching a fleet of VPC endpoints from your laptop through one EC2 instance
Syntax
ssh -D [bind_address:]local_port user@server
Try it: proxy curl through a remote server
ssh -f -N -D 1080 user@server
curl --socks5-hostname localhost:1080 http://internal.example.com
The --socks5-hostname flag (not --socks5) routes DNS through the proxy too, which avoids DNS leaks when accessing internal hostnames. The same proxy port reaches any host the server can see, with no separate tunnel per destination.
Reverse dynamic forwarding (ssh -R with no destination)
OpenSSH 7.6 and later supports -R [bind_address:]port with no destination, which creates a SOCKS proxy on the remote server rather than locally. Every connection made through that proxy tunnels back to the SSH client and is resolved from the client's network. As iximiuz describes it: "it's the exact mirror of -D: this time the proxy lives on the gateway."
ssh -f -N -R 0.0.0.0:1080 user@server
This requires GatewayPorts yes on the server to bind to a non-loopback address.
sshd_config prerequisites
Before running -R or expecting non-loopback binds to work, the SSH server's configuration must permit it. Check these two settings.
AllowTcpForwarding
Default: yes. If this is set to no or local on the server, remote port forwarding will fail silently or with a permission error.
Check the current value:
sudo grep -i AllowTcpForwarding /etc/ssh/sshd_config
GatewayPorts
Default: no. Controls whether the listening port created by -R binds to loopback only or to other interfaces.
According to the OpenBSD sshd_config man page, no forces remote port forwardings to be available to the local host only, yes forces them to bind to the wildcard address, and clientspecified allows the client to select the address.
Specifying 0.0.0.0 in -R when GatewayPorts no does not produce an error. The server silently falls back to loopback. This is the most common cause of "my tunnel isn't reachable from outside" failures.
After editing sshd_config:
sudo systemctl reload sshd
Persistent tunnels
One-off tunnels die when the terminal closes or the connection drops. For tunnels that must survive across reboots and network interruptions, use autossh or ~/.ssh/config directives.
Run tunnels in the background with -f -N
Two flags work together here. -N tells ssh to not execute a remote command, making it useful for just forwarding ports. -f requests ssh to go to background just before command execution. Always pair them for headless tunnels:
ssh -f -N -L 5432:localhost:5432 user@server
To stop a backgrounded tunnel:
kill $(pgrep -f "ssh -f -N -L 5432")
Keep tunnels alive with autossh
autossh wraps ssh, monitors the tunnel, and restarts it if the connection dies. It requires key-based authentication since reconnection happens unattended. As the autossh README notes: "As connections must be established unattended, the use of autossh requires that some form of automatic authentication be set up."
The modern recommended pattern uses OpenSSH keepalives instead of autossh's own monitoring port:
autossh -M 0 \
-o "ServerAliveInterval 30" \
-o "ServerAliveCountMax 3" \
-f -N -L 5432:localhost:5432 user@server
-M 0 disables autossh's built-in loop-monitoring port. -o ServerAliveInterval 30 sends keepalive packets every 30 seconds via OpenSSH. If the server stops responding after 3 missed keepalives (90 seconds), autossh kills and restarts the ssh process.
Define tunnels in ~/.ssh/config
Store tunnel definitions so you can start them with a short alias instead of a long command line:
Host db-tunnel
HostName server.example.com
User alice
LocalForward 5433 localhost:5432
LocalForward 6380 localhost:6379
Host expose-dev
HostName vps.example.com
User alice
RemoteForward 8080 localhost:3000
Start with:
ssh -f -N db-tunnel
Multiple LocalForward or RemoteForward lines are allowed in the same Host block. The OpenBSD ssh_config man page confirms: "Multiple forwardings may be specified, and additional forwardings can be given on the command line."
Comparing -L, -R, and -D
The mnemonic from iximiuz: "it's always the left-hand side that opens a new port." -L puts local on the left, so the local machine listens. -R puts remote on the left, so the remote machine listens.
Troubleshooting
The forwarded port is not reachable from outside the server
Check GatewayPorts in /etc/ssh/sshd_config. If it is no (the default), -R tunnels bind to 127.0.0.1 only. Set it to yes or clientspecified and reload sshd.
Permission denied or tunnel fails silently
Check AllowTcpForwarding in sshd_config. If set to no or local, remote forwarding is blocked. The default is yes, so only a hardened server config will have disabled it.
Port already in use
Another process is listening on the port you chose. Check with:
ss -tlnp | grep <port>
Pick a different local port.
Tunnel dies after a few minutes of inactivity
The SSH connection is timing out. Add keepalive options:
ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -f -N -L 8080:localhost:80 user@server
Or add ServerAliveInterval 60 and ServerAliveCountMax 3 to the relevant Host block in ~/.ssh/config. The default ServerAliveCountMax is 3, so with a 60-second interval the connection will drop after approximately 3 minutes of no response from the server.