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 set GatewayPorts yes in /etc/ssh/sshd_config on 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 no on the server).
  • Restrict tunnel accounts with AllowTcpForwarding, PermitOpen, and ForceCommand in sshd_config or authorized_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.