SSH tunnels are one of those tools that seem magical until you understand the three basic patterns. Once you do, you’ll use them constantly.

The Three Types

  1. Local forwarding (-L): Access a remote service as if it were local
  2. Remote forwarding (-R): Expose a local service to a remote network
  3. Dynamic forwarding (-D): Create a SOCKS proxy through the SSH connection

Let’s break down each one.

Local Forwarding: Reach Remote Services Locally

Scenario: You need to access a database that’s only available from a server you can SSH into.

1
ssh -L 5432:database.internal:5432 bastion.example.com

This creates a tunnel:

  • Your localhost:5432 → through bastion.example.com → to database.internal:5432

Now connect your database client to localhost:5432:

1
psql -h localhost -p 5432 -U myuser mydb

The database thinks the connection is coming from the bastion, not your laptop.

Syntax Breakdown

1
ssh -L [local_addr:]local_port:remote_host:remote_port user@ssh_server
  • local_addr - Optional. Defaults to 127.0.0.1. Use 0.0.0.0 to allow other machines to connect through your tunnel.
  • local_port - Port on your machine
  • remote_host - Target host (from the SSH server’s perspective)
  • remote_port - Target port

Multiple Tunnels

Chain multiple -L flags:

1
2
3
4
ssh -L 5432:db.internal:5432 \
    -L 6379:redis.internal:6379 \
    -L 9200:elastic.internal:9200 \
    bastion.example.com

Background Tunnel

Don’t need an interactive shell? Use -f and -N:

1
ssh -f -N -L 5432:db.internal:5432 bastion.example.com
  • -f - Background after authentication
  • -N - Don’t execute remote commands

Find and kill it later:

1
2
ps aux | grep "ssh -f"
kill <pid>

Remote Forwarding: Expose Local Services

Scenario: You’re developing locally and need to expose your service to a remote server (or the internet).

1
ssh -R 8080:localhost:3000 remote-server.example.com

This creates a tunnel:

  • remote-server.example.com:8080 → through SSH → to your localhost:3000

Anyone who can reach the remote server on port 8080 now hits your local dev server.

Syntax Breakdown

1
ssh -R [remote_addr:]remote_port:local_host:local_port user@ssh_server

Common Use Cases

Webhook development: Expose local server for webhook callbacks:

1
2
ssh -R 80:localhost:8000 myserver.com
# Webhooks to http://myserver.com now hit localhost:8000

Demo to clients: Share work-in-progress without deploying:

1
ssh -R 3000:localhost:3000 demo-server.example.com

Enabling Remote Binding

By default, remote forwarded ports only bind to 127.0.0.1 on the remote server. To allow external connections, edit /etc/ssh/sshd_config on the remote:

GatewayPortsyes

Or use GatewayPorts clientspecified and specify the bind address:

1
ssh -R 0.0.0.0:8080:localhost:3000 remote-server.example.com

Dynamic Forwarding: SOCKS Proxy

Scenario: You want to route arbitrary traffic through an SSH server.

1
ssh -D 1080 bastion.example.com

This creates a SOCKS5 proxy on localhost:1080. Configure your browser or application to use it:

1
2
3
4
# curl through the proxy
curl --socks5 localhost:1080 http://internal-service.example.com

# Firefox: Settings → Network → Manual proxy → SOCKS Host: localhost, Port: 1080

All traffic through the proxy appears to originate from the SSH server.

Use Cases

  • Access internal websites from outside the network
  • Bypass geographic restrictions by routing through a server in another region
  • Secure browsing on untrusted networks (coffee shop WiFi)

Proxy Chains

Route through multiple hops:

1
2
3
4
5
6
# First tunnel
ssh -D 1080 -J jumpbox.example.com internal-bastion.example.com

# Or manually chain
ssh -L 2222:internal:22 external-bastion.example.com
ssh -D 1080 -p 2222 localhost

SSH Config for Convenience

Codify your tunnels in ~/.ssh/config:

HHHooossstttHULLHURHUDdosoodoseposybseccesemrsen-traavtrootratNll-NtxNmuaaFFeadeyaainmdooxmeFmdcnemrrpepoemFeiwwolriolbnaasrowbnrarreeyaawsddmrsatodtri56tido43e8on37-0n1.29s8.0ee0e8xdrrx0aaevlamtdeompaircplbs.alea.ele.sixh.cenaoco.tmsomieptmnrl:tne3ea.0rlc0n:o0a6ml3:759432

Now just:

1
2
3
ssh db-tunnel    # Opens both database tunnels
ssh dev-expose   # Exposes local port 3000
ssh proxy        # Starts SOCKS proxy

Persistent Tunnels with autossh

SSH tunnels die when connections drop. autossh restarts them automatically:

1
2
3
4
5
# Install
apt install autossh  # or brew install autossh

# Run with monitoring
autossh -M 0 -f -N -L 5432:db.internal:5432 bastion.example.com

The -M 0 disables autossh’s monitoring port (modern SSH’s ServerAliveInterval is better):

#HoIsntSS~eerr.vvseesrrhAA/llciiovvneefICinogtuenrtvMaalx330

Systemd Service for Boot Persistence

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# /etc/systemd/system/ssh-tunnel.service
[Unit]
Description=SSH Tunnel to Database
After=network.target

[Service]
User=deploy
ExecStart=/usr/bin/ssh -N -L 5432:db.internal:5432 bastion.example.com
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
1
2
sudo systemctl enable ssh-tunnel
sudo systemctl start ssh-tunnel

Security Considerations

  1. Limit tunnel permissions - Use PermitOpen in authorized_keys to restrict what can be forwarded:

    permitopen="database.internal:5432"ssh-rsaAAAA...
  2. Disable forwarding globally if not needed:

    #AlslsohwdT_ccpoFnofriwgardingno
  3. Use jump hosts (-J) instead of agent forwarding for multi-hop:

    1
    
    ssh -J jumpbox.example.com final-destination.example.com
    
  4. Audit tunnel usage - Log forwarded connections:

    #LosgsLhedv_eclonVfEiRgBOSE

Quick Reference

TypeFlagDirectionExample
Local-LRemote → Local-L 5432:db:5432
Remote-RLocal → Remote-R 8080:localhost:3000
Dynamic-DSOCKS proxy-D 1080

Mnemonic:

  • Local = I want to reach a remote service Locally
  • Remote = I want the Remote to reach me
  • Dynamic = Don’t know the destination yet (proxy)

SSH tunnels solve real problems without installing additional software. They’re encrypted, authenticated, and work through most firewalls. Once you internalize the three patterns, you’ll wonder how you ever worked without them.