Skip to main content

SSH Tunnels and Port Forwarding

·1459 words·7 mins
Linux Learning Lab
Author
Linux Learning Lab
Writing about code, tools, and workflows.
Table of Contents
linux-networking - This article is part of a series.
Part 4: This Article

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_port

Syntax
#

ssh -L local_port:destination:destination_port user@ssh_server
  • local_port — the port that opens on your machine
  • destination — the host to connect to (from the SSH server’s perspective)
  • destination_port — the port on the destination host
  • ssh_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.com

Now connect your local tools to localhost:5432 and they reach the remote database:

psql -h localhost -p 5432 -U myuser mydb

Example: 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.internal

Open 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.com
Caution: Binding to 0.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_port

Syntax
#

ssh -R remote_port:destination:destination_port user@ssh_server

Example: 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.com

On 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.com

The 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 yes

Then:

ssh -R 0.0.0.0:9090:localhost:3000 user@server.example.com

Dynamic 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.com

This 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:1080

When 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 -L tunnels 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.internal

This 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.internal

SSH 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 mike

Now just:

ssh target

Combining with Port Forwarding
#

Forward a port through a jump host:

ssh -J jumpbox.example.com -L 5432:db.internal:5432 user@app-server.internal

Or in ~/.ssh/config:

Host db-tunnel
    HostName app-server.internal
    User admin
    ProxyJump jumpbox.example.com
    LocalForward 5432 db.internal:5432

Background 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 3

This 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.target
systemctl --user daemon-reload
systemctl --user enable --now ssh-tunnel-db
systemctl --user status ssh-tunnel-db

SSH 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:3000

Then 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 server

Practical 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.internal

Open 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.com

Run 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.com

Configure 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.com

From anywhere, connect to the Pi through your server:

ssh -p 2222 pi@your-server.example.com

Troubleshooting 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 yes

Security 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.0 when 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
#

TaskCommand
Access remote service locallyssh -L local:dest:port user@server
Expose local service remotelyssh -R remote:dest:port user@server
SOCKS proxyssh -D 1080 user@server
Jump through bastionssh -J jump user@target
Background tunnelssh -f -N -L ...
Persistent tunnelautossh -M 0 -f -N -L ...
Multiple forwardsssh -L p1:h1:p1 -L p2:h2:p2 user@server
SSH Config DirectivePurpose
LocalForward-L equivalent
RemoteForward-R equivalent
DynamicForward-D equivalent
ProxyJump-J equivalent
ServerAliveIntervalKeepalive interval (seconds)
ServerAliveCountMaxMissed keepalives before disconnect
linux-networking - This article is part of a series.
Part 4: This Article