Implementing Local and Remote SSH Port Forwarding for Tunneling

Unlock secure network access and firewall traversal using SSH port forwarding. This comprehensive guide details the practical implementation of both Local (`-L`) and Remote (`-R`) SSH tunneling techniques. Learn the essential syntax, understand the key differences between accessing remote services versus exposing local services, and see clear examples for tasks like securing database connections or sharing development environments. Includes critical best practices for creating persistent, secure background tunnels using key-based authentication.

Implementing Local and Remote SSH Port Forwarding for Tunneling

SSH port forwarding is one of those tools you forget about until a firewall, private subnet, or awkward vendor network blocks the simple path. Then it becomes the fastest clean fix. You can use it to reach a database through a bastion host, test a private admin page from your laptop, or expose a local development server to a machine that cannot reach your laptop directly.

The basic idea is simple: SSH opens a listening port on one side of the connection and carries the traffic through the encrypted SSH session to a destination on the other side. The part that trips people up is the direction. Local forwarding with -L lets your machine reach something near the SSH server. Remote forwarding with -R lets something near the SSH server reach something near your machine.

Local forwarding: bring a remote service to your laptop

Use local forwarding when the service you need is reachable from the SSH server, but not from your workstation.

ssh -L 15432:db.internal.example:5432 [email protected]

After this connects, your laptop listens on 127.0.0.1:15432. When you point psql, DBeaver, or an application config at that local port, SSH sends the traffic to bastion.example.com, and the bastion opens a connection to db.internal.example:5432.

Read the command from left to right:

local port on my machine : destination host as seen by the SSH server : destination port

That "as seen by the SSH server" detail matters. If the database is named db.internal.example only inside the private network, your laptop does not need to resolve that name. The bastion does. If the database only listens on localhost on the bastion, use this instead:

ssh -L 15432:127.0.0.1:5432 [email protected]

Local forwarding is usually the safer default because the listening port is on your workstation. By default OpenSSH binds local forwarded ports to the loopback interface, so other machines on your Wi-Fi or office network cannot use the tunnel. You can be explicit:

ssh -L 127.0.0.1:15432:db.internal.example:5432 [email protected]

Avoid binding to 0.0.0.0 unless you really mean to share the tunnel with other hosts. A command like this makes your laptop a proxy to the private database for anyone who can reach port 15432 on your laptop:

ssh -L 0.0.0.0:15432:db.internal.example:5432 [email protected]

That may be useful in a lab. It is rarely what you want on a normal workstation.

Remote forwarding: expose a local service through the server

Remote forwarding flips the listening side. Use it when a service is running on your machine, but someone or something near the SSH server needs to reach it.

ssh -R 18080:127.0.0.1:3000 [email protected]

This asks public.example.com to listen on port 18080. Connections to that port are carried back through SSH to 127.0.0.1:3000 on your laptop. This is handy when you are testing a webhook receiver, sharing a temporary demo, or debugging a callback from a staging system that cannot call your laptop directly.

There is one common surprise: remote forwarded ports usually bind to loopback on the SSH server by default. That means curl http://127.0.0.1:18080 works when run on public.example.com, but http://public.example.com:18080 from your browser may not.

To make a remote forwarded port reachable from other machines, the SSH server must allow it. In /etc/ssh/sshd_config, the relevant setting is commonly:

GatewayPorts clientspecified

Then you can request a public bind:

ssh -R 0.0.0.0:18080:127.0.0.1:3000 [email protected]

Use this carefully. You are publishing a local service through the server. Put a firewall in front of it, use a high random port, and do not expose admin tools, development databases, or unauthenticated apps to the internet.

Keep tunnels boring and reliable

For a long-running tunnel, you usually do not want a shell on the remote host:

ssh -N -L 15432:db.internal.example:5432 [email protected]

-N means "do not run a remote command." Add keepalives when the tunnel crosses NAT, VPN, or cloud load balancers that drop idle TCP sessions:

ssh -N \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  -L 15432:db.internal.example:5432 \
  [email protected]

For unattended use, prefer a systemd user service, autossh, or your process supervisor over a naked ssh -f command. Backgrounding with -f works, but it makes failed startups and stale tunnels harder to notice.

ssh -fN -L 15432:db.internal.example:5432 [email protected]

If you do use -fN, test the same command without -f first. Password prompts, unknown host key prompts, and port conflicts are much easier to diagnose in the foreground.

Troubleshooting checklist

When a tunnel connects but the application still fails, check each hop instead of guessing.

First, confirm the local listener exists:

ss -ltnp | grep 15432

Then test the destination from the SSH server:

ssh [email protected] 'nc -vz db.internal.example 5432'

If that fails, SSH forwarding is not the problem. The bastion cannot reach the service, the name does not resolve there, a security group blocks the port, or the service is bound to the wrong interface.

If the listener does not start, the local port may already be in use:

lsof -iTCP:15432 -sTCP:LISTEN

If remote forwarding fails with a message like remote port forwarding failed, the server may block TCP forwarding. Check AllowTcpForwarding in sshd_config, and check whether the requested port is already taken.

Security habits worth keeping

Use key-based authentication and restrict the account used for tunnels. For a dedicated tunnel user, you can combine limited shell access, firewall rules, and SSH options such as PermitOpen or PermitListen depending on the direction you need. Those controls prevent a convenience tunnel from turning into broad network access.

Name tunnels in your notes or runbooks by intent, not only by command. "Laptop 15432 to production reporting replica through bastion" is easier to audit than a mystery ssh -L line in shell history.

Local forwarding helps you reach inward. Remote forwarding lets others reach back toward you. Once that distinction is clear, most SSH tunneling problems become a matter of checking which side listens, which side resolves the destination, and which firewall sits between them.

A few real patterns you will see

A common production pattern is the database maintenance tunnel. You have a reporting replica in a private subnet, a bastion host with strict SSH access, and an analyst tool on your laptop. Local forwarding fits cleanly:

ssh -N -L 127.0.0.1:15432:reporting-db.internal:5432 [email protected]

The application on your laptop should use 127.0.0.1, not the private database hostname. If the tool insists on SSL hostname verification against the database host, you may need to connect with the database hostname and add a local hosts entry, or configure the client with the right SSL mode. The tunnel only moves TCP bytes; it does not rewrite database protocol details.

Another pattern is a temporary webhook receiver:

ssh -N -R 127.0.0.1:19090:127.0.0.1:9090 [email protected]

In that version, the forwarded port is intentionally only usable from the gateway itself. You might then configure a staging service on the gateway to call http://127.0.0.1:19090/hook. This is safer than publishing the port to the whole network.

For a short public demo, use a public bind only after adding a firewall rule:

ssh -N -R 0.0.0.0:19090:127.0.0.1:3000 [email protected]

Then restrict the gateway:

sudo ufw allow from 203.0.113.40 to any port 19090 proto tcp

Without that restriction, anyone who can reach the gateway can try the forwarded service.

What SSH tunneling does not solve

SSH forwarding is not a substitute for service authentication. If the database accepts local connections without a password, a tunnel may extend that weak trust boundary farther than intended. If a local development app has no login page, remote forwarding can publish it as-is.

It also does not make a flaky destination reliable. If the bastion cannot resolve the internal name, if the service is down, or if a security group blocks the path, the tunnel can still establish successfully while the application fails. That is why testing from the SSH server is so useful.

Finally, tunnels are easy to forget. A stale tunnel on a shared jump host can leave an unexpected port open. For anything long-running, put the command in a service file with a clear name, owner, and restart policy. For anything temporary, close it when the work is done and verify the listener disappeared with ss -ltnp.