SSH Tunneling & Port Forwarding
SSH tunneling is a way to send the traffic of another program through an encrypted SSH connection. Instead of using SSH only to get a shell on a remote machine, you use it as a secure pipe between two ports. This lets you reach services that are locked away on a remote server (like a database that only listens on localhost) without exposing them to the public internet. It is one of the most useful and most misunderstood tools in a DevOps engineer’s kit.
A tunnel here just means: “take a port on one machine, and make it behave as if it were a port on another machine.” A port is a numbered door on a server where a specific service listens (for example, PostgreSQL listens on port 5432). SSH can carry the traffic for that door across the encrypted connection.
Why tunnels matter
Many important services are deliberately bound to localhost (also written 127.0.0.1), meaning they only accept connections coming from the same machine. A database, an admin dashboard, or a metrics endpoint is often configured this way on purpose so the outside world cannot touch it. The problem: you are on the outside world too.
A tunnel solves this. You already have a trusted, encrypted SSH login to the server. SSH can forward a port through that same login, so the locked-down service becomes reachable from your laptop, while staying invisible to everyone else.
There are two main directions:
| Type | Flag | Direction | Use it when |
|---|---|---|---|
| Local forwarding | -L | Your machine -> remote service | You want to reach a service that lives on (or near) the remote server |
| Remote forwarding | -R | Remote machine -> your local service | You want the remote server to reach a service running on your laptop |
Local forwarding (-L)
Local forwarding opens a port on your machine. Anything that connects to that local port is sent through SSH and out to a destination chosen on the remote side.
The syntax is:
ssh -L LOCAL_PORT:DESTINATION_HOST:DESTINATION_PORT user@server
Read it as: “On my machine, open LOCAL_PORT. Forward its traffic through server, then on to DESTINATION_HOST:DESTINATION_PORT.”
Example: reach a remote database bound to localhost
Say your Ubuntu server runs PostgreSQL, and /etc/postgresql/16/main/postgresql.conf has listen_addresses = 'localhost'. The database is on port 5432 but only accepts local connections. You want to query it from your laptop with a GUI tool.
ssh -L 5432:localhost:5432 [email protected]
While that SSH session stays open, your laptop’s port 5432 is wired straight to the server’s PostgreSQL. Point your database client at localhost:5432 and you are talking to the remote database over an encrypted channel.
The
localhostin5432:localhost:5432is evaluated on the remote server, not on your laptop. That is why the database does not need to be exposed publicly — SSH connects to it locally, from the server’s own point of view.
Run a tunnel in the background
If you only want the tunnel and not an interactive shell, add -f (go to background) and -N (do not run a remote command):
ssh -fNL 5432:localhost:5432 [email protected]
Output:
It prints nothing and returns you to your prompt. The tunnel is now running quietly. To find and stop it later:
pgrep -af "ssh -fNL 5432"
Output:
48213 ssh -fNL 5432:localhost:5432 [email protected]
kill 48213
When to use local forwarding (and when not)
- Use it to reach internal services: databases, Redis, a private admin UI, a service running only on a server inside a private network the SSH host can see.
- Do not use it as a permanent production link. Tunnels die when the SSH session drops. For long-lived connections use a proper VPN or a managed connection. For resilient tunnels, tools like
autosshcan restart them automatically.
Remote forwarding (-R)
Remote forwarding is the mirror image. It opens a port on the remote server that forwards back to a service on your machine. This is how you let a server (or someone on the server) reach something running on your laptop.
ssh -R REMOTE_PORT:DESTINATION_HOST:DESTINATION_PORT user@server
Example: expose your local dev app to a remote server
You are building a web app on your laptop at localhost:3000, and you want the remote server to be able to reach it (for a webhook test, say).
ssh -R 8080:localhost:3000 [email protected]
Now anything on the server that connects to localhost:8080 is forwarded back to your laptop’s port 3000.
By default the forwarded remote port binds only to localhost on the server, so only the server itself can use it. To let other machines reach it, the server’s /etc/ssh/sshd_config must set GatewayPorts yes and you bind explicitly:
ssh -R 0.0.0.0:8080:localhost:3000 [email protected]
Opening a remote port to
0.0.0.0(all interfaces) exposes your local service to anyone who can reach that server’s port. Only do this withufwrules in place and never with sensitive apps. This is exactly how attackers create backdoors — treat-Rto0.0.0.0as a loaded weapon.
Keeping tunnels alive
Idle tunnels can be dropped by firewalls. Add keepalives so SSH sends a quiet ping periodically. You can set this per command or globally in ~/.ssh/config:
Host db-tunnel
HostName 203.0.113.10
User deploy
LocalForward 5432 localhost:5432
ServerAliveInterval 30
ServerAliveCountMax 3
Now ssh -fN db-tunnel starts the same tunnel with a friendly name.
Best Practices
- Prefer SSH key authentication over passwords for any tunnel you script or automate.
- Keep forwarded ports bound to
localhostunless you have a deliberate, firewalled reason to expose them. - Use
~/.ssh/configentries so tunnel definitions are documented, repeatable, and easy for teammates to read. - Add
ServerAliveIntervalto survive idle timeouts on long-running tunnels. - For production-grade, self-healing tunnels, use
autossh(sudo apt install autossh) instead of a baressh -f. - Audit
GatewayPortsandAllowTcpForwardinginsshd_configon your servers — disable forwarding entirely on hosts that should never tunnel. - Always close tunnels when finished; a forgotten
-Rtunnel is a standing security hole.