Darwvin

SSH port forwarding explained: local, remote and dynamic tunnels

SSH port forwarding explained with ssh -L, -R and -D: reach a private database, expose a dev server, run a SOCKS proxy, and keep tunnels alive.

Published 11 min read

SSH port forwarding sends TCP connections through an encrypted SSH session. -L lets a port on your machine reach a host that the SSH server can reach, -R lets a port on the SSH server reach a service on your machine, and -D opens a local SOCKS proxy that sends its traffic through the session. The commands are short, but the bind address and the server's forwarding settings decide whether a tunnel stays private, so the defaults matter as much as the syntax.

How the traffic flows

Port forwarding is part of the SSH connection protocol. RFC 4254 section 7 defines it for TCP/IP, and that is what the forwards in this guide carry. In a local forward, the SSH server opens the outgoing connection. In a remote forward, the server listens on a port and sends each new connection back to your machine, which opens the connection to its own target.

Encryption covers one leg of the path. Traffic between your machine and the SSH server is encrypted. The last hop is not covered: with -L and -D it runs from the SSH server to the destination, and with -R it runs from your machine to the service it reaches. A plain TCP service sends plain text on that hop, which matters for the security section below.

The host names in these examples use the reserved .example domain (RFC 2606), so swap in your own.

Local forwarding with -L

Use -L when a service is reachable from the SSH server but not from your laptop: a database on a private subnet, an admin page on an internal host, or a staging API. The syntax is:

-L [bind_address:]local_port:destination_host:destination_port

A connection takes this path:

your machine                     SSH server                      target
127.0.0.1:15432  ==== SSH ====>  opens a TCP connection  ---->  db-01.corp.example:5432

Start the tunnel in one terminal:

ssh -N -L 15432:db-01.corp.example:5432 deploy@bastion.example

Then connect through it from another terminal:

psql -h 127.0.0.1 -p 15432 -U app appdb

-N tells ssh not to run a remote command, so the session only forwards ports. The destination is resolved on the SSH server, because ssh(1) forwards to the host and port "on the remote side." Two mistakes follow from that:

  • localhost as the destination means the SSH server's own loopback address, not your laptop. Use a name or address the server can reach.
  • Use 15432 locally instead of 5432 to avoid a clash with a PostgreSQL server already running on your machine.

The local listener binds to loopback by default. ssh(1) binds local forwards according to the GatewayPorts setting, and ssh_config(5) sets its default to no, so the listener is reachable from your machine but not from the network.

Remote forwarding with -R

A remote forward is also called a reverse tunnel. You start it from your machine, but the listening port opens on the server.

Use -R when the service runs on your machine and someone needs to reach it through the server: a webhook receiver you are debugging, a demo for a client, or a laptop behind NAT with no public address.

curl on the SSH server ---> 127.0.0.1:8080 (sshd listens) ==== SSH ====> your machine: localhost:3000
ssh -N -R 8080:localhost:3000 deploy@public.example

Here localhost:3000 is resolved on your machine. The listener on the server binds to 127.0.0.1:8080, because ssh(1) says that by default TCP listening sockets on the server are bound to the loopback interface. Only processes on the server can connect to it.

To make the port reachable from other machines, the server must allow it. In the server's sshd_config:

# /etc/ssh/sshd_config on the server
GatewayPorts clientspecified

Then the client asks for a wildcard bind address:

ssh -N -R '*:8080:localhost:3000' deploy@public.example

clientspecified lets the client choose the bind address, and yes puts every remote forward on all interfaces. Exposing a dev server is a security decision, because anyone who can reach that port can use the app, debug pages included. Add authentication to the app before you open the port, and restrict the port with a firewall rule on the server.

Dynamic forwarding with -D

-D opens a local SOCKS listener. Each connection the proxy receives goes through the SSH session, and the SSH server opens the connection to whatever destination the client asked for. ssh(1) says it supports SOCKS4 and SOCKS5, and RFC 1928 defines SOCKS5.

