SSH local port forwarding lets you access a service running on a remote machine, or even a machine behind that remote machine, as if it were running on your local computer.
Let’s say you have a database running on db.internal.example.com on port 5432, but it’s only accessible from bastion.example.com. You want to connect to it using your local PostgreSQL client.
Here’s how you’d set up the tunnel:
ssh -L 5432:db.internal.example.com:5432 user@bastion.example.com
-L: This flag signifies local port forwarding.5432: This is the port on your local machine that will listen for connections.db.internal.example.com:5432: This is the actual destination host and port that SSH will connect to from thebastion.example.comserver.user@bastion.example.com: This is the SSH connection to your bastion host.
Once this command is running, any connection you make to localhost:5432 on your local machine will be forwarded through bastion.example.com to db.internal.example.com:5432. Your local PostgreSQL client would then be configured to connect to localhost on port 5432.
This setup is incredibly useful for accessing internal services that aren’t exposed to the public internet, or for securely connecting to services that otherwise use unencrypted protocols. You’re essentially creating a secure, encrypted tunnel for that specific port.
Consider this scenario: a web application is running on app.internal.example.com:8080, but it’s only reachable from bastion.example.com. You want to test its API using curl locally.
ssh -L 8080:app.internal.example.com:8080 user@bastion.example.com
After running this, curl http://localhost:8080 on your local machine will hit the web application running on app.internal.example.com. The SSH connection to bastion.example.com acts as the intermediary, forwarding the traffic.
You can also forward ports to services that are not directly accessible from the bastion host itself, but are accessible from the bastion host’s network. For example, if db.internal.example.com can reach 192.168.1.100:3306, you can still use the bastion as a jump point.
ssh -L 3306:192.168.1.100:3306 user@bastion.example.com
Here, bastion.example.com will initiate the connection to 192.168.1.100:3306. This is powerful for layered network access.
The syntax -L local_port:remote_host:remote_port is key. The remote_host is resolved from the perspective of the remote SSH server (bastion.example.com in our examples).
If you want to keep the tunnel open in the background, use the -N (do not execute a remote command) and -f (go to background) flags:
ssh -f -N -L 5432:db.internal.example.com:5432 user@bastion.example.com
This command will establish the tunnel and then immediately return you to your local shell prompt, with the tunnel running in the background. To stop it, you’d find the process ID (PID) using ps aux | grep "ssh -f -N -L" and then kill <PID>.
One of the most commonly misunderstood aspects is that the remote_host specified in the -L flag is resolved by the SSH server you’re connecting to, not by your local machine. This is what allows you to access services that are only available on the remote server’s private network.
You can also bind the local port to a specific interface if you don’t want it to be accessible from all local network interfaces. For example, to only allow connections from your local loopback interface:
ssh -L 127.0.0.1:5432:db.internal.example.com:5432 user@bastion.example.com
This prevents other machines on your local network from using your machine as a proxy to reach the remote service.
The next hurdle is often understanding how to manage multiple simultaneous tunnels or dealing with dynamic port assignments.