SSH tunnels explained: local, remote and dynamic port forwarding

SSH port forwarding comes in three kinds. ssh -L opens a port on your machine that reaches something on the server's side. ssh -R opens a port on the server that reaches something on your side. ssh -D opens a local SOCKS proxy that sends any connection through the server. Add -N to run no remote command, and -o ExitOnForwardFailure=yes so a tunnel that cannot open fails with an error.

Updated . All forwards, bind addresses, GatewayPorts values, AllowTcpForwarding values and the failure messages were run with OpenSSH 10.3 on macOS as client against OpenSSH 10.0 on Debian 13, using small HTTP servers as targets. Several -L options on one connection, -L through -J and the config error for a colon in LocalForward were run again with OpenSSH 10.0 on Debian 13.

Which one do you need?

FlagPort opens onUse it to
-L localYour machineReach a service that only the server can reach, such as a database.
-R remoteThe serverLet the server reach something on your machine, such as a dev site.
-D dynamicYour machineBrowse or connect to many hosts as if you were on the server.

An easy way to remember: -L listens on your local machine, -R listens on the remote server, and -D is dynamic because the app picks the destination each time.

Local forward: -L

ssh -N -L 8080:localhost:80 user@server
your app -> your machine, port 8080 -> SSH -> server -> localhost:80 as seen by the server

The format is local_port:destination:destination_port. Now http://127.0.0.1:8080 on your machine shows the web server that listens only on the server's own loopback address.

The key point is that the server resolves the destination. localhost means the server itself, not your machine. The destination can also be another host the server can reach, and that last hop is not encrypted by SSH:

ssh -N -L 8080:intranet.example.com:80 user@server

Remote forward: -R

ssh -N -R 9000:localhost:3000 user@server
a program on the server -> server, port 9000 -> SSH -> your machine -> localhost:3000 as seen by your machine

Now a program on the server can open http://127.0.0.1:9000 and reach the app running on your machine. It works even when your machine is behind NAT, because your side started the connection.

Dynamic forward: -D

ssh -N -D 1080 user@server
your app -> SOCKS proxy on your machine, port 1080 -> SSH -> server -> any host:port the app asks for

Point an application that supports SOCKS at 127.0.0.1:1080. To test with curl:

curl --socks5-hostname 127.0.0.1:1080 http://intranet.example.com/

--socks5-hostname makes the server do the DNS lookup, so internal names work and the lookup does not leak from your own network. Plain --socks5 resolves the name on your machine.

Browser settings and DNS leaks are covered in how to use SSH as a SOCKS proxy.

Several ports, or through a jump host

Repeat the option to open several forwards over one connection:

ssh -N -L 127.0.0.1:8080:localhost:80 -L 127.0.0.1:5433:localhost:5432 user@server

When the server sits behind a bastion, add -J. The forward starts at the last server of the chain, so that server resolves the destination:

ssh -N -J user@bastion -L 127.0.0.1:5433:localhost:5432 user@db-server

Chains of servers are covered in how to use ProxyJump and a jump host.

Running a tunnel without a shell

  • -N runs no remote command. The session only carries the tunnel.
  • -f goes to the background after login, so you can still type a passphrase first.
  • -o ExitOnForwardFailure=yes exits with an error if the forward cannot be set up.
ssh -f -N -o ExitOnForwardFailure=yes -L 127.0.0.1:8080:localhost:80 user@server

Without ExitOnForwardFailure, ssh prints a warning and stays connected with no working tunnel, which is confusing inside a script. With it, a local port already in use ends with exit code 255 and Could not request local forwarding, and a used remote port ends with remote port forwarding failed for listen port.

One catch, seen in our tests: with a bare -L 8080:..., ssh listens on both 127.0.0.1 and ::1. If only one of those is taken, for example by another program on IPv4, ssh prints bind [127.0.0.1]:8080: Address already in use and keeps running even with ExitOnForwardFailure=yes. Your app may then reach that other program. Writing the address, as in -L 127.0.0.1:8080:..., makes this a real failure, so use that form in scripts.

To stop a background tunnel, find its process and kill it:

pgrep -f 'L 127.0.0.1:8080'
kill PID

For tunnels you start often, a control socket lets you stop them by name with ssh -O exit. See how to save, start and stop SSH tunnels.

To keep a tunnel in your config, use LocalForward, RemoteForward and DynamicForward:

Host dbtunnel
  HostName 203.0.113.10
  User user
  LocalForward 127.0.0.1:8080 localhost:80
  ExitOnForwardFailure yes

Bind to localhost only

A forwarded port is open to whoever can reach it, and the service behind it may trust connections from the server. By default, ssh binds to loopback only, so keep it that way unless you have a reason.

These commands open the local port to your whole network. We confirmed both listen on all interfaces:

ssh -N -L 0.0.0.0:8080:localhost:80 user@server
ssh -N -g -L 8080:localhost:80 user@server

For -R, the server decides, with GatewayPorts in its sshd configuration:

GatewayPorts-R 9000:...-R 0.0.0.0:9000:...
no (default)Loopback onlyLoopback only, with no error
clientspecifiedLoopback onlyAll interfaces
yesAll interfacesAll interfaces, and 127.0.0.1:9000 also opens on all interfaces

So if your -R port cannot be reached from outside, GatewayPorts is the usual reason. Exposing your laptop's service to the internet this way is rarely what you want. Prefer clientspecified if you need it, and add a firewall rule.

When the server refuses

AllowTcpForwarding in the server's sshd configuration controls this. The values are yes (the default), no, local (allows -L and -D) and remote (allows -R).

  • A blocked -L or -D connects fine. The error comes only when something uses the port: channel 2: open failed: administratively prohibited: open failed.
  • A blocked -R says remote port forwarding failed for listen port.

The manual page notes that disabling forwarding adds little if users also have a shell, since they can run their own forwarder. To truly limit someone, take away the shell too, as in a tunnel-only user for a database.

Two more errors come up often. When the local or remote port is taken, see Address already in use and remote port forwarding failed. When connections through the tunnel are refused, see administratively prohibited: open failed.

Questions

What is the difference between ssh -L and ssh -R?

-L opens a port on your machine that leads to something the server can reach. -R opens a port on the server that leads back to something on your machine.

How do I forward several ports with one ssh command?

Repeat the option: ssh -N -L 127.0.0.1:8080:localhost:80 -L 127.0.0.1:5433:localhost:5432 user@server. One connection carries all of them. In ~/.ssh/config, add one LocalForward line per port.

How do I keep an SSH tunnel running?

Start it with -N and -o ExitOnForwardFailure=yes, and let autossh or a systemd service restart it after a drop. See autossh and systemd.

Why does my tunnel connect but nothing works?

The server may forbid forwarding with AllowTcpForwarding. A blocked -L or -D still connects, and fails only when the port is used, with administratively prohibited: open failed.

Is SSH port forwarding secure?

The traffic is encrypted between your machine and the SSH server. The hop from the server to the destination is not, and a port that listens on 0.0.0.0 can be used by anyone on your network, so keep the listen address at 127.0.0.1.

Sources

More in Tunnels and jump hosts

All guides