← Back to Blog

SSH Tunneling Explained: Local, Remote, and Dynamic Port Forwarding

SSH tunneling lets you encrypt any TCP traffic through an SSH connection — access private databases, expose local services to the internet, or route your entire browser through a SOCKS proxy. Here is exactly how each mode works, with real commands you can copy and run.

What Is SSH Tunneling?

An SSH tunnel is a method of transporting arbitrary networking data over an encrypted SSH connection. It can be used to add encryption to legacy applications, access services behind a firewall, or securely expose a local port to a remote machine. The SSH protocol was designed for this from the beginning — it is not a hack or workaround, it is a built-in feature specified in RFC 4254.

There are three distinct tunnel types, each with a different use case:

  • Local port forwarding (-L): Forward a port on your local machine to a port on a remote host, via the SSH server.
  • Remote port forwarding (-R): Forward a port on the remote SSH server back to a port on your local machine (or any host your local machine can reach).
  • Dynamic port forwarding (-D): Create a SOCKS proxy on your local machine that routes all traffic through the SSH server.

Understanding which mode you need is the key to using SSH tunnels effectively. The confusion usually comes from the direction of traffic flow — local vs remote describes where the listening socket opens, not which machine initiates the SSH connection.

Local Port Forwarding (-L): Access Remote Services Locally

Local port forwarding opens a port on your local machine and forwards connections through the SSH server to a destination host and port. The destination can be the SSH server itself or any host the SSH server can reach on its network.

Syntax

ssh -L [local_bind_address:]local_port:destination_host:destination_port user@ssh_server

Most Common Use Case: Access a Private Database

You have a PostgreSQL server at db.internal:5432 that is only accessible from your production server at prod.example.com. You want to connect from your laptop using a local GUI tool like TablePlus or psql.

# Forward local port 5433 → through prod.example.com → to db.internal:5432
ssh -L 5433:db.internal:5432 user@prod.example.com

# Now in another terminal, connect as if the DB were local:
psql -h 127.0.0.1 -p 5433 -U myuser mydatabase

The PostgreSQL server never sees a connection from your laptop's IP — it sees a connection from the SSH server's internal IP. Your laptop and prod.example.com communicate over encrypted SSH. The segment from prod.example.com to db.internal is unencrypted, but it is an internal private network connection.

Access a Remote Web App on a Non-Public Port

# Forward local 8080 → remote localhost:3000 (app running only on loopback)
ssh -L 8080:localhost:3000 user@prod.example.com

# Now open http://localhost:8080 in your browser

Run the Tunnel in the Background

# -N: do not execute a remote command
# -f: go to background after authentication
# -T: disable pseudo-tty allocation
ssh -fNT -L 5433:db.internal:5432 user@prod.example.com

Bind to All Interfaces (Share with Your Team)

By default, the local port binds to 127.0.0.1 only. To allow other machines on your local network to use the tunnel:

# Bind to all interfaces on your machine (0.0.0.0)
ssh -L 0.0.0.0:5433:db.internal:5432 user@prod.example.com

# Team members can connect to YOUR_IP:5433

Warning: This exposes the tunnel to anyone on your local network. Only do this on a trusted network. Requires GatewayPorts yes on the remote SSH server if you bind on a remote address.

Remote Port Forwarding (-R): Expose Local Services to the Internet

Remote port forwarding is the reverse: it opens a port on the remote SSH server and forwards connections back to your local machine (or any host your local machine can reach). This is how you expose a service running on your laptop to the internet without any inbound firewall rules.

Syntax

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

Expose a Local Dev Server to the Internet

You are running a webhook receiver on your laptop at port 3000 and need to receive GitHub webhooks during development. Your company's VPS at vps.example.com has a public IP.

# Open port 9000 on vps.example.com, forward to your laptop's port 3000
ssh -R 9000:localhost:3000 user@vps.example.com

# GitHub can now POST to http://vps.example.com:9000/webhook
# The request arrives at your laptop's localhost:3000

Required SSH Server Configuration

By default, remote port forwarding only binds to localhost on the SSH server, meaning the forwarded port is not publicly accessible. To bind it to 0.0.0.0 (all interfaces), add this to /etc/ssh/sshd_config on the server:

GatewayPorts yes
# or, to let the client choose:
GatewayPorts clientspecified

Then restart sshd: sudo systemctl restart sshd. After that, you can request a specific bind address from the client:

# Bind to all interfaces on the remote server
ssh -R 0.0.0.0:9000:localhost:3000 user@vps.example.com

Reverse Tunnel for Managing Servers Behind NAT

A classic DevOps pattern: you have a Raspberry Pi or an on-premise server behind a NAT router with no public IP. You want to SSH into it from anywhere. Set up a persistent reverse tunnel from the Pi to a VPS you control:

# Run this on the Pi (or in a systemd service):
ssh -fNT -R 2222:localhost:22 user@vps.example.com

