Why SSH Tunnels?#
SSH doesn’t just give you a remote shell. It can forward network traffic through an encrypted connection, letting you:
- Access a database or web UI that’s only available on a private network
- Expose a local development server to a remote machine
- Route your traffic through a remote host (SOCKS proxy)
- Hop through bastion/jump hosts to reach isolated systems
All of this works over a single encrypted SSH connection. No VPN setup, no firewall rule changes, no extra software.
Local Port Forwarding (-L)#
Local forwarding makes a remote service available on your local machine. Traffic flows:
your machine:local_port → SSH tunnel → remote_host:remote_portSyntax#
ssh -L local_port:destination:destination_port user@ssh_serverlocal_port— the port that opens on your machinedestination— the host to connect to (from the SSH server’s perspective)destination_port— the port on the destination hostssh_server— the machine you SSH into (the tunnel endpoint)
Example: Access a Remote Database#
A PostgreSQL database runs on db.internal port 5432, only accessible from jumpbox.example.com:
ssh -L 5432:db.internal:5432 user@jumpbox.example.comNow connect your local tools to localhost:5432 and they reach the remote database:
psql -h localhost -p 5432 -U myuser mydbExample: Access a Remote Web UI#
A monitoring dashboard runs on port 3000 inside a private network. You can reach server.internal via SSH:
ssh -L 8080:localhost:3000 user@server.internalOpen http://localhost:8080 in your browser. Here localhost in the forwarding spec means the SSH server itself — the dashboard is running on the same machine you’re SSH-ing into.
Binding to All Interfaces#
By default, the local port binds to 127.0.0.1 only. To make it available to other machines on your network:
ssh -L 0.0.0.0:8080:db.internal:5432 user@jumpbox.example.com0.0.0.0 exposes the tunnel to your entire local network. Use this only when you intend to share access.Remote Port Forwarding (-R)#
Remote forwarding is the reverse — it makes a local service available on the remote machine. Traffic flows:
remote_host:remote_port → SSH tunnel → your machine:local_portSyntax#
ssh -R remote_port:destination:destination_port user@ssh_serverExample: Expose a Local Dev Server#
You’re running a web app on localhost:3000 and want a colleague on a remote server to access it:
ssh -R 9090:localhost:3000 user@shared-server.example.comOn the remote server, curl http://localhost:9090 now reaches your local app.
Example: Give a Remote Server Access to a Local Service#
Your CI runner needs to reach a test database on your laptop:
ssh -R 5432:localhost:5432 user@ci-runner.example.comThe CI runner can now connect to localhost:5432 and hit your local PostgreSQL.
Allowing External Connections#
By default, remote forwarded ports bind to 127.0.0.1 on the remote host. To allow other machines to connect, the SSH server needs GatewayPorts yes in /etc/ssh/sshd_config:
# On the SSH server's /etc/ssh/sshd_config
GatewayPorts yesThen:
ssh -R 0.0.0.0:9090:localhost:3000 user@server.example.comDynamic Port Forwarding (-D)#
Dynamic forwarding creates a SOCKS proxy on your local machine. Any application that supports SOCKS can route its traffic through the SSH connection.
ssh -D 1080 user@proxy-server.example.comThis opens a SOCKS5 proxy on localhost:1080. Configure your browser or application to use it:
Browser Configuration#
Set your browser’s SOCKS proxy to localhost:1080. In Firefox:
- Settings → Network Settings → Manual proxy → SOCKS Host:
127.0.0.1, Port:1080
Command-Line Usage#
# curl through the SOCKS proxy
curl --socks5 localhost:1080 http://internal-site.example.com
# Use environment variable for applications that respect it
export ALL_PROXY=socks5://localhost:1080When to Use Dynamic Forwarding#
- Browsing internal websites from outside the network
- Routing traffic through a specific geographic location
- Accessing multiple internal services without setting up individual
-Ltunnels for each
Jump Hosts and ProxyJump#
When the target machine isn’t directly reachable — you need to hop through one or more intermediate hosts.
The Modern Way: ProxyJump (-J)#
ssh -J jumpbox.example.com user@target.internalThis connects to jumpbox.example.com first, then from there to target.internal. You authenticate to both hosts.
Multiple Hops#
Chain jump hosts with commas:
ssh -J jump1.example.com,jump2.internal user@target.internalSSH Config for Jump Hosts#
Put it in ~/.ssh/config so you don’t type it every time:
Host target
HostName target.internal
User admin
ProxyJump jumpbox.example.com
Host jumpbox
HostName jumpbox.example.com
User mikeNow just:
ssh targetCombining with Port Forwarding#
Forward a port through a jump host:
ssh -J jumpbox.example.com -L 5432:db.internal:5432 user@app-server.internalOr in ~/.ssh/config:
Host db-tunnel
HostName app-server.internal
User admin
ProxyJump jumpbox.example.com
LocalForward 5432 db.internal:5432Background and Persistent Tunnels#
Run a Tunnel in the Background#
Add -f (fork to background) and -N (no remote command):
ssh -f -N -L 5432:db.internal:5432 user@jumpbox.example.com-f— backgrounds the SSH process after authentication-N— don’t execute a remote command (just maintain the tunnel)
To close it later, find and kill the process:
ps aux | grep "ssh -f -N"
kill <pid>Persistent Tunnels with autossh#
autossh monitors the tunnel and restarts it if the connection drops:
# Install
sudo apt install autossh # Debian/Ubuntu
sudo dnf install autossh # Fedora/RHEL
# Run a persistent tunnel
autossh -M 0 -f -N -L 5432:db.internal:5432 user@jumpbox.example.com-M 0 disables the autossh monitoring port and relies on SSH’s built-in ServerAliveInterval instead. Add these to your ~/.ssh/config:
Host jumpbox
HostName jumpbox.example.com
ServerAliveInterval 30
ServerAliveCountMax 3This sends a keepalive every 30 seconds and reconnects after 3 missed responses.
Systemd Service for Persistent Tunnels#
For tunnels that should survive reboots:
# ~/.config/systemd/user/ssh-tunnel-db.service
[Unit]
Description=SSH tunnel to internal database
After=network-online.target
[Service]
ExecStart=/usr/bin/ssh -N -L 5432:db.internal:5432 user@jumpbox.example.com
Restart=always
RestartSec=10
[Install]
WantedBy=default.targetsystemctl --user daemon-reload
systemctl --user enable --now ssh-tunnel-db
systemctl --user status ssh-tunnel-dbSSH Config for Common Tunnels#
Put your frequent tunnels in ~/.ssh/config to avoid long command lines:
# Database access through jumpbox
Host db-tunnel
HostName jumpbox.example.com
User mike
LocalForward 5432 db.internal:5432
LocalForward 6379 redis.internal:6379
# SOCKS proxy through work VPN host
Host work-proxy
HostName vpn.work.example.com
User mike
DynamicForward 1080
# Dev server exposed to staging
Host expose-dev
HostName staging.example.com
User deploy
RemoteForward 9090 localhost:3000Then just:
ssh -f -N db-tunnel # Open database tunnels
ssh -f -N work-proxy # Start SOCKS proxy
ssh -f -N expose-dev # Expose local dev serverPractical Scenarios#
Accessing a Kubernetes Dashboard#
The dashboard is only accessible inside the cluster. You have SSH access to a node:
ssh -L 8443:kubernetes-dashboard.kube-system.svc:443 user@k8s-node.internalOpen https://localhost:8443 in your browser.
Debugging a Microservice Locally#
A service in staging talks to other services on private hostnames. Forward the dependencies:
ssh -L 5432:postgres.staging:5432 \
-L 6379:redis.staging:6379 \
-L 9200:elasticsearch.staging:9200 \
user@staging-bastion.example.comRun your service locally pointing at localhost for each dependency.
Secure Browsing on Public Wi-Fi#
Route all browser traffic through your home server:
ssh -D 1080 user@home-server.example.comConfigure your browser to use localhost:1080 as a SOCKS5 proxy. All traffic is encrypted through the SSH tunnel.
Accessing IoT Devices Behind NAT#
A Raspberry Pi at a remote site can’t accept incoming connections. Have it create a reverse tunnel to your server:
On the Pi:
autossh -M 0 -f -N -R 2222:localhost:22 user@your-server.example.comFrom anywhere, connect to the Pi through your server:
ssh -p 2222 pi@your-server.example.comTroubleshooting Tunnels#
“Address already in use”#
The local port is taken. Either kill the existing process or use a different port:
# Find what's using the port
ss -tlnp | grep :5432
# Pick a different local port
ssh -L 15432:db.internal:5432 user@jumpbox.example.com“Connection refused” on the forwarded port#
The destination service isn’t running or isn’t listening on the expected port. SSH into the intermediate host and check:
ssh user@jumpbox.example.com
ss -tlnp | grep 5432 # Is PostgreSQL actually listening?Tunnel connects but no data flows#
Check that the destination hostname resolves from the SSH server, not from your machine. db.internal needs to resolve on the jumpbox:
ssh user@jumpbox.example.com "host db.internal"“channel 3: open failed: administratively prohibited”#
The SSH server has AllowTcpForwarding no in its config. You need the server admin to enable it:
# /etc/ssh/sshd_config
AllowTcpForwarding yesSecurity Considerations#
- Tunnels bypass firewall rules. A local forward lets you reach services that firewalls intentionally restrict. Use responsibly and in accordance with your organization’s policies.
- Bind to localhost by default. Only bind to
0.0.0.0when you explicitly need other machines to use your tunnel. - Use key-based authentication. Tunnels often run unattended (background, autossh, systemd). Password auth doesn’t work well for this.
- Audit your tunnels. Forgotten tunnels leave open paths into private networks. Periodically check for stale SSH processes.
Quick Reference#
| Task | Command |
|---|---|
| Access remote service locally | ssh -L local:dest:port user@server |
| Expose local service remotely | ssh -R remote:dest:port user@server |
| SOCKS proxy | ssh -D 1080 user@server |
| Jump through bastion | ssh -J jump user@target |
| Background tunnel | ssh -f -N -L ... |
| Persistent tunnel | autossh -M 0 -f -N -L ... |
| Multiple forwards | ssh -L p1:h1:p1 -L p2:h2:p2 user@server |
| SSH Config Directive | Purpose |
|---|---|
LocalForward | -L equivalent |
RemoteForward | -R equivalent |
DynamicForward | -D equivalent |
ProxyJump | -J equivalent |
ServerAliveInterval | Keepalive interval (seconds) |
ServerAliveCountMax | Missed keepalives before disconnect |


