Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SSH Tunneling

SSH tunneling is the use of the SSH protocol’s port forwarding features to securely route arbitrary TCP traffic through an SSH connection. Originally part of SSH 1.x (1995) and extended in SSH 2.0 (2006), tunneling is a powerful tool for accessing services on remote networks, bypassing firewalls, and encrypting otherwise-unencrypted protocols. This page covers the three tunneling modes, the security considerations, and the production patterns.

The Three Modes

Local Port Forwarding (-L)

Forwards a local port to a remote host:port via the SSH server:

ssh -L 8080:remote-server:80 user@ssh-server
Localhost:8080 → SSH tunnel → SSH server → remote-server:80

The local port 8080 listens; connections are forwarded through the SSH server to remote-server:80 (which the SSH server can reach, even if the local host can’t).

Example use: access an internal-only web service:

ssh -L 8080:internal-web:80 user@bastion.example.com
# Now http://localhost:8080 reaches internal-web:80 via the bastion

Remote Port Forwarding (-R)

Forwards a port on the SSH server back to a local host:port:

ssh -R 9090:localhost:80 user@ssh-server
SSH server:9090 → SSH tunnel → Localhost:80

The SSH server’s port 9090 listens; connections are forwarded back through the SSH tunnel to the local host’s port 80.

Example use: expose a local development server to the internet via a remote SSH server:

ssh -R 8080:localhost:3000 user@my-remote-server
# Now my-remote-server:8080 → localhost:3000

Dynamic Port Forwarding (-D)

Creates a SOCKS proxy on the local host:

ssh -D 1080 user@ssh-server
Localhost:1080 → SOCKS5 proxy → SSH tunnel → SSH server → any remote TCP

Applications that support SOCKS5 (browsers, curl, etc.) can use the local port 1080 as a proxy; all traffic is tunneled through the SSH server.

Example use: route all browser traffic through an SSH server (VPN-like):

ssh -D 1080 user@ssh-server
# Configure browser to use SOCKS5 proxy at localhost:1080

Common Patterns

Bastion Host / Jump Host

For accessing a private network via a public bastion:

ssh -J user@bastion.example.com user@internal-host.internal.example.com

-J (ProxyJump) is the modern way: it tunnels the SSH connection to internal-host through bastion. The internal host is reachable only via the bastion.

Database Access

# Forward PostgreSQL port (5432) from the remote DB server
ssh -L 5432:db-host.internal:5432 user@bastion.example.com
# Now psql -h localhost -p 5432 connects to db-host.internal via the bastion
psql -h localhost -p 5432 mydb

This avoids exposing the database port to the internet; the SSH connection (with key auth) is the only entry point.

Reverse Tunnel for NAT Traversal

For a machine behind NAT (e.g., a home server) that wants to be reachable:

# On the home server (behind NAT), establish a reverse tunnel to a public server
ssh -R 2222:localhost:22 user@public-server.example.com
# Now public-server:2222 → home server:22 (SSH)
# From anywhere: ssh -p 2222 user@public-server.example.com → reaches the home server

Use autossh to keep the tunnel alive:

autossh -M 0 -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" \
    -R 2222:localhost:22 user@public-server.example.com -N

-N means “no command” — only forward ports.

Tunneling HTTP Through SSH

For unencrypted HTTP traffic:

ssh -L 8080:internal-http:80 user@bastion.example.com
curl http://localhost:8080/

The HTTP traffic is encrypted through the SSH tunnel; only the SSH server to internal-http:80 leg is plaintext (within the private network).

SSH Configuration

For persistent tunnels, configure SSH:

# ~/.ssh/config
Host bastion
  HostName bastion.example.com
  User myuser
  IdentityFile ~/.ssh/id_ed25519
  
  # Persistent reverse tunnel
  RemoteForward 2222 localhost:22
  
  # Keepalive (prevents idle disconnects)
  ServerAliveInterval 30
  ServerAliveCountMax 3
  
  # No shell (for tunnel-only use)
  RequestTTY no
  RemoteCommand exit 0  # or just use -N

