How do you reach a database that only listens on localhost?
A Postgres instance on a remote server binds to 127.0.0.1 on purpose. Nothing on the network can reach it, only processes running on that same box. Your laptop is not that box, so psql -h remote-server -p 5432 fails before it gets anywhere near authentication. Opening the port to the world defeats the reason it was bound to loopback in the first place.
What you want is to borrow the remote machine’s view of 127.0.0.1 for a few minutes, from your own terminal. SSH already has an encrypted, authenticated connection open between the two machines the moment you log in. Port forwarding reuses that connection to carry other traffic besides your shell.
How does SSH port forwarding work under the hood?
A plain SSH session looks like a single pipe: keystrokes go in, terminal output comes back. Underneath, the protocol multiplexes several independent channels over that one encrypted connection. The shell is one channel. A forwarded TCP connection is another.
Ask ssh to forward a port and it opens a new channel for every connection made to that port, carries the raw bytes through the existing session, and hands them off on the other side. No new certificate, no second login. The tunnel inherits whatever access the SSH login already has.
Keep that in mind before looking at the flags. -L, -R and -D are not three different mechanisms. They point the same channel-forwarding machinery in different directions.
How do you use SSH local port forwarding (-L)?
| |
Read the argument to -L as local_port:destination_host:destination_port. This opens port 5432 on your laptop. Anything that connects to it gets forwarded through the tunnel to localhost:5432 as seen from db-host, which is the Postgres instance bound to its own loopback. In a second terminal:
| |
psql thinks it’s talking to a local database. It’s talking to db-host’s loopback interface through SSH.
The destination host in the middle doesn’t have to be the SSH server itself:
| |
This forwards through jumpbox to a third machine that jumpbox can reach and you can’t. It’s the shape most people mean by “SSH tunnel”: one box with the access you lack, forwarding into a network you can’t otherwise touch. If you already run Postgres locally, port 5432 on your laptop is taken; pick another local port such as 15432 and pass -p 15432 to psql.
How do you expose a local port with SSH remote forwarding (-R)?
-R runs the same idea backwards. The syntax is remote_port:destination_host:destination_port, but the listener opens on the remote end.
| |
Now public-host listens on port 8080, and any connection to it comes back through the tunnel to localhost:3000 on your machine: your local dev server. You get a reachable address for a webhook provider or a colleague without opening a port on your own firewall, by borrowing a box that already has one.
One default trips people up. sshd binds remote-forwarded ports to the loopback interface of the remote host, so curl public-host:8080 from a third machine fails even though the forward is running. Only processes on public-host itself can reach it. Changing that takes GatewayPorts yes in that server’s sshd_config, which the server’s admin controls. No flag on your ssh command overrides it.
How do you turn an SSH tunnel into a SOCKS proxy (-D)?
-L and -R each forward to one fixed destination, decided when you run the command. -D doesn’t pick a destination at all:
| |
This opens a SOCKS proxy on localhost:1080. Any application pointed at it chooses the destination for each connection, and ssh relays it from remote-host’s position on the network:
| |
The -hostname part matters. With plain --socks5, curl resolves internal-service.corp on your laptop, where that name doesn’t exist, and fails before the proxy is ever used. --socks5-hostname sends the name through the tunnel so remote-host resolves it.
Browsers work the same way once you set the proxy in their network settings (and enable “proxy DNS when using SOCKS v5” in Firefox). Every site then sees remote-host’s IP and goes through its firewall rules. That’s how you reach a whole internal network through one jump box, or check what a site serves to another region, without writing a separate forward for each destination.
Which SSH forwarding type should you use: -L, -R or -D?
-L local | -R remote | -D dynamic | |
|---|---|---|---|
| Listener opens on | Your machine | The remote host | Your machine |
| Destination | Fixed, set on the command line | Fixed, set on the command line | Chosen per-connection by the app |
| Typical use | Reach a service that only the remote host can see | Expose your local service to the remote side | Route arbitrary traffic through the remote host’s network |
| Needs on the far end | Nothing beyond a normal SSH login | GatewayPorts if others must reach it | Nothing beyond a normal SSH login |
How do you keep an SSH tunnel open in the background?
Two problems show up fast: typing the full command every time, and the tunnel dying when the terminal closes. ~/.ssh/config fixes the first:
| |
ssh db-tunnel now opens both forwards at once. LocalForward, RemoteForward and DynamicForward map to -L, -R and -D, one line per forward, as many as you need in a Host block.
For a forward that stays up unattended, add -N (run no remote command, only hold the tunnel) and -f (go to the background after authenticating), plus a keepalive so a silently dropped connection gets noticed instead of hanging:
| |
That still won’t reconnect when the network drops. ssh exits and the forward is gone. autossh wraps the same command and restarts it when the connection dies:
| |
-M 0 turns off autossh’s own monitoring port and lets the ServerAlive* keepalive detect a dead connection instead. On modern OpenSSH that’s the setup to use.
When should you not use SSH tunnels?
A forwarded port inherits the access of the SSH login that opened it. That’s convenient when you’re the one holding the key, and a real exposure when the key or session is compromised, because the tunnel hands out everything the login can reach. AllowTcpForwarding in sshd_config is the server-side switch (default yes). Setting it to no closes this path, but anyone with shell access on the box can still run their own forwarder, so it only helps if you restrict shell access too.
Tunnels also stop scaling past a handful of people or services. Each one is a process tied to a terminal or an autossh instance. There’s no central place to see who has a tunnel open, revoke access without touching every client config, or apply one policy across users. At that point a VPN, or an identity-aware proxy in front of the service, should replace the pile of personal tunnels.
They’re also not the same tool as a reverse proxy or Cloudflare Tunnel, even though all three make something reachable that wasn’t. A reverse proxy sits permanently in front of a service and faces the public internet. Cloudflare Tunnel is closer to -R: an outbound connection from your side that becomes reachable from outside, but through Cloudflare’s edge with a stable hostname and no server port to manage. And while a reverse proxy or Cloudflare Tunnel relies on certificates, as covered in how the TLS handshake works, an SSH tunnel needs none. The encryption is the SSH session itself, authenticated by your key rather than by a certificate a browser would trust.
What should you set up first?
Pick the tunnel you retype most often and turn it into a Host block in ~/.ssh/config today. Reaching in to something only a remote host can see is -L. Exposing a local service through a box with a public address is -R. Routing an application’s traffic through a remote network without choosing destinations up front is -D. Once it’s in the config, ssh -N -f db-tunnel is all you type, and you stop mistyping port numbers at 11 p.m.