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.
Table of Contents
- 1. What SSH tunneling actually means technically
- 2. Local port forwarding with ssh -L
- 3. Remote port forwarding with ssh -R
- 4. Dynamic port forwarding and SOCKS proxy with ssh -D
- 5. Practical case: tunneling to a database only reachable from the server itself
- 6. autossh for permanently stable tunnels
- 7. SSH config: making tunnels persistent and reusable
- 8. Security considerations of tunnels left open
- 9. SSH tunneling variants in direct comparison
- 10. Summary
- 11. FAQ
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.