browser --SOCKS5--> 127.0.0.1:1080 ==== SSH ====> bastion --TCP--> intranet.corp.example:443
ssh -N -D 127.0.0.1:1080 deploy@bastion.example
curl --socks5-hostname 127.0.0.1:1080 https://intranet.corp.example/

--socks5-hostname tells curl to let the proxy resolve the name, which matters when the name exists only inside the network. If an application resolves names on your machine first, it uses your local resolver, and an internal name may fail or resolve to the wrong address. In a browser, set its SOCKS v5 proxy to 127.0.0.1 port 1080 and turn on the option that sends DNS lookups through the proxy. Browsers word that option differently, so look for it in the proxy settings.

Only applications set up to use the proxy send traffic through the tunnel. Everything else keeps using your normal network.

Server settings that control forwarding

Forwarding is partly a server decision, and the defaults are permissive. Check these settings on any server you administer.

SettingFileDefaultEffect
GatewayPortssshd_confignoyes puts remote forwards on all interfaces. clientspecified lets the client choose the bind address.
GatewayPortsssh_confignoyes binds local forwards to the wildcard address instead of loopback.
AllowTcpForwardingsshd_configyesno blocks all TCP forwarding. local allows client-side forwards (-L and -D). remote allows -R only.
PermitOpensshd_configallLimits the host and port destinations that forwards may reach.
PermitListensshd_configallLimits the addresses and ports that -R may bind on the server.
ExitOnForwardFailuressh_confignoMakes ssh exit if any requested forward cannot be set up.
ServerAliveIntervalssh_config0Seconds without data before ssh sends a keepalive probe. The default sends none.
ServerAliveCountMaxssh_config3Unanswered probes allowed before ssh disconnects.

For a shared server, you can limit a single account:

# /etc/ssh/sshd_config (excerpt)
GatewayPorts no
Match User tunnel
    AllowTcpForwarding local
    PermitOpen db-01.corp.example:5432

Turning forwarding off is not complete control. sshd_config(5) notes that disabling TCP forwarding does not improve security unless users are also denied shell access, because they can run their own forwarders.

Keeping tunnels alive

Tunnels fail for ordinary reasons. A laptop sleeps, the Wi-Fi network changes, or a NAT device drops an idle connection. A few ssh_config settings cover most of it:

Host db-tunnel
    HostName bastion.example
    User deploy
    LocalForward 15432 db-01.corp.example:5432
    ServerAliveInterval 30
    ServerAliveCountMax 3
    ExitOnForwardFailure yes
    BatchMode yes

ServerAliveInterval 30 makes ssh send a probe after 30 seconds without data from the server. ServerAliveCountMax 3 allows three unanswered probes before ssh disconnects, so a dead path gets noticed instead of hanging. ExitOnForwardFailure yes makes ssh exit when it cannot set up the forward, which turns a half-working session into a failure that a supervisor can see. BatchMode yes turns off password and host key confirmation prompts, so a background tunnel fails instead of waiting for input that nobody will type. The SSH config file guide explains the Host and precedence rules behind that block.

Something then has to restart the process after it exits. Two common options follow.

autossh starts ssh and restarts it when it dies or stops passing traffic. Its -M option sets a monitoring port, and -M 0 turns monitoring off, so autossh only restarts ssh when ssh exits. The autossh manual says that ServerAliveInterval and ServerAliveCountMax may be a better solution than the monitoring port in many ways.

autossh -M 0 -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes -L 15432:db-01.corp.example:5432 deploy@bastion.example

Or use a systemd user service:

# ~/.config/systemd/user/db-tunnel.service
[Unit]
Description=Tunnel to the staging database

[Service]
ExecStart=/usr/bin/ssh -N -o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L 15432:db-01.corp.example:5432 deploy@bastion.example
Restart=always
RestartSec=10

[Install]
WantedBy=default.target

