SSH Tunneling and Port Forwarding in Practice
AI generated
$
/etc
Linux · SSH · Networking · DevOps
SSH Tunneling and Port Forwarding
in Practice

SSH tunneling turns a single encrypted connection into a secure channel for any TCP service that would otherwise only be reachable locally on a server. This article explains local, remote and dynamic port forwarding through concrete examples, shows a tunnel into a database only reachable from the server itself, covers autossh for permanently stable connections, and the security risks of tunnels left open.

13 min read ssh -L · ssh -R · ssh -D · autossh OpenSSH · SOCKS5 · Linux

1. What SSH tunneling actually means technically

SSH is usually reduced to the remote shell, but the underlying protocol is a generic, encrypted transport channel for arbitrary TCP connections. Over a single authenticated connection on port 22, the SSH client can multiplex several logical channels, including channels of type direct-tcpip and forwarded-tcpip. These exact channel types are the technical foundation of port forwarding: instead of carrying data for a shell session, the channel transports raw TCP payload for any application, invisible to everything between client and server.

From this arise three fundamentally different forwarding directions. Local port forwarding (-L) brings a remote service to you. Remote port forwarding (-R) exposes a local service on the remote server. Dynamic port forwarding (-D) turns the same connection into a full SOCKS5 proxy. All three use the same encrypted connection and the same authentication, but differ fundamentally in who is allowed to access whom.

2. Local port forwarding with ssh -L

Local forwarding is the most common variant: a port on your own machine is transparently piped through to a target port behind the SSH server. The syntax ssh -L local_port:target_host:target_port user@sshserver opens a listener on the local machine. Any connection to it is forwarded through the SSH server to target_host:target_port, where target_host is resolved from the perspective of the SSH server, not from the client machine. This is where many beginners stumble: localhost as the target host refers to the server, not to your own laptop.

A typical use case is accessing an internal admin interface that only listens on the server host itself, for example a monitoring dashboard on port 9090. Using -N suppresses the remote shell entirely and uses the connection purely as a tunnel, and -f sends the process into the background. This combination is the foundation of almost every production tunnel setup.


# Local forwarding: reach an internal dashboard bound to the server itself
$ ssh -L 9090:127.0.0.1:9090 -N -f deploy@app-server.example.com

# Verify: local listener now exists on the client
$ ss -tlnp | grep 9090
LISTEN 0 128 127.0.0.1:9090 0.0.0.0:*  users:(("ssh",pid=41210,fd=6))

# Access the internal dashboard through the tunnel
$ curl -s http://127.0.0.1:9090/metrics | head -3

# Close the backgrounded tunnel again
$ pkill -f "9090:127.0.0.1:9090"

3. Remote port forwarding with ssh -R

Remote port forwarding reverses the direction: a service running locally on your machine is exposed to the outside through the SSH server. The syntax ssh -R remote_port:localhost:local_port user@sshserver opens a listener on the remote server that tunnels connections back to the local target port. By default, the server binds this listener only to 127.0.0.1, so it is reachable only from the server itself, not from outside. Only the server option GatewayPorts changes this behavior.

This is practically relevant, for example, to let a locally running webhook receiver be tested briefly through a reachable server during development, without relying on a third-party public tunneling service. Another scenario: support access to a device behind a restrictive firewall, where the device itself opens an outbound connection to a reachable jump host and offers a reverse connection through it, with no inbound firewall rule needed on the target device at all.

Concretely, the setup looks like this: ssh -R 8443:localhost:3000 -N -f deploy@jump-host.example.com opens a listener bound to 127.0.0.1:8443 on the jump host, visible with ss -tlnp | grep 8443 directly on the server. In the support scenario the roles flip: the device itself runs ssh -R 2222:localhost:22 -N -f support@jump-host.example.com and makes its own SSH shell reachable through the jump host via ssh -p 2222 localhost, with no inbound connection to the device itself needed at all.

4. Dynamic port forwarding and SOCKS proxy with ssh -D

