SSH tunneling lets you securely forward network traffic through an SSH connection. This is incredibly useful for accessing services that are otherwise inaccessible, encrypting unencrypted protocols, or bypassing firewalls.

Imagine you have a web server running on 192.168.1.100 on your local network, but you’re currently outside that network and can’t directly reach it. You also have an SSH server running on a machine accessible from the internet, say ssh.example.com.

Here’s how you can set up a local tunnel to access that web server:

ssh -L 8080:192.168.1.100:80 user@ssh.example.com

Now, on your local machine, you can open a web browser and go to http://localhost:8080. This traffic is sent from your browser to localhost:8080, then through the SSH connection to ssh.example.com, which then forwards it to 192.168.1.100:80. The response travels back the same way.

This is a local tunnel (-L). The format is -L [local_bind_address:]local_port:remote_host:remote_port. The remote_host and remote_port are resolved from the perspective of the SSH server (ssh.example.com in this case). If you omit local_bind_address, it defaults to localhost.

What if you want to expose a service running on your local machine to a remote network, but you don’t have a publicly accessible IP on your local machine? This is where remote tunnels (-R) come in.

Suppose you’re running a database on your laptop (localhost:5432) and you want a colleague on ssh.example.com to be able to access it. You can set up a remote tunnel:

ssh -R 9090:localhost:5432 user@ssh.example.com

Now, on ssh.example.com, you can connect to your local database using psql -h localhost -p 9090. The traffic from ssh.example.com:9090 is forwarded through the SSH connection back to your laptop, which then connects to localhost:5432. The format is -R [remote_bind_address:]remote_port:local_host:local_port. By default, remote_bind_address is localhost on the remote server, meaning only processes on ssh.example.com can connect to port 9090. If you want other machines to be able to connect to ssh.example.com:9090, you might need to configure GatewayPorts yes in the sshd_config on ssh.example.com and then bind to 0.0.0.0:9090.

The most flexible type is the dynamic tunnel (-D), which turns your SSH client into a SOCKS proxy.

ssh -D 1080 user@ssh.example.com

After running this, you can configure your web browser or other applications to use localhost:1080 as a SOCKS proxy. When you browse the web, all your traffic will be routed through ssh.example.com. This is perfect for browsing the internet as if you were on the remote network, or for tunneling traffic for applications that don’t have explicit proxy settings but support SOCKS.

The real magic of dynamic tunnels is that the SSH client acts as a SOCKS server. When an application connects to localhost:1080 and tells the SOCKS proxy "I want to connect to www.google.com:80", the SSH client receives this instruction. It then initiates a new SSH connection (or uses an existing one) to ssh.example.com and tells that server to connect to www.google.com:80. The response then flows back through the SOCKS proxy. This means you don’t need to pre-define individual port forwards; the proxy handles arbitrary destinations.

One detail often overlooked with remote tunnels (-R) is how GatewayPorts works. If GatewayPorts is no (the default), binding to remote_port means only localhost on the remote server can connect to it. If GatewayPorts is yes, binding to remote_port will bind to 0.0.0.0, making it accessible from any IP address that can reach the SSH server. This is critical for making a tunneled service available to machines other than the SSH server itself.

The next thing you’ll likely want to explore is using SSH agent forwarding to manage authentication across tunnels without storing keys on intermediate servers.

Want structured learning?

Take the full Ssh course →