What Is an SSH Tunnel? SSH Port Forwarding Explained (Local, Remote, Dynamic)
An SSH tunnel forwards network traffic through an encrypted SSH connection. Instead of letting a service like MySQL or Redis listen on a public port, you bind it to `localhost` on the server and reach it through an authenticated SSH session. The traffic rides inside the SSH channel — encrypted end to end — so an otherwise-plaintext protocol becomes private, and a service hidden behind a firewall becomes reachable without poking a hole in that firewall.
This is also called SSH port forwarding. There are three forms — local, remote, and dynamic — and each solves a different routing problem. Below are the exact commands, the edge cases that bite people, and how to keep a tunnel alive in production.
Key Takeaways
• An SSH tunnel wraps arbitrary TCP traffic inside an encrypted SSH connection.
• Local forwarding (`-L`) pulls a remote service to your machine; remote forwarding (`-R`) pushes a local service out to the server; dynamic forwarding (`-D`) turns SSH into a SOCKS proxy.
• The security win: the target service stays bound to `localhost` on the server and is never exposed to the internet. The only entry path is an authenticated SSH session.
• Use `autossh` plus keepalives for persistent tunnels, and `ProxyJump` to reach hosts behind a bastion.
How does an SSH tunnel actually work?
SSH multiplexes more than one logical channel over a single encrypted transport connection. A shell session is one channel; a forwarded port is another. When you open a tunnel, your local SSH client starts listening on a socket. Anything that connects to that socket gets its bytes wrapped, shipped over the encrypted link, and unwrapped on the other end, where the SSH daemon forwards them to the real target.
Because the forwarding endpoint resolves on the remote side, you can reach a `target_host:target_port` that is only routable from the server — for example, an internal database that has no public route at all. Your laptop never talks to the database directly. It talks to a local socket; SSH does the rest.
Three flags select the direction and shape of the forward: `-L`, `-R`, and `-D`.
What is local port forwarding (ssh -L)?
Local forwarding opens a listening port on *your* machine and forwards connections to a host:port reachable *from the SSH server*. The pattern:
“`bash ssh -L local_port:target_host:target_port user@sshserver “`
The canonical use case is reaching a remote database that is bound to `localhost` on the server. Say MySQL listens on `127.0.0.1:3306` on the server and is firewalled off from the internet:
“`bash ssh -L 3306:localhost:3306 user@server “`
Now point your client at `localhost:3306` on your laptop:
“`bash mysql -h 127.0.0.1 -P 3306 -u app -p “`
The `localhost` in `localhost:3306` is resolved on the server, so it means “the server’s own loopback.” That is the whole trick — the database never needs a public port.
A few edge cases worth knowing:
- Bind address. By default `-L` binds to `127.0.0.1` on your side, so only your machine can use the tunnel. To share it on your LAN, prefix a bind address: `-L 0.0.0.0:3306:localhost:3306` (and you’ll need `GatewayPorts` semantics on the client). Do this deliberately — you are widening the local attack surface.
- Port already in use. If `local_port` is taken, SSH errors with `bind: Address already in use`. Pick another local port; the right side doesn’t have to match: `-L 13306:localhost:3306`.
- No shell needed. Add `-N` to forward ports without running a remote command, and `-f` to background the client: `ssh -fN -L 3306:localhost:3306 user@server`.
What is remote port forwarding (ssh -R)?
Remote forwarding is the mirror image: it opens a listening port on the SSH server and forwards connections back to a host:port reachable from your machine. The pattern:
“`bash ssh -R remote_port:local_host:local_port user@server “`
This exposes a local service to the remote side. Example — you’re running a dev server on `localhost:8080` and want a colleague on the server’s network to reach it:
“`bash ssh -R 8080:localhost:8080 user@server “`
By default, the remote listener binds to the server’s loopback, so only processes on the server can reach `8080`. To make it bind on all interfaces of the server, you must set `GatewayPorts yes` (or `clientspecified`) in the server’s `/etc/sshd_config` and reload `sshd`. Without that, `ssh -R 0.0.0.0:8080:…` silently falls back to loopback.
Remote forwarding is how reverse-tunnel “phone home” setups work: a host behind NAT with no inbound route opens an outbound SSH connection to a reachable server and uses `-R` so the server can reach back into it.
What is dynamic port forwarding (ssh -D)?
Dynamic forwarding turns your SSH client into a SOCKS proxy. Instead of one fixed target, it routes arbitrary traffic — the destination is chosen per-connection by the SOCKS protocol:
“`bash ssh -D 1080 user@server “`
Now configure an application (browser, `curl`, etc.) to use a SOCKS5 proxy at `127.0.0.1:1080`. Every request flows through the server, encrypted over SSH:
“`bash curl –socks5-hostname 127.0.0.1:1080 https://internal.example.com “`
Use `–socks5-hostname` (not `–socks5`) so DNS is resolved on the server side — otherwise your local resolver leaks which hosts you’re reaching, and names that only resolve internally won’t work at all.
Dynamic forwarding is the right tool when you want the server to act as an egress point for many destinations: reaching a fleet of internal services, or routing a browser through a remote network without per-service tunnels.
The under-appreciated security property: an SSH tunnel lets you reach a remote database or internal service without ever opening its port to the internet. Bind the service to `127.0.0.1` on the server (for MySQL, `bind-address = 127.0.0.1`; for Postgres, `listen_addresses = ‘localhost’`; for Redis, `bind 127.0.0.1` with `protected-mode yes`), then `ssh -L` to reach it. The attack surface for that service stays effectively zero — there is no public port to scan, brute-force, or exploit. The only way in is an authenticated SSH session, so your entire perimeter for that data collapses down to one hardened door: SSH key auth on a single port. Compare that to exposing `3306` to the internet and praying your password policy holds.
Local vs remote vs dynamic forwarding
| Local (`-L`) | Remote (`-R`) | Dynamic (`-D`) | |
|---|---|---|---|
| Flag | `-L lport:host:rport` | `-R rport:host:lport` | `-D port` |
| Listener opens on | Your machine | The SSH server | Your machine |
| Direction | Pull remote service to you | Push local service to server | Many destinations (SOCKS) |
| Targets | One fixed host:port | One fixed host:port | Any, chosen per-connection |
| Typical use | Reach a remote DB / internal service | Expose a local dev server / reverse tunnel | Browser proxy, multi-service egress |
| Resolves target on | Server side | Client side | Server side |
How do you reach a service behind a bastion (jump host)?
Internal services often sit behind a bastion (jump host) — the only box with an inbound route. You don’t want to tunnel twice by hand. Use `ProxyJump`:
“`bash ssh -J user@bastion user@internal-db-host “`
Combine it with local forwarding to reach a database two hops away:
“`bash ssh -J user@bastion -L 5432:localhost:5432 user@db-host “`
Make it permanent in `~/.ssh/config` so plain `ssh db-host` just works:
“` Host db-host HostName 10.0.5.10 User app ProxyJump bastion
Host bastion HostName bastion.example.com User app “`
`ProxyJump` (the `-J` flag) supersedes the older `ProxyCommand … nc …` pattern and is cleaner and safer because it doesn’t require `netcat` on the bastion.
How do you keep an SSH tunnel alive in production?
A bare `ssh -L` dies when the network blips, your laptop sleeps, or an idle timeout fires. Two things fix this.
Keepalives detect dead connections and stop the tunnel from being torn down by idle NAT timeouts. Put these in `~/.ssh/config`:
“` Host * ServerAliveInterval 30 ServerAliveCountMax 3 ExitOnForwardFailure yes “`
`ServerAliveInterval 30` sends a probe every 30 seconds; after 3 unanswered probes the client gives up. `ExitOnForwardFailure yes` makes SSH exit immediately if the forward can’t be established — critical so a supervisor knows to restart instead of leaving a connected-but-useless session.
autossh automatically restarts the tunnel when it drops:
“`bash autossh -M 0 -fN -L 3306:localhost:3306 user@server “`
`-M 0` disables autossh’s own monitoring port and leans on SSH keepalives instead (the recommended setup). For an always-on tunnel, wrap it in a `systemd` unit with `Restart=always` so it survives reboots and the supervisor owns the lifecycle.
Need a reliable SSH endpoint for your tunnels?
An SSH tunnel is only as good as the server on the other end. DarazHost VPS and dedicated servers ship with full SSH root access, making them an ideal tunnel endpoint and bastion host: reach your database and internal services securely, use the box as a jump host, and keep every service port bound to `localhost` and closed to the internet. You get reliable, secure infrastructure with 24/7 support — so the one door into your stack stays hardened and always up.
When should you *not* use an SSH tunnel?
SSH tunnels are excellent for point-to-point access and ad-hoc reach, but they aren’t a full VPN. For many concurrent users, complex routing, or sustained high-throughput traffic, a purpose-built WireGuard or IPsec VPN scales better and is easier to audit. SSH tunnels also forward TCP only — UDP-based protocols (DNS-over-UDP, some VoIP, QUIC) won’t traverse them without extra tooling. Reach for tunnels when you want quick, encrypted, authenticated access to a specific service; reach for a VPN when you’re building a network.
Frequently asked questions
Is an SSH tunnel encrypted? Yes. All traffic inside the tunnel is encrypted by the SSH transport layer using the same ciphers as your interactive SSH session. This is precisely why tunneling is used to protect plaintext protocols — the application data never crosses the network in the clear.
What’s the difference between `-L` and `-R`? `-L` (local) opens the listening port on your machine and forwards to a target reachable from the server — you pull a remote service toward you. `-R` (remote) opens the listening port on the server and forwards back to a target reachable from your machine — you push a local service outward.
Why use `ssh -fN` instead of just `ssh`? `-N` says “don’t run a remote command” (you only want forwarding, not a shell), and `-f` backgrounds the SSH process after authentication. Together they give you a clean, detached tunnel: `ssh -fN -L 3306:localhost:3306 user@server`.
Does the remote service need a public port for `ssh -L` to work? No — that’s the entire point. The forward target is resolved on the server side, so a service bound to `127.0.0.1` on the server (with no public port at all) is fully reachable through the tunnel. Keep it that way.
Why does my SOCKS proxy leak DNS or fail to resolve internal names? Your client is resolving hostnames locally instead of through the proxy. Use SOCKS5 with remote DNS — for `curl` that’s `–socks5-hostname` rather than `–socks5`; in browsers, enable “proxy DNS when using SOCKS v5.” This sends name resolution to the server, where internal names actually resolve.