SSH Tunneling: The Complete Guide for 2026
You need to connect to a PostgreSQL database that only accepts connections from localhost. Or access a web dashboard behind a corporate firewall. Or route your traffic through a trusted server on an untrusted network. SSH tunneling solves all of these problems without installing a VPN, opening firewall ports, or exposing services to the public internet.
SSH tunnels create encrypted channels between machines, forwarding network traffic through the secure SSH connection. This guide covers every type of SSH tunnel — local, remote, dynamic — with practical examples, ASCII diagrams, and production-ready configurations.
Table of Contents
- What SSH Tunneling Is and How It Works
- Local Port Forwarding (-L)
- Remote Port Forwarding (-R)
- Dynamic Port Forwarding / SOCKS Proxy (-D)
- Jump Hosts and ProxyJump (-J)
- Practical Examples
- SSH Config for Persistent Tunnels
- autossh: Keeping Tunnels Alive
- Multiple Tunnels in One Command
- Security Best Practices
- Debugging Tunnels
- SSH Tunneling vs VPN
- Frequently Asked Questions
1. What SSH Tunneling Is and How It Works
An SSH tunnel wraps arbitrary TCP traffic inside an encrypted SSH connection. Instead of connecting directly to a remote service — which might be blocked by a firewall or listening only on a private network — you route the traffic through an SSH server that can reach the service.
The core concept is port forwarding: SSH listens on a port at one end of the tunnel and forwards any traffic it receives through the encrypted connection to a destination at the other end.
There are three types of SSH port forwarding:
- Local forwarding (-L) — forward a local port to a remote destination through the SSH server
- Remote forwarding (-R) — forward a remote port on the SSH server back to a local destination
- Dynamic forwarding (-D) — create a local SOCKS proxy that routes traffic to any destination through the SSH server
2. Local Port Forwarding (-L)
Local port forwarding is the most common type. It opens a port on your local machine and forwards traffic through the SSH connection to a destination host and port. The syntax is:
ssh -L [bind_address:]local_port:destination_host:destination_port user@ssh-server
Basic Examples
# Forward local port 5432 to a remote PostgreSQL server
ssh -L 5432:localhost:5432 user@db-server
# The "localhost" above refers to the SSH server's perspective
# It means: from db-server, connect to localhost:5432
# (the database running on db-server itself)
# Forward to a DIFFERENT host reachable from the SSH server
ssh -L 5432:internal-db.corp:5432 user@jump-host
# Use a different local port (when 5432 is already in use)
ssh -L 15432:internal-db.corp:5432 user@jump-host
# Connect your client to localhost:15432
Bind Address and Background Mode
By default, SSH binds to localhost only. To allow other machines on your network to use the tunnel, specify the bind address. Use -N (no remote command) and -f (fork to background) for tunnel-only connections:
# Only your machine can use the tunnel (default)
ssh -L 127.0.0.1:8080:internal-web:80 user@jump
# Allow any machine on your network to use the tunnel
ssh -L 0.0.0.0:8080:internal-web:80 user@jump
# Start tunnel in background, no remote shell
ssh -fNL 5432:db-server:5432 user@jump-host
# -f fork to background after authentication
# -N no remote command (tunnel only)
3. Remote Port Forwarding (-R)
Remote port forwarding works in the opposite direction: it opens a port on the remote SSH server and forwards traffic back to your local machine. Use it to expose a local service through a remote server.
ssh -R [bind_address:]remote_port:destination_host:destination_port user@ssh-server
Use Cases
# Expose your local dev server (port 3000) on a public server (port 8080)
ssh -R 8080:localhost:3000 user@public-server
# Anyone can now visit http://public-server:8080
# Expose a local service for a colleague behind NAT
ssh -R 9090:localhost:9090 user@shared-server
# Colleague connects to shared-server:9090
# Expose your local Kubernetes dashboard
ssh -R 8443:localhost:8443 user@jump-host
By default, remote forwarded ports bind to 127.0.0.1 on the server. To make them accessible externally, set GatewayPorts yes (or clientspecified) in the server's /etc/ssh/sshd_config, then use ssh -R 0.0.0.0:8080:localhost:3000 user@public-server.
4. Dynamic Port Forwarding / SOCKS Proxy (-D)
Dynamic forwarding creates a local SOCKS proxy. Instead of forwarding a single port to a single destination, it routes any traffic sent through the proxy to any destination reachable from the SSH server.
ssh -D [bind_address:]port user@ssh-server
Setting Up a SOCKS Proxy
# Create a SOCKS5 proxy on localhost:1080
ssh -D 1080 user@remote-server
# Background tunnel with no shell
ssh -fND 1080 user@remote-server
# Use with curl
curl --socks5-hostname localhost:1080 http://internal-service.corp:8080
# Use with wget
https_proxy=socks5://localhost:1080 wget http://internal-service.corp:8080
Browser and System Configuration
# Firefox: Settings > Network Settings > Manual Proxy Configuration
# SOCKS Host: localhost Port: 1080 SOCKS v5
# Check "Proxy DNS when using SOCKS v5"
# Chrome with proxy flag:
google-chrome --proxy-server="socks5://localhost:1080"
# Linux environment variable (for CLI tools):
export ALL_PROXY=socks5://localhost:1080
5. Jump Hosts and ProxyJump (-J)
Jump hosts (also called bastion hosts) are intermediate servers you pass through to reach a destination. Instead of SSH-ing to the bastion, then SSH-ing from there to the target, ProxyJump chains the connections in a single command.
ssh -J jump-host destination-host
Single Jump Host
# Jump through bastion to reach internal server
ssh -J user@bastion user@internal-server
# With port forwarding through a jump host
ssh -J user@bastion -L 5432:db-server:5432 user@internal-server
# Jump host on a non-standard port
ssh -J user@bastion:2222 user@internal-server
Multiple Jump Hosts (Chaining)
# Chain through two jump hosts
ssh -J user@bastion1,user@bastion2 user@destination
# Chain through three jump hosts
ssh -J user@hop1,user@hop2,user@hop3 user@destination
ProxyJump vs ProxyCommand: ProxyJump (-J) replaced the older ProxyCommand approach (ssh -o ProxyCommand="ssh -W %h:%p user@bastion" user@dest). ProxyJump is simpler, handles key forwarding correctly, and is available since OpenSSH 7.3 (2016). Use ProxyCommand only for compatibility with very old SSH versions.
6. Practical Examples
Access a Remote PostgreSQL Database
# Scenario: PostgreSQL on db.internal only accepts connections
# from the application server (app-server)
ssh -L 5432:db.internal:5432 user@app-server
# Now connect your local client:
psql -h localhost -p 5432 -U myuser -d mydb
# Or use a GUI tool (pgAdmin, DBeaver) pointed at localhost:5432
Access a Web Dashboard Behind a Firewall
# Scenario: Grafana dashboard runs on monitoring.internal:3000
# Only reachable from jump-host
ssh -L 3000:monitoring.internal:3000 user@jump-host
# Open http://localhost:3000 in your browser
Access a Kubernetes Dashboard
# Scenario: K8s dashboard on the cluster, accessible via kubectl proxy
# on the control plane node at 127.0.0.1:8001
ssh -L 8001:localhost:8001 user@k8s-control-plane
# Or tunnel to the dashboard service directly
ssh -L 8443:kubernetes-dashboard.kubernetes-dashboard.svc:443 user@k8s-node
# Then open https://localhost:8443 in your browser
Tunnel to a Docker Container
# Scenario: Docker container running Redis on a remote server,
# bound to 127.0.0.1:6379 (not exposed publicly)
ssh -L 6379:localhost:6379 user@docker-host
# Connect locally:
redis-cli -h localhost -p 6379
Access a Remote Nginx Server for Debugging
# Scenario: nginx admin interface on port 8080, firewalled
ssh -L 8080:localhost:8080 user@web-server
# Now test: curl http://localhost:8080/nginx_status
Expose Local Dev Server to Teammates
# Scenario: You are developing locally on port 3000
# Your teammate needs to see your work but you are behind NAT
ssh -R 8080:localhost:3000 user@shared-server
# Teammate visits http://shared-server:8080 to see your app
7. SSH Config for Persistent Tunnels
Instead of typing long tunnel commands every time, define them in ~/.ssh/config. SSH reads this file automatically and applies the matching options.
# ~/.ssh/config
# Jump host definition
Host bastion
HostName bastion.example.com
User admin
Port 22
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 60
ServerAliveCountMax 3
# Internal server accessed through bastion
Host internal-web
HostName 10.0.1.50
User deploy
ProxyJump bastion
# Database tunnel (just run: ssh db-tunnel)
Host db-tunnel
HostName bastion.example.com
User admin
LocalForward 5432 db.internal:5432
LocalForward 6379 cache.internal:6379
RequestTTY no
ExecCommand none
# SOCKS proxy (just run: ssh socks-proxy)
Host socks-proxy
HostName remote-server.example.com
User admin
DynamicForward 1080
RequestTTY no
ExecCommand none
# Remote forwarding (expose local dev server)
Host expose-dev
HostName shared-server.example.com
User dev
RemoteForward 8080 localhost:3000
RequestTTY no
ExecCommand none
# Wildcard for all corp servers
Host *.corp.example.com
User admin
ProxyJump bastion
IdentityFile ~/.ssh/id_ed25519_corp
ServerAliveInterval 60
Now you can open tunnels with simple commands:
# Open database tunnel
ssh db-tunnel
# Start SOCKS proxy
ssh socks-proxy
# SSH to internal server through bastion
ssh internal-web
# All corp servers automatically go through bastion
ssh app1.corp.example.com
8. autossh: Keeping Tunnels Alive
SSH tunnels drop when the network hiccups, the laptop sleeps, or the connection times out. autossh monitors the SSH connection and restarts it automatically when it fails.
# Install autossh
sudo apt install autossh # Debian/Ubuntu
brew install autossh # macOS
sudo dnf install autossh # Fedora/RHEL
Basic Usage
# Replace "ssh" with "autossh -M 0" in your tunnel command
# -M 0 disables autossh's monitoring port (use SSH keepalives instead)
autossh -M 0 -fNL 5432:db.internal:5432 user@jump-host
# With SSH keepalive options (recommended)
autossh -M 0 -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" \
-fNL 5432:db.internal:5432 user@jump-host
systemd Service for Persistent Tunnels
For tunnels that must survive reboots, create a systemd service:
# /etc/systemd/system/ssh-tunnel-db.service
[Unit]
Description=SSH tunnel to database
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=tunnel-user
ExecStart=/usr/bin/autossh -M 0 -N \
-o "ServerAliveInterval 30" \
-o "ServerAliveCountMax 3" \
-o "ExitOnForwardFailure yes" \
-L 5432:db.internal:5432 \
jump-host
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
# Enable and start the tunnel service
sudo systemctl daemon-reload
sudo systemctl enable ssh-tunnel-db
sudo systemctl start ssh-tunnel-db
# Check status
sudo systemctl status ssh-tunnel-db
# View logs
journalctl -u ssh-tunnel-db -f
9. Multiple Tunnels in One Command
You can stack multiple -L, -R, and -D flags in a single SSH command:
# Forward multiple ports in one connection
ssh -N \
-L 5432:db.internal:5432 \
-L 6379:cache.internal:6379 \
-L 8080:monitoring.internal:3000 \
-L 9200:elastic.internal:9200 \
user@jump-host
# Mix local and remote forwarding
ssh -N \
-L 5432:db.internal:5432 \
-R 8080:localhost:3000 \
-D 1080 \
user@server
# In SSH config (cleaner for permanent setups)
Host dev-tunnels
HostName jump-host.example.com
User admin
LocalForward 5432 db.internal:5432
LocalForward 6379 cache.internal:6379
LocalForward 8080 monitoring.internal:3000
LocalForward 9200 elastic.internal:9200
RequestTTY no
10. Security Best Practices
Use Key-Based Authentication
# Generate an Ed25519 key (strongest modern option)
ssh-keygen -t ed25519 -C "tunnel@myworkstation"
# Copy the public key to the server
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server
# Disable password auth on the server (/etc/ssh/sshd_config)
PasswordAuthentication no
PubkeyAuthentication yes
Restrict Tunnel Permissions on the Server
# In /etc/ssh/sshd_config, restrict what tunnels users can create:
# Disable all forwarding by default
AllowTcpForwarding no
GatewayPorts no
PermitTunnel no
# Allow forwarding only for specific users
Match User tunnel-user
AllowTcpForwarding local # only local forwarding
PermitOpen db.internal:5432 # only to this destination
PermitListen none # no remote forwarding
ForceCommand /bin/false # no shell access
X11Forwarding no
Restrict authorized_keys
# In ~/.ssh/authorized_keys, restrict what a key can do:
restrict,port-forwarding,permitopen="db.internal:5432" ssh-ed25519 AAAA...
# "restrict" disables everything, then selectively re-enable:
# port-forwarding - allow port forwarding
# permitopen="..." - only allow forwarding to specific destinations
# This key can ONLY create a tunnel to db.internal:5432, nothing else
Use a Dedicated Tunnel User
# Create a user with no shell, only for tunneling
sudo useradd -m -s /usr/sbin/nologin tunnel-user
sudo mkdir -p /home/tunnel-user/.ssh && sudo chmod 700 /home/tunnel-user/.ssh
# Add the public key with restrictions
echo 'restrict,port-forwarding,permitopen="db.internal:5432" ssh-ed25519 AAAA...' \
| sudo tee /home/tunnel-user/.ssh/authorized_keys
sudo chown -R tunnel-user:tunnel-user /home/tunnel-user/.ssh
11. Debugging Tunnels
Verbose Mode
# Add -v for verbose output (up to -vvv for maximum detail)
ssh -vvv -L 5432:db.internal:5432 user@jump-host
# Look for these key lines in the output:
# debug1: Local forwarding listening on 127.0.0.1 port 5432
# debug1: channel 0: new [port listener]
# debug1: Connection to port 5432 forwarding to db.internal port 5432 requested
Check if the Tunnel Port is Open
# Check if the local port is listening
ss -tlnp | grep 5432
# or
netstat -tlnp | grep 5432
# Check with lsof (shows the SSH process)
lsof -i :5432
# Test the tunnel with nc (netcat)
nc -zv localhost 5432
# Test with curl (for HTTP tunnels)
curl -v http://localhost:8080
Common Problems and Fixes
# Problem: "bind: Address already in use"
# Something else is using the local port
lsof -i :5432
# Fix: kill the process or use a different local port
ssh -L 15432:db.internal:5432 user@jump-host
# Problem: "channel 0: open failed: connect failed: Connection refused"
# The destination service is not running or not accepting connections
# Fix: verify the service is running on the remote end
ssh user@jump-host "nc -zv db.internal 5432"
# Problem: "channel 0: open failed: administratively prohibited"
# The SSH server has AllowTcpForwarding set to no
# Fix: enable forwarding in /etc/ssh/sshd_config
# Problem: tunnel connects but no data flows
# Often a DNS issue; the SSH server cannot resolve the hostname
# Fix: use IP addresses instead of hostnames
ssh -L 5432:10.0.1.50:5432 user@jump-host
Escape Sequences
# While in an SSH session, press Enter then ~ then a command:
# ~? show available escape commands
# ~# list forwarded connections
# ~C open command line to add/remove forwarding on the fly
# ~. terminate the SSH session
# Example: press Enter, ~C, type -L 6379:cache.internal:6379, Enter
12. SSH Tunneling vs VPN: When to Use Which
SSH tunnels and VPNs both create encrypted connections, but they solve different problems.
| Feature | SSH Tunnel | VPN |
|---|---|---|
| Scope | Specific ports/services | Full network access |
| Setup | One command, no install | Server + client software |
| Protocol | TCP only | TCP, UDP, ICMP, all IP |
| DNS | Manual (SOCKS proxy for DNS) | Automatic routing |
| Performance | TCP-over-TCP can stall | WireGuard is near native |
| Software | SSH client (built-in) | WireGuard, OpenVPN, etc. |
| Best for | Quick, targeted access | Full network membership |
Use SSH tunnels when:
- You need to access one or a few specific services (database, dashboard, API)
- You want zero-install access — SSH is already on every Linux/macOS machine
- You need a quick, temporary connection
- You are debugging or doing ad-hoc work on remote infrastructure
Use a VPN when:
- You need full network access to many services
- You need UDP support (DNS, VoIP, game servers)
- You want all traffic automatically routed through the tunnel
- You need to join a network as a full participant (mDNS, broadcast, etc.)
- Performance matters — WireGuard runs in kernel space and avoids TCP-over-TCP overhead
Frequently Asked Questions
What is the difference between local and remote SSH port forwarding?
Local port forwarding (-L) opens a port on your local machine and forwards traffic through the SSH server to a destination. You use it to access remote services as if they were local, such as connecting to a database behind a firewall. Remote port forwarding (-R) does the opposite: it opens a port on the remote SSH server and forwards traffic back to your local machine or network. You use it to expose a local service to the internet, such as letting a colleague access your development server through a shared SSH server.
How do I create an SSH tunnel to access a remote database?
Use local port forwarding with the -L flag. For example, to access a PostgreSQL database on a remote server: ssh -L 5432:db-server:5432 user@jump-host. This binds port 5432 on your local machine and forwards traffic through jump-host to db-server:5432. You can then connect your database client to localhost:5432. If your local port 5432 is already in use, pick a different local port like 15432: ssh -L 15432:db-server:5432 user@jump-host.
What is a SOCKS proxy with SSH and when should I use it?
A SOCKS proxy created with ssh -D opens a local port that acts as a dynamic proxy. Unlike -L which forwards a single port to a single destination, -D routes any traffic sent through it to any destination reachable from the SSH server. Configure your browser or application to use the SOCKS5 proxy at localhost:1080 and all traffic flows through the SSH tunnel. Use it when you need to access multiple services on a remote network without setting up individual tunnels for each one, or when you want to browse the web as if you were on the remote network.
How do I keep an SSH tunnel alive permanently?
Use autossh, a tool that monitors SSH connections and automatically restarts them if they drop. Install it with your package manager (apt install autossh or brew install autossh), then replace ssh with autossh -M 0 in your tunnel command. The -M 0 flag disables autossh's own monitoring port and relies on SSH's built-in keepalive instead. Combine it with ServerAliveInterval and ServerAliveCountMax in your SSH config. For production, create a systemd service unit that runs autossh on boot with Restart=always.
SSH tunnel vs VPN: which should I use?
SSH tunnels are best for forwarding specific ports or a small number of services. They require no special software beyond an SSH client, are quick to set up, and work through most firewalls. VPNs are better when you need full network-level access to a remote network, where all traffic (not just specific ports) should be routed through the tunnel. VPNs handle routing, DNS, and broadcast traffic that SSH tunnels cannot. Use SSH tunnels for quick, targeted access to specific services. Use a VPN when you need to join a remote network as if your machine were physically connected to it.
What is ProxyJump and how does it replace nested SSH tunnels?
ProxyJump (-J flag or ProxyJump directive in SSH config) lets you reach a destination server through one or more intermediate jump hosts in a single command. Instead of opening a tunnel to the first server and then SSH-ing from there, you run: ssh -J jump-host destination-host. SSH automatically chains the connections. You can chain multiple jump hosts: ssh -J host1,host2 destination. In your SSH config, set ProxyJump host1 under the destination host's entry. ProxyJump replaced the older ProxyCommand with netcat and is simpler, more secure, and handles key forwarding correctly.
Conclusion
SSH tunneling is one of the most powerful networking tools available, and it is already installed on your machine. Whether you need to access a database behind a firewall, expose a development server to a teammate, or route all your traffic through a trusted network, SSH tunnels provide a secure, encrypted channel with zero additional software.
Start with local port forwarding (-L) to access a single remote service. Once you are comfortable, add persistent tunnels in your ~/.ssh/config, keep them alive with autossh, and use ProxyJump to simplify bastion host access. For broader network access beyond individual ports, consider pairing SSH tunnels with a VPN like WireGuard.
Learn More
- Docker Compose: The Complete Guide — manage multi-container applications on servers you tunnel into
- Nginx Performance Tuning Guide — optimize the web servers behind your tunnels
- Kubernetes: The Complete Guide — access K8s dashboards and services through SSH tunnels
- Linux Commands Cheat Sheet — essential commands for managing remote servers
- Bash Shortcuts Cheat Sheet — work faster in the terminal sessions you tunnel into