# From anywhere, SSH to the Pi via the VPS:
ssh -p 2222 user@vps.example.com
# This lands you inside the Pi

Dynamic Port Forwarding (-D): Full SOCKS Proxy

Dynamic port forwarding creates a SOCKS5 proxy server on your local machine. Unlike local forwarding which targets one specific host:port, a SOCKS proxy lets you route any TCP connection through the SSH server — your application specifies the destination at connection time.

Syntax

ssh -D local_port user@ssh_server

Route Browser Traffic Through a Remote Server

# Create SOCKS5 proxy on local port 1080
ssh -fNT -D 1080 user@vps.example.com

# Configure your browser to use SOCKS5 proxy:
# Host: 127.0.0.1  Port: 1080

# Or use curl with the proxy:
curl --socks5-hostname 127.0.0.1:1080 https://example.com

All traffic from the browser (or curl) is encrypted to the SSH server, then exits from the VPS's IP. This is useful for accessing region-restricted resources, testing geo-specific behaviour, or securing traffic on untrusted WiFi.

Route Specific Apps with ProxyChains

On Linux, you can use proxychains to force any program through the SOCKS proxy without changing its configuration:

# Install proxychains-ng
sudo apt install proxychains4

# Edit /etc/proxychains4.conf:
# [ProxyList]
# socks5  127.0.0.1 1080

# Run any command through the proxy:
proxychains4 curl https://ipinfo.io/ip
proxychains4 git clone https://github.com/example/repo.git

Generate Your SSH Config File

Stop retyping long SSH commands. Our free SSH Config Generator creates a properly formatted ~/.ssh/config with host aliases, identity files, port forwarding, jump hosts, and more.

Open SSH Config Generator

SSH Config File: Save Tunnels Permanently

Typing long ssh -L or ssh -R commands every time is error-prone. Store your tunnels in ~/.ssh/config so you can launch them with a simple alias.

Local Forward in SSH Config

Host prod-db-tunnel
    HostName prod.example.com
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    LocalForward 5433 db.internal:5432
    LocalForward 6380 redis.internal:6379
    ServerAliveInterval 60
    ServerAliveCountMax 3
    ExitOnForwardFailure yes

Now run: ssh -fNT prod-db-tunnel — both database and Redis are forwarded.

Jump Host (ProxyJump) for Multi-Hop Access

When the target server is only reachable through a bastion host, use ProxyJump (formerly ProxyCommand):

Host bastion
    HostName bastion.example.com
    User ubuntu
    IdentityFile ~/.ssh/bastion.pem

Host internal-server
    HostName 10.0.1.50
    User ubuntu
    IdentityFile ~/.ssh/internal.pem
    ProxyJump bastion
    # SSH to internal-server through bastion in one command:
    # ssh internal-server

You can combine ProxyJump with LocalForward to tunnel through multiple hops:

Host db-via-bastion
    HostName 10.0.1.50
    User ubuntu
    ProxyJump bastion
    LocalForward 5433 10.0.2.20:5432

Persistent Tunnels with autossh

Regular SSH tunnels die when the connection drops (network hiccup, laptop sleep, SSH server restart). autossh monitors the tunnel and restarts it automatically.

Install and Run autossh

# Install
sudo apt install autossh       # Debian/Ubuntu
brew install autossh           # macOS

# Run a persistent local forward
autossh -M 20000 -fNT -L 5433:db.internal:5432 user@prod.example.com

# -M 20000: monitoring port autossh uses to detect tunnel health
# Set -M 0 to disable monitoring (rely on ServerAlive* options instead)

Run autossh as a systemd Service

For server-side persistent tunnels, create a systemd unit file at /etc/systemd/system/ssh-tunnel-db.service:

[Unit]
Description=SSH tunnel to production database
After=network.target

[Service]
User=ubuntu
ExecStart=/usr/bin/autossh -M 0 -NT \
    -o "ServerAliveInterval=30" \
    -o "ServerAliveCountMax=3" \
    -o "ExitOnForwardFailure=yes" \
    -o "StrictHostKeyChecking=accept-new" \
    -i /home/ubuntu/.ssh/id_ed25519 \
    -L 5433:db.internal:5432 \
    deploy@prod.example.com
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now ssh-tunnel-db
sudo systemctl status ssh-tunnel-db

Real-World Use Cases

1. Access AWS RDS Without a Public Endpoint

AWS RDS instances should never be publicly accessible. The correct pattern is to place the RDS instance in a private subnet and access it only through an EC2 bastion host.

# RDS endpoint: mydb.xxxx.us-east-1.rds.amazonaws.com
# Bastion: bastion.example.com (public subnet, security group allows your IP:22)

ssh -fNT -L 5432:mydb.xxxx.us-east-1.rds.amazonaws.com:5432 ec2-user@bastion.example.com

# Connect locally:
psql -h 127.0.0.1 -p 5432 -U admin mydb

