SSH Tunneling
scp and rsync used an SSH connection to move files, but SSH isn't limited to that — once an encrypted channel is established, any other TCP traffic can be routed through it as well. This capability is called SSH tunneling (or port forwarding), and it's typically used to securely reach services that are only accessible from an internal network — a database bound only to the local network, for instance — from the outside.
Why a Tunnel Is Needed: a Common Scenario
In many production architectures, a database or an internal admin panel is deliberately closed off from the outside network, reachable only from the server itself or an internal network (127.0.0.1 or 10.20.0.0/24, for example) — a sound security decision. But an administrator still occasionally needs to reach that internal service from their own machine — connecting to PostgreSQL through a GUI client, say.
There are two unappealing options here: open the service to the outside network (weakening security) or log into the server over SSH every time and work entirely inside a terminal (losing the ability to use GUI tools). SSH tunneling offers a third, better path: making the remote port appear available on the local machine through the existing SSH connection — the service itself is never exposed externally; only your SSH session opens a "path" to it.
Local Forwarding (-L): Exposing a Remote Port Locally
The most commonly used tunnel type. Syntax:
For example, connecting to PostgreSQL listening only on 127.0.0.1:5432 on db-server:
Once this command is running, any request sent to localhost:5432 on your local machine is automatically routed through the SSH tunnel to db-server's 127.0.0.1:5432. From another terminal:
target_host here is not the SSH server itself, but the address as seen from the SSH server's point of view. If the target service runs on the SSH server itself, that's 127.0.0.1; if it's on another internal host reachable through the SSH server, that host's internal IP is used instead:
Here, bastion-server acts purely as a "waypoint," while the actual database lives at 10.20.0.30 — showing that a tunnel can reach any host visible from the bastion, not just the bastion itself.
Running a Tunnel in the Background
To avoid tying up a terminal:
-f— moves the SSH connection to the background once authentication succeeds;-N— means no remote command will be run (the connection exists purely for port forwarding; there's no need to open a shell).
Warning
ssh -f -N returns success and backgrounds itself even if the forward never bound — for instance when the local port is already in use. You're then left with a live SSH connection but no working tunnel, and the failure only surfaces later as a confusing "connection refused" on the local port. Add -o ExitOnForwardFailure=yes so the backgrounded ssh exits immediately when the forward can't be set up, turning a silent failure into an obvious one:
Stopping the tunnel requires finding and killing the corresponding ssh process:
Remote Forwarding (-R): Exposing a Local Service Remotely
The direction is reversed: a service running on your local machine becomes reachable from the remote server.
This makes a service running on port 3000 on your local machine "visible" on public-server's port 8080 — anyone connected to public-server (or the server itself) can reach your local port 3000 through localhost:8080.
A typical use case: temporarily exposing a web application running locally in development to the outside world — for testing a webhook, for instance — without opening anything on your own network.
Warning
By default, sshd_config sets GatewayPorts no, meaning the remote forward is only reachable from public-server itself (127.0.0.1), not from the outside network. Opening this up to the whole network (GatewayPorts yes) requires careful thought about the security implications, since it can make your local service reachable from the entire internet.
Dynamic Forwarding (-D): a SOCKS Proxy
Where local and remote forwarding each target one specific port, dynamic forwarding creates an entire SOCKS proxy — through which any number of services, without being individually defined in advance, become reachable from the SSH server's point of view:
Setting a browser or other application's network settings to use 127.0.0.1:1080 as a SOCKS5 proxy routes all of that application's network requests through bastion-server, as if you were making the requests from that server yourself. This is more convenient than writing a separate -L for each service when navigating several internally-only web services (admin panels, monitoring dashboards) one after another.
Comparing the Tunnel Types
| Flag | Direction | Typical use |
|---|---|---|
-L |
Local port → remote address | Reaching an internal DB or admin panel from a local machine |
-R |
Remote port → local address | Temporarily exposing a locally running service to the outside |
-D |
SOCKS proxy | Reaching several internal services through one shared proxy, without listing each one |
Practical Scenario: Reaching an Internal-Only Admin Panel
Problem: a monitoring dashboard at monitoring.internal:9090 is only visible from the server's internal network (10.20.0.0/24), but needs to be reached from a browser on a personal laptop.
Step 1 — identify an existing access path: bastion-server is reachable over SSH and connected to the internal network.
Step 2 — open a local forwarding tunnel:
Step 3 — confirm the tunnel is working:
The local port 9090 is shown being held by the ssh process itself.
Step 4 — check it in a browser: navigating to http://127.0.0.1:9090 should open the dashboard — the browser is talking to the local SSH tunnel, not directly to monitoring.internal.
Step 5 — close the tunnel once done:
Step 6 — result and documentation: the monitoring service was never exposed to the outside network; access was made only through a temporary, explicitly initiated SSH tunnel.
Common Mistakes
Confusing the Three Parts of the -L Syntax
In local_port:target_host:target_port, forgetting that target_host is usually not the SSH server itself but a host visible from the SSH server's perspective — 127.0.0.1 is often the right value where the tunnel's own destination address gets written instead.
Forgetting How to Stop a Backgrounded Tunnel
A tunnel started with -f keeps running even after the terminal is closed. Not knowing how to find and stop the corresponding ssh process leads to unused, forgotten tunnels piling up over time.
Expecting Remote Forwarding to Open to the Whole Network by Default
By default, a remote forward is only reachable from the server itself. Making it reachable from outside requires a separate, deliberate configuration change.
Treating a Tunneled Service as "Secure Enough" and Forgetting About It Long-Term
The tunnel itself is a secure channel, but if the service behind it has weak authentication of its own (a default password, for instance), the tunnel hides the problem without solving it.
Exercises
- Run a simple HTTP service bound only to
127.0.0.1on your lab server (for example,python3 -m http.server 8000 --bind 127.0.0.1), then open it in your local browser using-L. - Run the tunnel above with
-f -N, find the process withps aux, and stop it withkill. - Open a SOCKS tunnel with
-D 1080and test it in a browser's proxy settings, connecting to something on the internal network. - Deliberately point
-Lat a nonexistent target host and observe the resulting error.
Verification criterion: for exercise 1, the service must remain unreachable via a direct connection to the server's own IP address (confirming it's still bound only to 127.0.0.1), while it opens correctly through http://127.0.0.1:<local_port> on your machine via the tunnel.