Once more than one or two targets are involved, setting up individual local forwards quickly becomes unwieldy. Dynamic port forwarding solves this by turning the SSH connection into a full SOCKS5 proxy. The command ssh -D 1080 -N -f user@sshserver opens local port 1080 as a SOCKS5 endpoint. Any application that supports SOCKS5 can reach arbitrary targets routable from the SSH server through it, without needing a dedicated -L entry for every target.

In practice, browsers, curl, or entire command line toolchains via proxychains are configured so their traffic runs through the SOCKS proxy. This makes it possible to reach a whole internal subnet that is only routed from the jump host, for example to test several internal web applications in the same VPC segment without setting up a separate tunnel for each one. Important: DNS resolution runs locally by default, not through the proxy, unless you explicitly enable --socks5-hostname or the corresponding browser setting for remote DNS.


# Dynamic forwarding: turn the SSH connection into a SOCKS5 proxy
$ ssh -D 1080 -N -f deploy@jump-host.example.com

# Route a single curl request through the proxy, resolving DNS remotely
$ curl --socks5-hostname 127.0.0.1:1080 http://internal-app.local/health

# Route an arbitrary CLI tool through the same proxy via proxychains
$ echo "socks5 127.0.0.1 1080" >> /etc/proxychains.conf
$ proxychains mysql -h db-internal.local -u reporting -p

# Verify the proxy listener is up
$ ss -tlnp | grep 1080

5. Practical case: tunneling to a database only reachable from the server itself

A very common scenario in Magento and PHP deployments: MySQL or MariaDB runs on the application server but is deliberately bound only to 127.0.0.1, ruling out any direct access from the network from the outset. For reporting, migration debugging, or connecting a GUI client such as DBeaver or Sequel Ace, you still need access from your own machine. This is exactly what local port forwarding is the right tool for, without ever making the database reachable over the public network.

The tunnel ssh -L 3307:127.0.0.1:3306 -N -f deploy@app-server.example.com binds local port 3307, because 3306 on your own machine is often already occupied by a local MySQL instance. The GUI client then simply connects to 127.0.0.1:3307 as if the database were installed locally. The SSH server only ever sees encrypted SSH traffic on port 22, never unencrypted MySQL traffic on the wire to the outside, which makes this route considerably safer for sensitive production data than a directly exposed database port.


#!/usr/bin/env bash
# db-tunnel.sh: open a persistent local tunnel to the app server's database
set -euo pipefail

REMOTE_HOST="app-server.example.com"
REMOTE_USER="deploy"
LOCAL_PORT=3307
DB_PORT=3306

# Skip if a tunnel on this port is already running
if ss -tln | grep -q ":${LOCAL_PORT} "; then
  echo "[INFO] Tunnel on port ${LOCAL_PORT} already active"
  exit 0
fi

autossh -M 0 -f -N \
  -o "ServerAliveInterval=30" \
  -o "ServerAliveCountMax=3" \
  -o "ExitOnForwardFailure=yes" \
  -L "${LOCAL_PORT}:127.0.0.1:${DB_PORT}" \
  "${REMOTE_USER}@${REMOTE_HOST}"

echo "[OK] Database reachable at 127.0.0.1:${LOCAL_PORT}"
mysql -h 127.0.0.1 -P "${LOCAL_PORT}" -u reporting -p -e "SELECT 1"

6. autossh for permanently stable tunnels

A manually started tunnel survives neither a WiFi switch, nor a brief network outage, nor a server reboot. This is exactly the problem autossh solves: it starts the actual ssh process, continuously monitors the connection, and automatically rebuilds it after a disconnect, without manual intervention. Combined with the options ServerAliveInterval and ServerAliveCountMax, SSH itself detects dead connections reliably, even before a TCP timeout would ever kick in, and signals autossh that a restart is needed.