2. Access Kubernetes Services Locally (kubectl port-forward Alternative)

While kubectl port-forward is the standard approach, sometimes you need to access a Kubernetes node's port or a service not exposed via port-forward. If your kubectl master is behind a VPN or bastion:

# Tunnel to the K8s API server via bastion
ssh -fNT -L 6443:k8s-api.internal:6443 user@bastion.example.com

# Update your kubeconfig server to point to localhost:
# server: https://localhost:6443

3. Bypass Corporate Firewall for HTTPS Traffic

On networks that block outbound ports other than 80 and 443, run your SSH server on port 443. Since SSH and HTTPS use the same port, most firewalls cannot distinguish them without deep packet inspection:

# On the server: run sshd on port 443 (in addition to 22)
# In /etc/ssh/sshd_config:
Port 22
Port 443

# From the client:
ssh -p 443 -D 1080 user@vps.example.com

4. Testing Internal Webhooks in Development

Instead of paying for ngrok, use a remote port forward to a cheap VPS you own:

# Your local dev server runs at localhost:3000
# Your VPS public IP: 203.0.113.10

ssh -R 0.0.0.0:8080:localhost:3000 user@203.0.113.10

# GitHub webhook URL: http://203.0.113.10:8080/webhook

Security Considerations

SSH tunnels are powerful, which makes them a potential security risk if misconfigured or misused. Key points:

  • Disable unnecessary forwarding on servers: In /etc/ssh/sshd_config, set AllowTcpForwarding no and GatewayPorts no on servers where tunneling is not needed. This prevents compromised accounts from using the server as a pivot point.
  • Use AllowUsers or AllowGroups: Restrict which users can SSH at all. Combined with ForceCommand, you can create tunnel-only accounts that cannot run arbitrary commands.
  • Restrict tunnel-only accounts: Create an SSH key pair with a restricted key in authorized_keys using the permitopen option to limit what that key can forward:
# In ~/.ssh/authorized_keys on the server:
permitopen="db.internal:5432",no-pty,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA... deploy-key-comment
  • Always use key-based authentication: Disable password authentication on all SSH servers (PasswordAuthentication no in sshd_config). Brute force attacks against password SSH are constant on any public IP.
  • Monitor active tunnels: Run ss -tnlp | grep ssh on the server to see active forwarded ports. Unexpected listening ports are a red flag.
  • Audit GatewayPorts: Never set GatewayPorts yes on a multi-tenant server. Any user who can SSH can then expose internal ports to the public internet.

Debugging SSH Tunnels

When a tunnel does not work, these steps resolve 95% of cases:

# 1. Run ssh with verbose output to see exactly what is happening
ssh -vvv -L 5433:db.internal:5432 user@prod.example.com

# 2. Check if the local port is actually listening
ss -tnlp | grep 5433      # Linux
lsof -i :5433             # macOS

# 3. Test connectivity from the SSH server to the destination
ssh user@prod.example.com
nc -zv db.internal 5432   # run inside the SSH session

# 4. Check sshd_config on the server for AllowTcpForwarding
grep -i forward /etc/ssh/sshd_config

# 5. Check firewall on the SSH server (for remote forwards)
sudo ufw status
sudo iptables -L INPUT -n

Common Error: "channel 3: open failed: connect failed"

This means the SSH server cannot reach destination_host:destination_port. Either the destination is wrong, the destination service is not running, or a firewall on the SSH server's side is blocking the connection. Test with nc -zv destination_host port from inside an SSH session.

Common Error: "bind: Address already in use"

The local port you tried to forward is already occupied. Kill the existing tunnel: lsof -ti :5433 | xargs kill, or choose a different local port number.

Quick Reference: SSH Tunnel Commands

# LOCAL FORWARD: access remote_host:remote_port via localhost:local_port
ssh -L local_port:remote_host:remote_port user@ssh_server

# REMOTE FORWARD: expose localhost:local_port on ssh_server:remote_port
ssh -R remote_port:localhost:local_port user@ssh_server

# DYNAMIC FORWARD: SOCKS5 proxy on localhost:local_port
ssh -D local_port user@ssh_server

# Useful flags:
# -f    go to background
# -N    don't execute a command (tunnel only)
# -T    no pseudo-tty
# -C    compress traffic (slower on fast networks)
# -i    identity file (private key)
# -p    SSH server port (default 22)
# -o ServerAliveInterval=60    keep connection alive

The Bottom Line

SSH tunneling is one of the most useful tools in a DevOps engineer's toolkit. Local forwarding is your go-to for accessing private databases and services. Remote forwarding is how you expose local dev services to the internet without ngrok. Dynamic forwarding gives you a full SOCKS proxy for routing browser and application traffic. Store all your tunnel configs in ~/.ssh/config and use autossh with systemd for production-grade persistent tunnels.

Related tools: SSH Config Generator to build your config file, Port Lookup to check what services use specific ports, Subnet Calculator for network planning, and more DevOps guides.