Restart=always restarts the service whether ssh exited cleanly or not, including after a signal or a timeout. RestartSec=10 waits ten seconds before each restart. The systemd default is 100 milliseconds, which is too fast when the server stays down for minutes. Enable the unit with systemctl --user enable --now db-tunnel.service.

Security considerations

A forward is a listener, and a listener has an audience. Keep these points in mind:

  • Loopback is shared. Every user on a machine can connect to a listener on 127.0.0.1, so a tunnel on a shared host is open to everyone logged in there.
  • A wildcard bind is a network service. An empty bind address or * makes ssh listen on all interfaces, as ssh(1) describes.
  • The SSH server does the connecting for -L and -D. Anyone who can use that local port gets the same reach the SSH server has toward the destination, which is why PermitOpen matters.
  • The encrypted part ends at the SSH server for -L and -D, and at your machine for -R. Use TLS or another protocol on the last hop for anything sensitive.
  • Give tunnel accounts their own keys (key setup), and limit them with Match blocks as shown above.

Troubleshooting

  • Connection refused on 127.0.0.1:15432. Nothing listens on that port, so the tunnel is not running or ssh exited. Check with ss -ltnp | grep 15432. The ss tool lists listening TCP sockets, and -p shows the process that owns each one.
  • Address already in use. Another process holds the local port, so ssh cannot listen on it. The same ss command shows the owner. Choose another local port.
  • Connection accepted, then it hangs or closes. The SSH server cannot reach the destination. Test the same host and port from the bastion itself, because that is where the connection is opened.
  • Permission denied (publickey) before the tunnel opens. The login failed, so no forward was set up. The fix is in the publickey troubleshooting guide.
  • Remote forward works on the server but not from outside. Check GatewayPorts in the server's sshd_config and the bind address in your -R command.
  • Tunnel stops after a quiet period. Add the keepalive settings and a restart policy from the section above.
  • A forward is missing and nothing reports it. Set ExitOnForwardFailure yes so ssh exits, and run ssh -v for details.

Forwards in a desktop client

RemoteOpsX is a desktop SSH client with local, remote and dynamic port forwarding and bastion (jump host) routes, next to its SSH terminal and SFTP file browser. It runs on Linux and Windows, with Windows in beta and macOS in preview, as of October 2026. It connects to your servers directly, and Darwvin's service only handles sign-in, subscriptions and device approval. A forward created in any client still opens a listener, on your machine for -L and -D and on the server for -R, so the rules in this guide apply there too. Current platform details are on the product page.

FAQ

Does SSH port forwarding encrypt traffic all the way to the destination?

No. SSH encrypts the leg between your machine and the SSH server, and the last hop is outside it. With -L and -D that hop runs from the server to the destination, and with -R it runs from your machine to the service. Use TLS or another end-to-end protection for that hop.

Why can't other computers reach my forwarded port?

That is the default. Local forwards bind to loopback unless you give an explicit bind address or set GatewayPorts to yes in ssh_config, and remote forwards bind to loopback on the server unless its sshd_config allows another address. Open a port to the network only when you mean to.

Can I forward UDP over SSH?

No. The forwards in this guide carry TCP connections, as RFC 4254 section 7 defines them. For UDP-based services, use a VPN or a tool built for UDP.

Why does my tunnel drop after a while?

A sleeping laptop, a network change, or an idle timeout in a NAT device or firewall can break a connection silently. Set ServerAliveInterval and ServerAliveCountMax so ssh notices, and let a supervisor such as systemd or autossh restart it.

Is a SOCKS proxy the same as a VPN?

No. A -D proxy carries only the TCP connections of applications you point at it. SOCKS5 can relay UDP too (RFC 1928 covers both TCP and UDP), but the forwarding SSH sets up is TCP only, as RFC 4254 section 7 defines it. A VPN routes IP traffic at the network layer, so it can carry TCP and UDP for applications that do not know it exists.

References

Get product news by email

We use your address only for these emails. The unsubscribe link is in every email. Read our privacy policy.