SSH Tunneling and Port Forwarding
SSH port forwarding turns any SSH connection into an encrypted tunnel for
arbitrary TCP traffic. It is the fastest way to secure a single service without
deploying a full VPN. This guide covers all three forwarding modes -- local,
remote, and dynamic -- plus jump hosts, persistent tunnels with autossh, and
systemd service units for production reliability.
Part of the VPN and SSH guide series. See also: rsync Over SSH | SSHFS and Remote File Access | WireGuard Setup
Local Port Forwarding (-L)
Local forwarding binds a port on your local machine and forwards traffic through the SSH server to a destination host.
ssh -L [bind_address:]local_port:destination:dest_port user@ssh-server
Example: Access a remote database
The PostgreSQL server at db.internal (port 5432) is only reachable from the
bastion host jump.example.com:
ssh -L 5432:db.internal:5432 [email protected] -N
Now connect your local client to localhost:5432 and the traffic travels
encrypted to db.internal through the bastion.
| Flag | Meaning |
|---|---|
-L |
Local forward |
-N |
Do not execute a remote command (tunnel only) |
-f |
Fork to background after authentication |
Combine them for a background tunnel:
ssh -fNL 5432:db.internal:5432 [email protected]
Remote Port Forwarding (-R)
Remote forwarding binds a port on the remote (SSH server) side and forwards traffic back to the local machine or another host reachable from it.
ssh -R [bind_address:]remote_port:destination:dest_port user@ssh-server
Example: Expose a local web app to a remote server
You are developing on localhost:3000 and want it reachable on the server's
port 8080:
ssh -R 8080:localhost:3000 [email protected] -N
On the server, curl http://localhost:8080 now hits your development machine.
Security note: By default sshd only binds remote forwards to
127.0.0.1. To allow binding to all interfaces setGatewayPorts yesin/etc/ssh/sshd_configon the server (use with caution).
Dynamic Port Forwarding (-D) -- SOCKS Proxy
Dynamic forwarding creates a local SOCKS5 proxy. Any application that supports SOCKS (browsers, curl, etc.) can route traffic through the SSH server.
ssh -D 1080 [email protected] -N
Configure your browser or system proxy to use SOCKS5 localhost:1080. All DNS
lookups and TCP connections are now performed from the remote server's
perspective.
Using curl through the SOCKS proxy
curl --socks5-hostname localhost:1080 https://ifconfig.me
This is the quickest way to browse the internet "as if" you were on the remote network -- no VPN software required.
ProxyJump (-J) -- Bastion / Jump Hosts
ProxyJump (OpenSSH 7.3+) chains SSH connections through one or more bastion
hosts without setting up manual port forwards.
ssh -J bastion.example.com user@internal-server
Multiple hops:
ssh -J bastion1,bastion2 user@deep-internal
Equivalent ~/.ssh/config entry:
Host internal-server
HostName 10.0.0.50
User admin
ProxyJump bastion.example.com
This is cleaner and more secure than the older ProxyCommand with nc (netcat).
SSH Config for Reusable Tunnels
Store frequently used tunnels in ~/.ssh/config:
Host db-tunnel
HostName jump.example.com
User admin
LocalForward 5432 db.internal:5432
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 30
ServerAliveCountMax 3
Then simply run:
ssh -fN db-tunnel
ServerAliveInterval and ServerAliveCountMax detect dead connections: the
client sends a keepalive every 30 seconds and disconnects after 3 missed
replies (90 seconds).
Persistent Tunnels with autossh
autossh monitors an SSH tunnel and restarts it automatically if it drops.
sudo apt install autossh # Debian/Ubuntu
sudo dnf install autossh # Fedora/RHEL
Usage:
autossh -M 0 -fN -L 5432:db.internal:5432 [email protected] \
-o "ServerAliveInterval=30" -o "ServerAliveCountMax=3"
-M 0 disables autossh's own monitoring port and relies on the SSH keepalive
mechanism instead, which is simpler and works through firewalls.
systemd Service for Tunnel Persistence
For production, wrap the tunnel in a systemd unit. Create
/etc/systemd/system/ssh-tunnel-db.service:
[Unit]
Description=SSH tunnel to db.internal
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/ssh -NL 5432:db.internal:5432 [email protected] \
-o "ServerAliveInterval=30" -o "ServerAliveCountMax=3" \
-o "ExitOnForwardFailure=yes" \
-i /home/admin/.ssh/id_ed25519
Restart=always
RestartSec=10
User=admin
[Install]
WantedBy=multi-user.target
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now ssh-tunnel-db
Check status:
sudo systemctl status ssh-tunnel-db
journalctl -u ssh-tunnel-db -f
ExitOnForwardFailure=yes ensures SSH exits if the local port is already in use
(e.g. a stale process), allowing systemd's Restart=always to retry cleanly.
Security Hardening
- Use key-based authentication only (
PasswordAuthentication noon the server). - Restrict tunnel accounts with
AllowTcpForwarding,PermitOpen, andForceCommandinsshd_configorauthorized_keys:
# /etc/ssh/sshd_config (per-user override)
Match User tunnel-user
AllowTcpForwarding yes
PermitOpen db.internal:5432
ForceCommand /bin/false
X11Forwarding no
This allows the user to create the specific tunnel but prevents shell access and any other forwarding target.
Troubleshooting
| Symptom | Cause / Fix |
|---|---|
bind: Address already in use |
Another process holds the local port; kill it or choose a different port. |
| Tunnel connects but traffic times out | Firewall between SSH server and destination; verify with nc -zv from the server. |
| Tunnel drops after idle period | Add ServerAliveInterval and ServerAliveCountMax. |
channel N: open failed: connect failed |
Destination host/port unreachable from the SSH server. |
Return to the VPN and SSH hub for more guides, or read about rsync Over SSH.