The -M 0 option disables autossh's older monitoring port mechanism in favor of SSH's own keepalive check, which is the more robust approach on modern OpenSSH versions and does not require an additional open port. For permanent operation, such a tunnel belongs in a systemd unit rather than a manually started background session: only that way does it also survive a server reboot automatically, and can be monitored with systemctl status like any other service and traced through the journal in case of failure.

7. SSH config: making tunnels persistent and reusable

Long ssh command lines with several options are error-prone and poorly reusable. The file ~/.ssh/config solves this by defining host aliases with preconfigured options. A host block can directly contain LocalForward, RemoteForward and DynamicForward, so a simple ssh db-tunnel is enough to build exactly the same tunnel as a long command line would. This reduces typos and makes tunnel definitions versionable when the config file is part of a dotfiles repository.

The option ExitOnForwardFailure=yes is decisive for automation: without it, SSH starts successfully even if the port forwarding itself fails, for example because the local port is already in use, and scripts wrongly assume a working tunnel. With this option, the connection aborts immediately if the forwarding cannot be established, surfacing failures early in CI pipelines and deploy scripts instead of silently swallowing them.


# ~/.ssh/config: reusable tunnel definitions as host aliases

Host db-tunnel
  HostName app-server.example.com
  User deploy
  LocalForward 3307 127.0.0.1:3306
  ServerAliveInterval 30
  ServerAliveCountMax 3
  ExitOnForwardFailure yes
  # no shell needed, tunnel only
  RequestTTY no
  RemoteCommand none

Host webhook-relay
  HostName jump-host.example.com
  User deploy
  RemoteForward 8443 localhost:3000
  ExitOnForwardFailure yes

Host socks-proxy
  HostName jump-host.example.com
  User deploy
  DynamicForward 1080
  ServerAliveInterval 30

8. Security considerations of tunnels left open

An SSH tunnel is only as secure as the underlying key and the server's configuration. By default, the server option GatewayPorts no is active, meaning remote forwards bind only to 127.0.0.1 and are not reachable from outside. If this option is accidentally set to yes, every remote forward potentially exposes the forwarded service to the entire network the SSH server sits in, often without the user who opened the tunnel even being aware of it.

A second, frequently overlooked risk: anyone who only wants to grant tunnel access should deploy a dedicated SSH key with restricted options in authorized_keys, for example command="echo restricted",no-pty,permitopen="127.0.0.1:3306". This way the key can only tunnel to the named target, but cannot open a shell or reach any other arbitrary ports. Forgotten, permanently open tunnels are also a monitoring concern: with ss -tlnp | grep ssh and regular review, orphaned tunnels can be identified before they become unnoticed access paths into production systems.


{
  "audit_run": "2026-07-12T07:40:00+02:00",
  "host": "app-server.example.com",
  "open_ssh_forward_listeners": [
    {
      "local_bind": "127.0.0.1:8443",
      "type": "remote_forward",
      "owning_pid": 9021,
      "connected_since": "2026-06-02T11:12:04+02:00",
      "flag": "stale_over_30_days"
    },
    {
      "local_bind": "127.0.0.1:3307",
      "type": "local_forward",
      "owning_pid": 41210,
      "connected_since": "2026-07-12T06:58:11+02:00",
      "flag": "active_expected"
    }
  ],
  "recommended_action": "terminate stale_over_30_days entries and rotate the associated key"
}

9. SSH tunneling variants in direct comparison

Whether local, remote or dynamic forwarding is the right choice depends on the direction of access and the number of targets. Just as important is the choice between a fragile ad hoc setup and a robust, permanent pattern, especially for database tunnels that are needed constantly in day-to-day work.

Scenario Unsafe / fragile approach Recommended SSH tunneling pattern Benefit
Remote database access Bind the database port to 0.0.0.0 and open it in the firewall ssh -L tunnel to a database on 127.0.0.1 Database stays completely unreachable from outside
Keeping a tunnel running permanently Leave a terminal with ssh -L open manually autossh with ServerAliveInterval as a systemd service Automatic reconnect after network outage or reboot
Testing an internal web service Create a firewall rule for public access ssh -R reverse tunnel only for the test period No permanently open port on the target system
Reaching several internal applications A separate tunnel or VPN client for every application ssh -D dynamic SOCKS5 proxy for all targets A single tunnel for an arbitrary number of targets
Restricting tunnel access Full access via your normal personal SSH key Dedicated key with permitopen and no-pty in authorized_keys Key cannot be misused for shell access

