Local forwarding, remote forwarding, dynamic proxying, and jump hosts — SSH tunnelling turns any encrypted SSH connection into a secure conduit for any TCP traffic.
-L local · -R remote · -D dynamic · -J ProxyJump · persistent tunnels
01 — How SSH Tunnelling Works
SSH port forwarding works by asking the SSH daemon at one end to open a socket, accept connections on it, and relay the data through the encrypted SSH channel to a destination socket at the other end. The application connecting to the tunnel sees a plain TCP socket — it has no idea its traffic is being encrypted and transported over SSH.
A tunnel is just a pipe. One end is a port on a machine you can reach; the other end is a port on a machine your SSH server can reach — which may be completely firewalled from your own network.
| Type | Who opens the listening port | Traffic flows |
| Local (-L) | Your local machine | Local port → SSH server → destination |
| Remote (-R) | The SSH server | Remote port → SSH client → destination |
| Dynamic (-D) | Your local machine (SOCKS5 proxy) | Local SOCKS port → SSH server → any destination |
Prerequisites — sshd_config on the server
# Local and dynamic forwarding — no special server config required
# (AllowTcpForwarding defaults to yes)
# Remote forwarding — requires this in /etc/ssh/sshd_config:
AllowTcpForwarding yes # must be yes (default)
GatewayPorts no # "no" = tunnel binds to 127.0.0.1 on server only
# "yes" = tunnel binds to 0.0.0.0 (accessible externally)
# "clientspecified" = client chooses the bind address
02 — Local Port Forwarding (-L)
Local forwarding opens a port on your local machine. Connections to that local port are forwarded through the SSH server and on to a target host and port — which only the SSH server needs to be able to reach.
YOUR MACHINE SSH SERVER DESTINATION
┌─────────────────┐ ┌──────────────┐ ┌─────────────┐
│ │ │ │ │ │
│ app / browser │ │ │ │ target │
│ connects to │ ──SSH──► │ relays │ ──TCP──► │ service │
│ localhost:PORT │ encrypted│ traffic │ plain │ HOST:PORT │
│ │ │ │ │ │
└─────────────────┘ └──────────────┘ └─────────────┘
Syntax
ssh -L [bind_address:]local_port:destination_host:destination_port user@ssh_server
| Part | Meaning |
| bind_address | Interface on your local machine to listen on. Omit (or use localhost) to restrict to your own machine. Use 0.0.0.0 to share the tunnel with others on your LAN. |
| local_port | The port that opens on your local machine. You connect your app to this. |
| destination_host | Where the SSH server should send the traffic. Can be the SSH server itself (localhost) or any host it can reach. |
| destination_port | The port on the destination host. |
| user@ssh_server | The SSH server acting as the relay. |
Examples
# Access a remote PostgreSQL database as if it were local
# localhost:5432 → ssh_server → localhost:5432 (on the server)
ssh -L 5432:localhost:5432 user@db-server.example.com
# Access a database on a third host the SSH server can reach
# localhost:5432 → ssh_server → 192.168.1.50:5432
ssh -L 5432:192.168.1.50:5432 user@ssh-server.example.com
# Access an internal web app behind a firewall
# localhost:8080 → ssh_server → internal-app.local:80
ssh -L 8080:internal-app.local:80 user@bastion.example.com
# Use a non-conflicting local port (remote service is on 80, local 80 is taken)
ssh -L 9080:localhost:80 user@webserver.example.com
# Share the tunnel with your LAN (bind to all interfaces)
ssh -L 0.0.0.0:5432:localhost:5432 user@db-server.example.com
# Multiple tunnels in one SSH connection
ssh -L 5432:localhost:5432 \
-L 6379:localhost:6379 \
-L 8080:internal-web:80 \
user@bastion.example.com
Background (persistent) local tunnel
# -N = don't execute a remote command (tunnel only)
# -f = go to background after authentication
ssh -fN -L 5432:localhost:5432 user@db-server.example.com
# Find and kill a background tunnel
ps aux | grep ssh
kill
Then connect your app to the tunnel
# PostgreSQL client — connect to the local tunnel endpoint
psql -h 127.0.0.1 -p 5432 -U myuser mydatabase
# MySQL client through a tunnel
mysql -h 127.0.0.1 -P 3306 -u root -p
# Redis through a tunnel
redis-cli -h 127.0.0.1 -p 6379
# Browser — just open http://localhost:8080
✔ Local forwarding is the go-to pattern for securely accessing remote databases, admin panels, and internal services without exposing those services to the internet.
03 — Remote Port Forwarding (-R)
Remote forwarding is the mirror image of local forwarding. The SSH server opens a port — connections to that remote port are forwarded back through the SSH channel to your local machine (or any host your local machine can reach). This is how you make a service running behind NAT reachable from the internet.
YOUR MACHINE SSH SERVER REMOTE CLIENT
┌─────────────────┐ ┌──────────────┐ ┌─────────────┐
│ │ │ │ │ │
│ local service │ ◄──SSH── │ opens port │ ◄──TCP── │ someone │
│ on HOST:PORT │ encrypted│ REMOTE_PORT │ plain │ connects │
│ │ │ │ │ │
└─────────────────┘ └──────────────┘ └─────────────┘
Syntax
ssh -R [bind_address:]remote_port:destination_host:destination_port user@ssh_server
Examples
# Expose your local web server (port 3000) on the SSH server's port 9000
# Anyone who can reach ssh_server:9000 will hit your localhost:3000
ssh -R 9000:localhost:3000 user@public-server.example.com
# Expose a local development HTTPS server
ssh -R 8443:localhost:443 user@public-server.example.com
# Reverse SSH tunnel — expose your machine's SSH on the server's port 2222
# Useful for reaching a host behind NAT or a strict firewall
ssh -R 2222:localhost:22 user@public-server.example.com
# From the public server, reach your machine via the reverse tunnel:
# ssh -p 2222 localuser@localhost (run this on public-server)
# Expose a service on a third host your machine can reach
# public-server:9000 → your machine → 192.168.1.20:80
ssh -R 9000:192.168.1.20:80 user@public-server.example.com
Bind to all interfaces on the server (requires GatewayPorts)
# By default, the remote port binds to 127.0.0.1 on the SSH server
# — only processes on that server can connect to it.
# To allow external connections to the remote port:
# 1. Set in /etc/ssh/sshd_config on the server:
GatewayPorts yes # or: clientspecified
# 2. Specify the bind address explicitly in the -R flag:
ssh -R 0.0.0.0:9000:localhost:3000 user@public-server.example.com
# or with clientspecified:
ssh -R *:9000:localhost:3000 user@public-server.example.com
Persistent reverse tunnel (background)
# Background tunnel — reconnects are handled separately (see section 08)
ssh -fN -R 2222:localhost:22 user@public-server.example.com
# Verify the tunnel opened on the server
ssh user@public-server.example.com "ss -tlnp | grep 2222"
⚠ With GatewayPorts yes, the remote port is publicly accessible on the SSH server — anyone can connect to it. Use firewall rules on the server to restrict access to the forwarded port if needed.
04 — Dynamic Port Forwarding (-D)
Dynamic forwarding turns the SSH connection into a full SOCKS5 proxy. Instead of forwarding a single port to a single destination, the SSH server becomes a proxy — any application that speaks SOCKS5 can route its traffic through the tunnel to any destination the SSH server can reach.
YOUR MACHINE SSH SERVER INTERNET
┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
│ │ │ │ │ │
│ SOCKS5-aware │ │ │ │ any host │
│ app connects to │ ──SSH──► │ proxies to │ ──TCP──► │ any port │
│ localhost:1080 │ encrypted│ destination │ plain │ │
│ │ │ │ │ │
└──────────────────┘ └──────────────┘ └──────────────┘
Syntax
ssh -D [bind_address:]local_port user@ssh_server
Examples
# Open a SOCKS5 proxy on localhost:1080
ssh -D 1080 user@remote-server.example.com
# Background SOCKS proxy
ssh -fN -D 1080 user@remote-server.example.com
# Share the proxy on your LAN
ssh -fN -D 0.0.0.0:1080 user@remote-server.example.com
# Different local port to avoid conflicts
ssh -fN -D 8888 user@remote-server.example.com
Using the SOCKS5 proxy
# curl — route a single request through the proxy
curl --socks5-hostname localhost:1080 https://example.com
# curl — check what IP the server sees
curl --socks5-hostname localhost:1080 https://ifconfig.me
# wget
wget -e "use_proxy=yes" -e "http_proxy=socks5h://localhost:1080" https://example.com
# Firefox — Settings → Network → Manual proxy → SOCKS Host: 127.0.0.1 Port: 1080
# Chrome via command line
google-chrome --proxy-server="socks5://127.0.0.1:1080"
# Any app with SOCKS5 support — point it to 127.0.0.1:1080
Route ALL system traffic through the proxy (proxychains)
# Install proxychains
sudo apt install proxychains4
# /etc/proxychains4.conf — add at the bottom:
socks5 127.0.0.1 1080
# Prefix any command to route it through the tunnel
proxychains4 curl https://ifconfig.me
proxychains4 ssh user@internal-host
proxychains4 nmap -sT -Pn 192.168.1.0/24
05 — ProxyJump / Bastion Hosts (-J)
A jump host (bastion) is an intermediary SSH server you must pass through to reach an internal host. ProxyJump chains SSH connections transparently — your SSH client connects to the bastion, then uses that connection as a relay to the final target, all in one command.
YOUR MACHINE BASTION HOST INTERNAL HOST
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ │ │ │ │ │
│ ssh │─SSH──►│ relays │─SSH──►│ final │
│ client │ │ connection│ │ target │
│ │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
Single jump
# Reach internal-server via bastion in one command
ssh -J user@bastion.example.com user@192.168.1.50
# Different usernames on each hop
ssh -J bastion-user@bastion.example.com internal-user@192.168.1.50
# Custom port on the bastion
ssh -J user@bastion.example.com:2222 user@192.168.1.50
Multiple jumps (chain)
# Chain two bastion hosts to reach the final target
ssh -J user@bastion1.example.com,user@bastion2.internal user@final-host.internal
~/.ssh/config — cleaner multi-hop setup
# ~/.ssh/config
Host bastion
HostName bastion.example.com
User admin
IdentityFile ~/.ssh/id_ed25519_bastion
Host db-server
HostName 192.168.1.50
User dbadmin
ProxyJump bastion
IdentityFile ~/.ssh/id_ed25519_internal
Host app-server
HostName 192.168.1.60
User deploy
ProxyJump bastion
# Two-hop: your machine → bastion → internal-jump → final-host
Host deep-internal
HostName 10.10.10.20
User admin
ProxyJump bastion,app-server
# Now connecting is just:
ssh db-server
ssh deep-internal
Port forwarding through a jump host
# Local forward through a bastion to an internal database
ssh -J user@bastion.example.com \
-L 5432:192.168.1.50:5432 \
user@192.168.1.50
# Or using ~/.ssh/config — if db-server has ProxyJump set:
ssh -L 5432:localhost:5432 db-server
06 — ~/.ssh/config — Persistent Tunnel Definitions
Defining tunnels in ~/.ssh/config eliminates long command lines and makes tunnels repeatable. The LocalForward and RemoteForward directives mirror the -L and -R flags exactly.
~/.ssh/config directives for forwarding
# Local forward — access remote PostgreSQL locally
Host db-tunnel
HostName db-server.example.com
User admin
IdentityFile ~/.ssh/id_ed25519
LocalForward 5432 localhost:5432 # local:5432 → server:5432
LocalForward 6379 localhost:6379 # local:6379 → server:6379 (Redis)
ServerAliveInterval 60
ServerAliveCountMax 3
# Remote forward — expose local dev server on a public host
Host expose-dev
HostName public-server.example.com
User admin
IdentityFile ~/.ssh/id_ed25519
RemoteForward 9000 localhost:3000 # server:9000 → local:3000
ExitOnForwardFailure yes
# Dynamic SOCKS proxy
Host socks-proxy
HostName proxy-server.example.com
User admin
DynamicForward 1080
RequestTTY no
# Connect and establish all defined tunnels:
ssh db-tunnel # opens tunnels on 5432 and 6379 automatically
ssh -fN db-tunnel # background, no shell
# -fN is the cleanest way to establish a config-defined tunnel in the background
Useful connection options
| Option | Meaning |
| ServerAliveInterval 60 | Send a keepalive every 60 seconds — prevents idle tunnels from being dropped by firewalls or NAT. |
| ServerAliveCountMax 3 | Close the connection after 3 missed keepalives. |
| ExitOnForwardFailure yes | Abort the SSH connection if a requested tunnel port cannot be bound. Prevents silent tunnel failures. |
| RequestTTY no | Don't allocate a terminal — useful for tunnel-only connections. |
| Compression yes | Enable compression — helps on slow links for text-heavy traffic. |
| ControlMaster auto | Multiplex tunnels over an existing SSH connection to the same host. |
| ControlPath ~/.ssh/cm-%r@%h:%p | Socket path for connection multiplexing. |
| ControlPersist 10m | Keep the master connection alive 10 minutes after the last session closes. |
07 — Connection Multiplexing
SSH multiplexing reuses an existing connection for new sessions and tunnels — eliminating the authentication round-trip and the overhead of a second TCP handshake to the same server.
~/.ssh/config — enable multiplexing
Host *
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
# First connection: normal SSH, creates the master socket
ssh user@server.example.com
# Second connection: instant — reuses the existing socket, no re-auth
ssh user@server.example.com
# Open a tunnel over an existing connection — no extra auth round-trip
ssh -fN -L 5432:localhost:5432 user@server.example.com
# Check if a master connection is active
ssh -O check user@server.example.com
# Stop the master connection gracefully
ssh -O stop user@server.example.com
08 — Persistent & Auto-Reconnecting Tunnels
SSH tunnels drop when the network hiccups, the server reboots, or an idle timeout fires. For long-lived tunnels — especially reverse tunnels for remote access — you need automatic reconnection.
Option A — AutoSSH
# Install autossh
sudo apt install autossh # Debian / Ubuntu
sudo dnf install autossh # RHEL / Fedora
# AutoSSH monitors the tunnel and restarts it on failure
# AUTOSSH_GATETIME=0 prevents autossh from giving up on first failure
AUTOSSH_GATETIME=0 autossh -M 0 -fN \
-o "ServerAliveInterval 30" \
-o "ServerAliveCountMax 3" \
-L 5432:localhost:5432 \
user@db-server.example.com
# Reverse tunnel that auto-reconnects
AUTOSSH_GATETIME=0 autossh -M 0 -fN \
-o "ServerAliveInterval 30" \
-o "ServerAliveCountMax 3" \
-R 2222:localhost:22 \
user@public-server.example.com
Option B — systemd service (recommended for servers)
# /etc/systemd/system/ssh-tunnel-db.service
[Unit]
Description=SSH local tunnel to database server
After=network.target
Wants=network-online.target
[Service]
User=tunnel
Environment="AUTOSSH_GATETIME=0"
ExecStart=/usr/bin/autossh -M 0 -N \
-o "ServerAliveInterval=30" \
-o "ServerAliveCountMax=3" \
-o "ExitOnForwardFailure=yes" \
-o "StrictHostKeyChecking=accept-new" \
-i /home/tunnel/.ssh/id_ed25519 \
-L 5432:localhost:5432 \
tunnel@db-server.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
Option C — systemd service for a reverse tunnel
# /etc/systemd/system/ssh-reverse-tunnel.service
[Unit]
Description=Reverse SSH tunnel to public server
After=network-online.target
Wants=network-online.target
[Service]
User=tunnel
Environment="AUTOSSH_GATETIME=0"
ExecStart=/usr/bin/autossh -M 0 -N \
-o "ServerAliveInterval=30" \
-o "ServerAliveCountMax=3" \
-o "ExitOnForwardFailure=yes" \
-o "StrictHostKeyChecking=accept-new" \
-i /home/tunnel/.ssh/id_ed25519 \
-R 2222:localhost:22 \
tunnel@public-server.example.com
Restart=always
RestartSec=15
[Install]
WantedBy=multi-user.target
ℹ Create a dedicated system user (tunnel) with its own key pair and no shell (nologin) for running persistent tunnel services. Never run them as root or your personal user account.
09 — Real-World Scenarios
Scenario A — access a remote database securely
# Problem: PostgreSQL on db-server is not publicly exposed.
# You need to query it from your laptop.
# Solution: local tunnel
ssh -fN -L 5432:localhost:5432 admin@db-server.example.com
# Now connect your local psql / pgAdmin / DBeaver to:
# Host: 127.0.0.1 Port: 5432
psql -h 127.0.0.1 -U appuser -d mydb
Scenario B — share a local dev server with a colleague
# Problem: your dev server runs on localhost:3000 behind NAT.
# A colleague needs to review your work from their machine.
# Solution: remote tunnel to a shared public server
ssh -fN -R 9000:localhost:3000 admin@shared-server.example.com
# Tell your colleague: http://shared-server.example.com:9000
# (requires GatewayPorts yes on shared-server)
Scenario C — access an internal admin panel via bastion
# Problem: Grafana at internal-monitoring:3000 is only reachable
# from inside the company network, behind a bastion host.
# Solution: local tunnel through a jump host
ssh -J admin@bastion.company.com \
-fN -L 3000:internal-monitoring.local:3000 \
admin@internal-monitoring.local
# Open http://localhost:3000 in your browser
Scenario D — reach a host stuck behind a double NAT
# Problem: a Raspberry Pi or home server is behind two layers of NAT
# (ISP + home router). You can't port-forward at either level.
# Solution: persistent reverse tunnel from the Pi to a VPS
# Run on the Pi:
autossh -M 0 -fN \
-o "ServerAliveInterval=30" \
-R 2222:localhost:22 \
pi@your-vps.example.com
# SSH into your Pi from anywhere via the VPS:
ssh your-vps.example.com
# then on the VPS:
ssh -p 2222 pi@localhost
Scenario E — encrypted tunnel for a non-TLS service
# Problem: an old internal service talks plain TCP, no encryption.
# You need to reach it over the internet securely.
# Solution: wrap it in an SSH tunnel
ssh -fN -L 9999:legacy-service.internal:9999 admin@bastion.example.com
# Connect your app to localhost:9999 — traffic is encrypted over SSH
10 — Security Considerations
SSH tunnels are powerful — but that power cuts both ways. A tunnel that forwards a port into an internal network is an attack surface if not controlled carefully.
| Risk | Mitigation |
| Tunnel left open indefinitely | Set ClientAliveCountMax and ClientAliveInterval on the server to reap idle connections. |
| Remote port publicly exposed | Default GatewayPorts no binds remote tunnels to 127.0.0.1. Only change if you need external access, then add a firewall rule. |
| Unauthorised users opening tunnels | Set AllowTcpForwarding no globally in sshd_config and re-enable per user with a Match User block. |
| SOCKS proxy abused as open proxy | Bind dynamic forward to 127.0.0.1 only (default). Never bind to 0.0.0.0 on a shared machine. |
| Tunnel bypasses firewall controls | Audit outbound SSH rules. Legitimate tunnels should use dedicated keys and dedicated system users. |
| Private key compromise | Use per-tunnel key pairs with no passphrase only for automated service accounts. Rotate keys regularly. |
Restrict forwarding per user in sshd_config
# Disable forwarding globally
AllowTcpForwarding no
GatewayPorts no
# Re-enable only for specific users or groups
Match User tunnel-user
AllowTcpForwarding yes
GatewayPorts clientspecified
Match User sftp-only
ForceCommand internal-sftp
AllowTcpForwarding no
X11Forwarding no
11 — Troubleshooting
| Symptom | Cause & Fix |
| bind: Address already in use | Another process is on that local port. Pick a different local_port or kill the occupying process: ss -tlnp | grep PORT. |
| channel 3: open failed: connect failed | The SSH server cannot reach the destination host/port. Verify the destination is up and reachable from the server side. |
| Remote port not accessible externally | GatewayPorts is no on the server — remote tunnels bind to 127.0.0.1 only by default. Set GatewayPorts yes and bind to 0.0.0.0. |
| Tunnel drops after a few minutes idle | Firewall or NAT is killing idle connections. Add ServerAliveInterval 60 to ~/.ssh/config. |
| Permission denied opening tunnel | AllowTcpForwarding no on the server. Check sshd_config. |
| -fN: command not found or hangs | Authentication is failing (passphrase prompt with -f). Add the key to ssh-agent first: ssh-add ~/.ssh/id_ed25519. |
| autossh exits immediately | Set AUTOSSH_GATETIME=0 — without it, autossh gives up if the first connection attempt fails. |
| ProxyJump connection refused | The bastion cannot reach the final target. Test from the bastion directly: ssh user@internal-host. |
| SOCKS proxy connects but traffic doesn't flow | Application is not doing DNS through SOCKS — use socks5h (not socks5) in curl/apps to resolve DNS on the server side. |
Diagnostic commands
# See verbose tunnel negotiation
ssh -vvv -L 5432:localhost:5432 user@server
# Check what ports are listening locally
ss -tlnp | grep ssh
# Check the remote tunnel port is open on the server
ssh user@server "ss -tlnp | grep 2222"
# Verify traffic is flowing through the tunnel
# (run on local after opening the tunnel)
curl -v http://localhost:8080
# Test SOCKS proxy
curl --socks5-hostname localhost:1080 https://ifconfig.me
# Show all active SSH tunnel processes
ps aux | grep "ssh.*-[LRD]"
12 — Quick Reference
| Command | What it does |
| ssh -L 5432:localhost:5432 user@host | Local tunnel — access remote PostgreSQL on localhost:5432 |
| ssh -L 8080:internal:80 user@bastion | Local tunnel — reach an internal web app via bastion |
| ssh -fN -L 5432:localhost:5432 user@host | Same, but run in background (tunnel only, no shell) |
| ssh -R 9000:localhost:3000 user@host | Remote tunnel — expose local:3000 as server:9000 |
| ssh -R 2222:localhost:22 user@host | Reverse SSH — reach this machine via server port 2222 |
| ssh -fN -R 2222:localhost:22 user@host | Same, background |
| ssh -D 1080 user@host | SOCKS5 proxy on localhost:1080 |
| ssh -fN -D 1080 user@host | Same, background |
| ssh -J bastion user@internal | Jump through a bastion host |
| ssh -J b1,b2 user@final | Chain two bastion hops |
| ssh -L 5432:db:5432 -J bastion user@db | Local tunnel through a jump host |
| curl --socks5-hostname localhost:1080 URL | Route a curl request through SOCKS5 proxy |
| autossh -M 0 -fN -L ... user@host | Auto-reconnecting local tunnel |
| ssh -O check user@host | Check if a multiplexed master connection is active |
✔ The single most useful habit: put all your tunnel definitions in ~/.ssh/config with LocalForward, RemoteForward, ProxyJump, and ServerAliveInterval. Then opening any tunnel is just ssh -fN tunnel-name.