SSH Tunneling: The Complete Guide for 2026

Published February 12, 2026 · 25 min read

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.

⚙ Try it: Generate a ready-to-run tunnel command with our SSH Tunnel Builder (port forwarding, SOCKS proxy, ProxyJump, autossh). You can also keep the Linux Commands Cheat Sheet and Bash Shortcuts Cheat Sheet open while working through these examples.

Table of Contents

  1. What SSH Tunneling Is and How It Works
  2. Local Port Forwarding (-L)
  3. Remote Port Forwarding (-R)
  4. Dynamic Port Forwarding / SOCKS Proxy (-D)
  5. Jump Hosts and ProxyJump (-J)
  6. Practical Examples
  7. SSH Config for Persistent Tunnels
  8. autossh: Keeping Tunnels Alive
  9. Multiple Tunnels in One Command
  10. Security Best Practices
  11. Debugging Tunnels
  12. SSH Tunneling vs VPN
  13. 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.

Without tunnel (blocked by firewall): [Your Machine] ---X--> [Database :5432] firewall blocks direct access With SSH tunnel: [Your Machine] [SSH Server] [Database] localhost:5432 ------> ssh-server:22 ------> db:5432 | encrypted SSH tunnel private network | | Your app connects Database responds to localhost:5432 through the tunnel

There are three types of SSH port forwarding:

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
Local Port Forwarding: ssh -L 5432:db-server:5432 user@jump [Your Machine] [jump] [db-server] +--------------+ +----------------+ +----------------+ | | | | | | | localhost |===> | SSH connection |===> | PostgreSQL | | :5432 | | (encrypted) | | :5432 | | | | | | | +--------------+ +----------------+ +----------------+ ^ | | traffic flows | app connects through encrypted database to localhost SSH tunnel responds back

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
Remote Port Forwarding: ssh -R 8080:localhost:3000 user@public-server [Your Machine] [public-server] [Internet] +--------------+ +-------------------+ +--------------+ | | | | | | | Dev server | <== | SSH connection | <== | Visitors | | localhost | | (encrypted) | | connect to | | :3000 | | public-server | | public-server| | | | :8080 | | :8080 | +--------------+ +-------------------+ +--------------+ Traffic flows: Internet :8080 --> public-server --> tunnel --> your :3000

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
Dynamic SOCKS Proxy: ssh -D 1080 user@remote-server [Your Machine] [remote-server] +--------------+ +-------------------+ +----------+ | | | |--> | Site A | | SOCKS proxy |===> | SSH connection |--> | Site B | | localhost | | (encrypted) |--> | API C | | :1080 | | routes to ANY |--> | DB D | | | | destination |--> | ... | +--------------+ +-------------------+ +----------+ All traffic through the proxy exits from remote-server's network

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
ProxyJump: ssh -J bastion internal-server [Your Machine] [bastion] [internal-server] +--------------+ +----------------+ +--------------------+ | | | | | | | SSH client |=>| Jump through |=>| Final destination | | | | (no shell here)| | (shell opens here) | | | | | | | +--------------+ +----------------+ +--------------------+ Single command, keys stay on your machine, no agent forwarding needed

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
⚙ Related: See our Kubernetes Complete Guide for more on accessing cluster services securely.

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
⚙ Related: Optimizing the nginx you are tunneling into? Read our Nginx Performance Tuning Guide.

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
⚙ Related: Managing Docker services behind these tunnels? See our Docker Compose Complete Guide.

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:

Use a VPN when:

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

Related Resources

Docker Compose: The Complete Guide
Manage multi-container apps on servers you tunnel into
Nginx Performance Tuning
Optimize the web servers behind your SSH tunnels
Kubernetes: The Complete Guide
Access dashboards and cluster services through SSH tunnels
Linux Commands Cheat Sheet
Essential commands for managing remote servers
Bash Shortcuts Cheat Sheet
Work faster in terminal sessions you tunnel into