Then:

ssh -N bastion  # establish the tunnel without a shell

Security Considerations

Gateway Ports (-g)

By default, SSH only listens on localhost for forwarded ports. To listen on all interfaces (allowing other machines to use the tunnel), use -g or GatewayPorts yes:

ssh -g -L 8080:internal:80 user@bastion
# Now other machines can connect to this machine:8080

Security implication: anyone who can reach this machine’s port 8080 can use the tunnel. Use sparingly.

Tunnel-Only Users

For SSH users that should only tunnel (no shell), create a restricted user:

# Create a user with no shell
sudo useradd -m -s /usr/sbin/nologin tunnel-only
sudo passwd tunnel-only

# Configure SSH (with forced command for tunneling)
sudo tee -a /etc/ssh/sshd_config << EOF
Match User tunnel-only
  ForceCommand internal-sftp  # or just no shell
  AllowTcpForwarding yes
  PermitTTY no
  X11Forwarding no
EOF

Key Authentication Only

Disable password auth for tunnels:

# /etc/ssh/sshd_config
PasswordAuthentication no
PubkeyAuthentication yes

For tunnel-only users, this is essential.

Time-Limited Keys

For temporary access, generate keys with validity periods:

ssh-keygen -t ed25519 -f ~/.ssh/temp_key -O force-command='internal-sftp' -O no-port-forwarding -V '+1d'

The key is valid for 1 day; useful for contractors or one-off access.

Production Use Cases

Dev/Staging Database Access

Developers tunnel to a staging database without exposing it to the internet:

# In ~/.ssh/config
Host staging-db-tunnel
  HostName bastion.staging.example.com
  User developer
  LocalForward 5432 staging-db.internal:5432
  ServerAliveInterval 30
# Connect
ssh -N staging-db-tunnel
# Now psql -h localhost connects to staging-db

Accessing Services in a Private VPC

ssh -L 8080:internal-service.vpc:80 user@bastion

The internal service is never exposed; the bastion (with MFA-enforced SSH) is the only entry.

Web-Based Administration

For accessing web admin interfaces (e.g., admin panels, Grafana, Prometheus) that should not be public:

ssh -L 3000:grafana.internal:3000 user@bastion
# Open http://localhost:3000

Common Pitfalls

  1. Forgetting that SSH tunnels don’t scale. A single SSH tunnel has a TCP throughput limit (~1 Gbps on modern hardware). For high-throughput, use a proper VPN (WireGuard, IPSec).

  2. Forgetting that the SSH server’s CPU is the bottleneck. SSH encrypts every byte; for high-throughput, the server’s CPU saturates. Use AES-GCM (hardware-accelerated) or ChaCha20-Poly1305.

  3. Forgetting that tunnels can be hijacked. A compromised local user can use an established tunnel. Use restrictive config (no shell, forced commands) for tunnel-only users.

  4. Forgetting that local forwards can conflict. Two SSH tunnels to the same local port (-L 8080:...) conflict; only the first works. Use different ports.

  5. Forgetting that DNS isn’t tunneled. The tunnel forwards TCP/UDP ports, not DNS. For DNS resolution of internal hosts, use a separate DNS tunnel or configure /etc/hosts.

  6. Forgetting that tunnels die when the SSH connection dies. For persistent tunnels, use autossh or systemd to restart on failure.

Comparison to Other Tunneling Solutions

SolutionUse casePerformanceSecurity
SSH tunnelAd-hoc, single-user, dev~1 GbpsStrong (SSH encryption)
WireGuardVPN, many users~3 GbpsStrong (modern crypto)
IPSecSite-to-site VPN~5 GbpsStrong (mature)
SSLH / TLS terminationMultiplex on port 443HighTLS
Cloudflare TunnelExpose local to internetVariableTLS + Cloudflare auth

For occasional access, SSH tunnels are the simplest. For persistent VPN, WireGuard. For site-to-site, IPSec. For web exposure, Cloudflare Tunnel.

References