SSH Port Forwarding: Local, Remote, and Dynamic Tunnels

SSH Port Forwarding: Local, Remote, and Dynamic Tunnels

What You'll Learn

  • The difference between -L (local), -R (remote), and -D (dynamic) forwarding
  • How to reach a database behind a bastion, expose a local app, and run a SOCKS proxy
  • How to troubleshoot when a tunnel "won't connect"

Quick Summary

  • Reach a remote service from your machine-L (local forward)
  • Expose a local service to a remote host-R (remote forward)
  • Send a whole app's traffic through SSH-D (dynamic / SOCKS)

Assumptions

  • OS: Ubuntu (OpenSSH client / server)
  • You can already log in over SSH
  • Port numbers are examples; adapt them to your setup

What is SSH Port Forwarding?

Conclusion: It tunnels arbitrary TCP connections inside the encrypted SSH session, letting you reach ports you can't hit directly as long as you can SSH in.

Services behind a firewall or inside a private network can't be reached directly from outside. But if you can SSH in, you can carry another TCP connection inside that SSH session. That is port forwarding (tunneling).

There are three kinds. They differ only in direction and which side listens; the idea is the same.

Type Option Listens on Use case
Local -L Your side Use a remote service from your machine
Remote -R Remote Expose a local service to the remote
Dynamic -D Your side Send a whole app through the tunnel

How Do You Use Local Forwarding (-L)?

Conclusion: The form is -L localport:desthost:destport. Connections to your local port are forwarded to the destination as seen from the SSH server. Reaching a DB through a bastion is the classic case.

Basic form

$ ssh -L 8080:localhost:80 user@server

Now a connection to your localhost:8080 reaches localhost:80 as seen from server. Open http://localhost:8080 in a browser and it behaves like hitting port 80 on the server.

The desthost is resolved from the SSH server's point of view. localhost means the server itself; another name means a host the server can reach.

Example: reach a DB through a bastion

The database (db.internal:5432) sits behind a bastion and isn't directly reachable from your machine.

$ ssh -L 15432:db.internal:5432 user@bastion

In another terminal, treat your local port 15432 as if it were the real DB.

$ psql -h localhost -p 15432 -U dbuser appdb

Tunnel only (-N / -f)

If you don't need a login shell and only want the tunnel, add -N; to push it to the background, add -f.

$ ssh -fN -L 15432:db.internal:5432 user@bastion
  • -N: do not run a remote command (forwarding only)
  • -f: go to the background after authentication

A backgrounded tunnel keeps running. Stop it when you're done.

$ ps aux | grep "ssh -fN"
$ kill <PID>

How Do You Use Remote Forwarding (-R)?

Conclusion: The form is -R remoteport:desthost:destport. The remote side listens and forwards to a destination as seen from your machine. Sharing a local dev server temporarily is the common use.

This is the reverse direction. A connection to the remote port reaches the destination as seen from your machine (the one running ssh).

Basic form

$ ssh -R 8080:localhost:3000 user@server

A connection to localhost:8080 on server is forwarded to your localhost:3000. Your local dev server (port 3000) becomes reachable from the server.

The gotcha for public access: GatewayPorts

By default, -R listens only on the remote loopback (127.0.0.1). The server itself can reach it, but nothing outside the server can. To listen on all interfaces, the server's /etc/ssh/sshd_config needs:

GatewayPorts yes

Reload after changing it: sudo systemctl reload ssh.

What is Dynamic Forwarding (-D)?

Conclusion: -D port opens a local SOCKS proxy. Instead of one tunnel per destination, an app's traffic exits through the SSH server.

-L is "one port, one destination," but -D doesn't fix a destination. It creates a SOCKS proxy locally; any app pointed at it sends traffic out through the SSH server.

$ ssh -D 1080 user@server

Now localhost:1080 is a SOCKS5 proxy. Point your browser or curl at it.

$ curl --socks5-hostname localhost:1080 https://example.com

--socks5-hostname resolves names on the proxy side. Use it to reach hosts that only the SSH server's DNS can resolve. Plain --socks5 resolves locally instead.

Why Won't My Tunnel Connect?

Conclusion: Most failures are a port clash, a misunderstanding of where the destination name resolves, or missing remote-exposure settings. The error text pinpoints which.

bind: Address already in use

The listening port on your side (or the remote, for -R) is already taken. Use another port or find the process holding it.

$ ss -tlnp | grep :8080

channel ... open failed: connect failed

The tunnel is up, but the SSH server can't reach the destination. Verify the destination host, port, and reachability from the server.

$ ssh user@server
$ nc -vz db.internal 5432

-R works but outside hosts can't connect

Usually the missing GatewayPorts above. Even -R 0.0.0.0:8080:... won't help if the server config is still no.

Common confusion: which side resolves the destination name?

  • In -L 15432:db.internal:5432, db.internal resolves on the SSH server
  • In -R 8080:localhost:3000, localhost:3000 resolves on your machine

When "it resolves locally but won't connect," you've usually mixed up the resolving side.

How Do You Save Tunnels in ~/.ssh/config?

Conclusion: Instead of long options every time, put LocalForward / RemoteForward / DynamicForward in your config and just run ssh host.

Host db-tunnel
    HostName bastion.example.com
    User user
    LocalForward 15432 db.internal:5432

Host socks
    HostName server.example.com
    User user
    DynamicForward 1080

From then on, just name the host.

$ ssh -fN db-tunnel

For more on the config file, see SSH config Tips.

Summary and Safe Templates

Conclusion: Pick -L or -R by direction, and -D when the destination isn't fixed. Don't leave tunnels open; stop them when you're done.

Copy-paste templates

# Local forward (remote DB to your machine)
ssh -fN -L 15432:db.internal:5432 user@bastion

# Remote forward (your dev server to a remote host)
ssh -R 8080:localhost:3000 user@server

# Dynamic forward (SOCKS proxy)
ssh -D 1080 user@server

Next Reading