Skip to main content
SSH tunnels from scratch: local and remote port forwarding explained

SSH tunnels from scratch: local and remote port forwarding explained

· 10 min read
Practical guides for developers

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.

SSH Port Forwarding — Quick ReferenceTASKCOMMANDLocal port forward (foreground)ssh -L 8080:localhost:80 user@serverLocal port forward (background)ssh -f -N -L 8080:localhost:80 user@serverRemote port forward (background)ssh -f -N -R 8080:localhost:3000 user@serverDynamic SOCKS proxy (background)ssh -f -N -D 1080 user@serverShare local tunnel with LANssh -L 0.0.0.0:8080:localhost:80 user@serverMultiple local forwardsssh -L 5432:db:5432 -L 6379:redis:6379 user@server

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

note

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
note

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.

AllowTcpForwarding ValuesVALUEEFFECTyes (default)Local and remote forwarding both allowedallSame as yesnoAll TCP forwarding blockedlocalOnly -L allowed; -R is blockedremoteOnly -R allowed; -L is blocked

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.

GatewayPorts InteractionCLIENT COMMANDGATEWAYPORTSRESULTssh -R 8080:localhost:80 user@serverno (default)Port 8080 bound to 127.0.0.1 onlyssh -R 0.0.0.0:8080:localhost:80 user@servernobind_address silently ignored; falls back to 127.0.0.1ssh -R 0.0.0.0:8080:localhost:80 user@serveryesPort 8080 bound to all interfaces — publicly accessiblessh -R 0.0.0.0:8080:localhost:80 user@serverclientspecifiedClient's bind_address honoured; binds to 0.0.0.0
note

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.

Comparing -L, -R, and -DFLAGWHO LISTENSWHERE CONNECTIONS GOTYPICAL USE CASE-L local_port:host:hostportLocal (client) machineThrough tunnel to server, then server connects to host:hostportReach a remote database or private service from your laptop-R remote_port:host:hostportRemote (server) machineThrough tunnel back to client, then client connects to host:hostportExpose a local dev server or homelab port through a public VPS-D local_portLocal (client) machine (SOCKS proxy)Through tunnel to server, server connects to whatever SOCKS client requestsRoute multiple destinations through one bastion

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.

About the author

ST
Simple Tech GuidesPractical guides for developers

Simple Tech Guides publishes practical, developer-focused content on frameworks, tools, and platforms.