Introduction

SSH port forwarding — also called SSH tunneling — lets one TCP connection ride inside another TCP connection, wrapped in an encrypted SSH session. That sounds abstract, but it solves very concrete problems I run into constantly on my own VPS: reaching a private database, getting to a service sitting behind NAT, showing off a local app without deploying it anywhere, or routing traffic through a jump host without punching extra holes in a firewall.

The core idea comes straight from OpenSSH itself: the ssh client can forward arbitrary TCP connections over its secure channel, and sshd on the server side can allow or restrict that forwarding. In practice, this turns SSH into something bigger than "remote shell." It becomes a general-purpose encrypted transport layer you already have installed everywhere.


Three Forwarding Types

1. Local Forwarding (-L)

Local forwarding opens a port on your machine and sends anything that hits it through the SSH server to a destination host and port.

ssh -L local_port:destination_host:destination_port user@ssh_server

Mental model: "Make something remote look local."

Example:

ssh -N -L 5432:db.internal:5432 user@bastion

Point your app at localhost:5432, and it talks to db.internal:5432 as if that database were sitting right next to it.

2. Remote Forwarding (-R)

Remote forwarding flips the direction — it opens a port on the SSH server and sends traffic back through the tunnel to a service running on your machine.

ssh -R remote_port:localhost:local_port user@ssh_server

Mental model: "Make something local look remote."

Example:

ssh -N -R 8080:localhost:3000 user@public-vm

Now anything hitting public-vm:8080 gets routed back to your local app on port 3000.

3. Dynamic Forwarding (-D)

Dynamic forwarding opens a SOCKS proxy on your machine. Instead of a fixed destination, SSH decides where each connection goes based on what your app requests.

ssh -D local_port user@ssh_server

Mental model: "Give me a general-purpose exit point through this server."

Example:

ssh -N -D 1080 user@bastion

Point your browser at SOCKS5 proxy localhost:1080, and every site it visits routes through the SSH server.


Background Mode

For tunnel-only sessions — where you don't need an interactive shell, just the pipe — combine -N and -f.

ssh -N -f -L 5432:db.internal:5432 user@bastion

Breaking that down:

  1. -N — run no remote command, this connection exists only to forward
  2. -f — background the client after authentication succeeds
  3. The tunnel keeps running quietly in the background from there

One gotcha worth flagging: without ExitOnForwardFailure, the client can happily background itself even if the port forward never actually came up. You'll think you have a tunnel and you don't. Add the flag to fail loudly instead:

ssh -N -f -o ExitOnForwardFailure=yes -L 5432:db.internal:5432 user@bastion

Binding Behavior That Trips People Up

The forwarding listener usually binds to loopback only, unless you tell it otherwise — and this is where I've seen (and made) the most mistakes.

Local forward bind

For -L and -D, using localhost as the bind address means the listener is local-only — nothing outside your machine can reach it. An empty bind address or * opens the listener on all interfaces instead.

Remote forward bind

For -R, the server-side listener defaults to loopback only. An explicit bind address is only honored if the server's GatewayPorts setting allows it.

Why this matters: loopback binding keeps the tunnel private to the machine that owns it. Wildcard binding exposes the forwarded port to the network — sometimes the whole internet, if that machine has a public IP. This single default is the difference between "convenient tunnel" and "surprise open port."


Server-Side Controls

Tunnel behavior isn't purely a client-side decision. The server's config can allow, restrict, or flatly refuse forwarding — which matters a lot if you're running a bastion host or any shared SSH server (as I do on my own AlmaLinux VPS).

Key knobs in sshd_config:

  1. AllowTcpForwarding — controls whether TCP forwarding works at all, or only in one direction (local or remote)
  2. GatewayPorts — controls whether forwarded listeners may bind beyond loopback
  3. PermitOpen — restricts which destinations are allowed for TCP forwarding
  4. PermitListen — restricts listen addresses and ports for remote forwarding

In practice:

  • AllowTcpForwarding no shuts off TCP forwarding entirely
  • PermitOpen acts as a destination allowlist
  • PermitListen acts as a listen-side allowlist
  • GatewayPorts decides whether a forwarded port can go public or stays loopback-only

If you're the one running the SSH server other people tunnel through, these settings matter more than any client-side flag — they're your actual security boundary.


Common Patterns

This is the section worth bookmarking, because these four scenarios cover most of what I actually reach for ssh -L, -R, or -D to do. I've added the "why you'd do this" and the thing to double-check for each one.

Private database access

Why: You've got a database that's deliberately not exposed to the internet — maybe it's on an internal network, maybe it only listens on a private interface — and you want to run queries or point a GUI client at it from your laptop without opening the database port to the world.

ssh -N -L 3306:db.internal:3306 user@bastion

Now your MySQL client connects to 127.0.0.1:3306 and it behaves exactly like a local database. The bastion host is doing all the work of reaching db.internal, and your database's actual port never has to touch a public-facing firewall rule.

Watch for: if you forget -N, you'll also get a shell session mixed in — harmless, but noisy if you're scripting this. And double-check the bind is loopback-only unless you specifically want other machines on your laptop's network reaching that tunnel too.