Mironsoft

Server access, network security and deployment infrastructure

Secure access to internal services without open ports?

We set up SSH tunnel configurations with autossh and systemd, restrict tunnel keys to the bare minimum, and make sure your databases and internal services never need to be directly exposed.

Tunnel setup

Local, remote and dynamic forwarding managed cleanly through ~/.ssh/config

Persistence

autossh as a systemd service, automatically surviving reboots and network outages

Access hardening

Restricted tunnel keys with permitopen and regular monitoring of open tunnels

10. Summary

SSH tunneling and port forwarding solve three different access problems on the same encrypted foundation. Local forwarding (-L) brings a remote service to you, for example a database deliberately bound only to 127.0.0.1 on the server. Remote forwarding (-R) exposes a local service through the server, without inbound firewall rules on the actual target device. Dynamic forwarding (-D) bundles an arbitrary number of targets behind a single SOCKS5 proxy.

For production use, a manually started tunnel is not enough. autossh with ServerAliveInterval, wrapped in a systemd unit, keeps tunnels permanently stable and automatically survives network outages and reboots. Just as important is the security side: GatewayPorts no, restricted tunnel keys with permitopen, and regular monitoring of open forwards prevent forgotten tunnels from becoming unnoticed access paths into production systems.

SSH Tunneling and Port Forwarding: The Key Points at a Glance

Local vs. remote

ssh -L brings a remote service to you, ssh -R exposes a local service through the server.

Dynamic with SOCKS

ssh -D builds a SOCKS5 proxy for an arbitrary number of targets, without a separate tunnel for each one.

Database tunnel in practice

ssh -L 3307:127.0.0.1:3306 reaches a database that only listens internally on the server, without ever exposing it publicly.

Persistence & security

autossh as a systemd service for stability, permitopen keys and monitoring against forgotten open tunnels.

11. FAQ: SSH Tunneling and Port Forwarding

1Difference between local and remote forwarding?
ssh -L brings a service behind the server to you. ssh -R exposes a local service through the server instead.
2How does ssh -D work?
Opens a local SOCKS5 proxy port. Any SOCKS5-capable application reaches arbitrary targets behind the SSH server through it, without separate -L entries.
3Why does my tunnel die unnoticed?
Network changes or NAT timeouts end the TCP connection without SSH noticing right away. ServerAliveInterval detects it actively, but a manual tunnel does not rebuild itself afterward.
4What does autossh add over a custom loop?
autossh uses active SSH keepalives and only restarts on a real outage. Custom restart loops often detect dead connections only after significant delay.
5How do I tunnel to an internal database?
ssh -L 3307:127.0.0.1:3306 -N -f user@server. The client connects to 127.0.0.1:3307, only encrypted SSH traffic is visible on the outside.
6What does ssh -N stand for?
Suppresses a remote command or shell, the connection serves purely as a tunnel. Not strictly required, but makes the purpose explicit.
7Why is GatewayPorts off by default?
GatewayPorts no binds remote forwards only to 127.0.0.1 on the server. Without this restriction, every reverse tunnel would be visible across the whole server network.
8How do I find open tunnels?
ss -tlnp | grep ssh lists active forward listeners with PID on client and server. Regular review uncovers forgotten tunnels.
9Can multiple forwards run at once?
Yes, several -L, -R and -D options can be combined in one invocation or one host block of ~/.ssh/config.
10Is an SSH tunnel the same as a VPN?
No. A VPN usually routes all traffic at the IP level, an SSH tunnel forwards specific connections or SOCKS5 traffic only.