Internal web app access

Why: Same idea as the database case, but for a web dashboard, admin panel, or internal tool — something like a monitoring UI or an app still in staging that has no business being reachable from outside your infrastructure.

ssh -N -L 8080:web.internal:80 user@bastion

Open http://localhost:8080 in your browser, and you're looking at web.internal:80 through the tunnel. This is the pattern I use constantly for checking on internal services running on containers that only listen on private addresses — no need to stand up a public reverse proxy just to peek at a dashboard.

Watch for: if the internal app expects a specific Host header or redirects based on hostname, you might hit redirect loops through localhost:8080. A quick /etc/hosts entry pointing a friendly hostname at 127.0.0.1 usually clears that up.

Reverse tunnel for demo

Why: You've got something running locally — a dev server, a work-in-progress app — and you want someone else (a client, a teammate, or just yourself from another network) to reach it without deploying anything or messing with your home router's port forwarding.

ssh -N -R 9000:localhost:3000 user@public-vm

Anyone who can reach public-vm:9000 is now actually talking to the app running on your machine at port 3000. This is the reverse-tunnel trick that saves you from spinning up a throwaway deployment just to show someone a five-minute demo.

Watch for: by default this binds to loopback on the server, meaning only processes on public-vm itself can reach port 9000 — not the outside world. If you actually want it public, you need GatewayPorts enabled server-side (and you should think hard about whether that's really what you want, since it's now exposed to anyone who can reach that VM).

Browser proxy

Why: You want to route browser traffic — or any SOCKS-aware app — through a trusted server without hardcoding a single fixed destination. Useful for testing how a site looks from another network, or for keeping traffic off an untrusted local network (say, hotel Wi-Fi) while you're between more permanent VPN setups.

ssh -N -D 1080 user@bastion

Configure your browser (or curl --socks5) to use localhost:1080 as a SOCKS5 proxy, and every connection gets tunneled out through bastion instead of your local network.

Watch for: DNS resolution. Plenty of apps do SOCKS forwarding for TCP connections but still resolve hostnames locally, leaking which sites you're visiting even though the traffic itself is tunneled. Look for a "remote DNS" or "proxy DNS" setting — in Firefox it's the network.proxy.socks_remote_dns flag — to make sure lookups go through the tunnel too.


Security Notes

SSH tunneling is secure transport, not automatically safe transport. Those are different guarantees, and conflating them is how people end up with accidental exposure.

Watch these risks:

  1. Forwarding a public listener with -R or GatewayPorts can expose a service to the entire network
  2. Compression can leak information when trusted and untrusted traffic share the same SSH connection
  3. Agent forwarding is a separate feature from port forwarding and shouldn't be enabled casually
  4. -g lets remote hosts reach your local forwarded ports, widening exposure
  5. The tunnel only protects the link between SSH endpoints — the destination service still needs its own authentication

Best practice checklist:

  1. Prefer loopback binds by default
  2. Set ExitOnForwardFailure=yes so failures don't hide silently
  3. Use -N for forwarding-only sessions
  4. Restrict the server with AllowTcpForwarding, PermitOpen, and PermitListen
  5. Avoid public exposure unless you genuinely mean to publish the service

Troubleshooting

If a tunnel fails, check these in order:

1. Port already in use, locally or on the remote side

ss -tlnp | grep 5432

If something's already bound to that port, either kill it or pick a different local port for your -L/-R flag.

2. Server disallows forwarding in sshd_config

ssh user@bastion "sudo sshd -T | grep -i forwarding"

sshd -T dumps the effective config after all Match blocks are applied, so you're seeing what actually applies to your connection — not just what's in the file.

3. Destination host or port unreachable from the SSH server's side

ssh user@bastion "nc -zv db.internal 5432"

This runs the check from the bastion, which is what matters — your laptop being able to reach db.internal tells you nothing if the bastion itself can't.

4. Bind address blocked by GatewayPorts

ssh user@bastion "sudo sshd -T | grep -i gatewayports"

If this comes back no (the default) and you're trying to bind beyond loopback with -R, that's your problem right there.

5. ExitOnForwardFailure not set, so the client backgrounded silently despite the failure

ssh -N -f -o ExitOnForwardFailure=yes -L 5432:db.internal:5432 user@bastion; echo "exit code: $?"

Run it in the foreground first (drop -f) if you want to watch the failure happen live instead of just reading the exit code after the fact.

Debug command worth memorizing:

ssh -v -N -L 5432:db.internal:5432 user@bastion

Stack more -v flags for deeper debug output.


When To Use Each Type

  • Use -L when a local app needs to reach a remote private service
  • Use -R when a remote host needs to reach a local service
  • Use -D when the destination changes often, or the app already supports a SOCKS proxy

Closing

SSH port forwarding is one of those tools that stays useful for decades because it's simple, reliable, and built into OpenSSH everywhere — no extra software, no extra agent, nothing to install on a fresh box. Once -L, -R, and -D click, you can reach private services, cross NAT boundaries, and publish local tools with very little setup.

Used well, it's a secure bridge. Used carelessly, it's accidental public exposure. The difference almost always comes down to two things: bind addresses and server-side policy.