OpenSSH can carry TCP connections through an authenticated, encrypted SSH session. The SSH channel protects traffic between the SSH client and SSH server.
Each forward also has a separate connection to its final destination. For local and dynamic forwarding, that connection is opened from the SSH server side. For remote forwarding, it is opened from the SSH client side. That final hop may still use an unencrypted protocol such as HTTP unless the destination service provides TLS.
This guide shows how to create local (-L), remote (-R), and dynamic (-D) forwards on RHEL 8 through 10, Rocky Linux, AlmaLinux, CentOS Stream, and Oracle Linux using one consistent lab topology.
Tested on: Rocky Linux 10.2 with OpenSSH 9.9p1. The forwarding behavior was validated locally with separate loopback services; the examples use
client1,bastion.example.com, andweb.internalto make the client, SSH gateway, and destination roles easier to distinguish.
tunneluser. Root access is not required unless you bind a listener to a privileged port below 1024 or change the OpenSSH server configuration.
SSH port forwarding compatibility and lab topology
| Distribution | Versions | SSH client package | Procedure |
|---|---|---|---|
| RHEL | 8, 9, 10 | openssh-clients |
Same ssh -L, -R and -D syntax |
| Rocky Linux | 8, 9, 10 | openssh-clients |
Same procedure |
| AlmaLinux | 8, 9, 10 | openssh-clients |
Same procedure |
| CentOS Stream | 9, 10 | openssh-clients |
Same procedure |
| Oracle Linux | 8, 9, 10 | openssh-clients |
Same procedure |
Confirm the client tools on the machine where you run ssh:
ssh -VSample output:
OpenSSH_9.9p1, OpenSSL 3.5.5 27 Jan 2026rpm -q openssh-clientsSample output:
openssh-clients-9.9p1-23.el10_2.rocky.0.1.x86_64On a host that accepts SSH connections, confirm the server package and service:
rpm -q openssh-serversystemctl status sshdSample output:
● sshd.service - OpenSSH server daemon
Loaded: loaded (/usr/lib/systemd/system/sshd.service; enabled; preset: enabled)
Active: active (running) since Sat 2026-07-11 16:21:02 IST; 1h 16min ago
Main PID: 888 (sshd)Use one topology and the same names throughout the examples:
| Role | Example |
|---|---|
| SSH client | client1 |
| SSH server or gateway | bastion.example.com |
| Internal target | web.internal:8080 |
| SSH account | tunneluser |
| Local forwarded port | 5555 |
| Remote forwarded port | 9000 |
| SOCKS port | 1080 |
For local forwarding (-L), the target hostname is resolved and contacted from the SSH server side. For remote forwarding (-R), the target is contacted from the SSH client side.
Dynamic forwarding (-D) also creates destination connections from the SSH server side, but hostname resolution depends on the SOCKS client. Use a proxy URL such as socks5h:// when the destination hostname must be resolved through the remote network.
Local, remote and dynamic SSH forwarding compared
| Forwarding type | Listener is created on | Destination is reached from | Typical use |
|---|---|---|---|
Local -L |
SSH client | SSH server side | Access an internal web or database service |
Remote -R |
SSH server | SSH client side | Make a client-side service reachable from the SSH server |
Dynamic -D |
SSH client | SSH server side | Create a local SOCKS proxy |
OpenSSH defines the three forms with these templates:
ssh -L [bind_address:]local_port:target_host:target_port user@ssh_server
ssh -R [bind_address:]remote_port:target_host:target_port user@ssh_server
ssh -D [bind_address:]socks_port user@ssh_serverCommon options used in this guide:
| Option | Purpose |
|---|---|
-N |
Do not run a remote command |
-T |
Do not allocate a pseudo-terminal |
-f |
Move SSH to the background after authentication |
-v |
Show diagnostic output |
-o ExitOnForwardFailure=yes |
Exit if SSH cannot create the requested listener |
-g |
Allow other hosts to connect to a locally forwarded port |
Test a new tunnel in the foreground first:
ssh -N -T -v -o ExitOnForwardFailure=yes ...Add -f only after the listener and destination path work.
Check SSH server forwarding permissions
Forwarding can be restricted on the SSH server. Read the effective runtime configuration:
sshd -T | grep -Ei \
'allowtcpforwarding|disableforwarding|gatewayports|permitopen|permitlisten'Sample output:
gatewayports no
allowtcpforwarding yes
disableforwarding no
permitopen any
permitlisten anyWhen forwarding permissions are configured inside a Match block, include representative connection details so sshd applies the matching rules:
sshd -T \
-C user=tunneluser,addr=192.0.2.50,host=client1 \
| grep -Ei \
'allowtcpforwarding|disableforwarding|gatewayports|permitopen|permitlisten'Replace the user, client address, and hostname with values from the connection you are testing. Without -C, the output might not reflect restrictions applied by Match User, Match Address, or similar conditional blocks. AllowTcpForwarding, GatewayPorts, PermitOpen, and PermitListen are all valid inside Match blocks.
Relevant sshd_config directives:
AllowTcpForwarding yes|no|local|remote— controls whether local or remote TCP forwarding is acceptedDisableForwarding yes|no— disables forwarding when set toyesPermitOpen host:port— restricts destination addresses for forwarded connectionsPermitListen host:port— restricts remote-forward listener addresses and portsGatewayPorts no|yes|clientspecified— controls whether remote-Rlisteners can bind beyond loopback
There are two different GatewayPorts settings:
GatewayPortsin the SSH client configuration controls the default bind behavior of local and dynamic forwards created with-Land-D.GatewayPortsin the SSH server configuration controls whether remote forwards created with-Rmay bind beyond loopback.
The -g client option allows other hosts to connect to locally created forwarded ports. OpenSSH documents client GatewayPorts for local forwarding and server GatewayPorts for remote listeners. The server setting accepts no, yes, or clientspecified; its default is no.
For a dedicated tunnel account, place restrictions in a Match User tunneluser block when you need tighter policy than the global defaults.
After editing the SSH server configuration, validate and reload:
sshd -tA valid configuration exits silently. On success:
systemctl reload sshdKeep an existing administrative SSH session open while reloading a remote sshd instance so a syntax error does not lock you out.
Configure local SSH port forwarding
Local forwarding sends traffic from a listener on the SSH client through the encrypted SSH session; the SSH server opens the connection to the target service.
client1:5555
|
| encrypted SSH connection
v
bastion.example.com
|
| connection from bastion
v
web.internal:8080
Create the tunnel from client1:
ssh -N -T \
-o ExitOnForwardFailure=yes \
-L 127.0.0.1:5555:web.internal:8080 \
[email protected]127.0.0.1:5555is the listener on the SSH client.web.internal:8080is resolved and contacted from the bastion.[email protected]establishes the encrypted SSH session.
In another terminal, confirm the listener:
ss -lntp | grep ':5555'Sample output:
LISTEN 0 128 127.0.0.1:5555 0.0.0.0:* users:(("ssh",pid=14018,fd=4))Send HTTP traffic through the tunnel:
curl http://127.0.0.1:5555The curl command should return the same response you would see when contacting web.internal:8080 from the bastion.
When the target service runs on the SSH server itself, point the remote side at loopback on the bastion:
ssh -N -T \
-o ExitOnForwardFailure=yes \
-L 127.0.0.1:5555:127.0.0.1:8080 \
[email protected]Only the target hostname changes; the forwarding mechanics stay the same.
Allow another host to use the local forward
Binding to a specific client-side address is usually clearer than opening the forward to every interface. Confirm that the bind address exists on the SSH client:
ip address showThen bind the forward to that address:
ssh -N -T \
-o ExitOnForwardFailure=yes \
-L 192.0.2.10:5555:web.internal:8080 \
[email protected]Replace 192.0.2.10 with an address assigned to the client host. OpenSSH cannot create the listener when the requested address is not locally available.
ssh -g can also allow other hosts to connect to a local forward, but that increases exposure. The client firewall must allow the chosen port when another host connects. Avoid an empty bind address unless listening on all interfaces is intentional.
Configure remote SSH port forwarding
Remote forwarding is useful when a service runs on the SSH client and you want a listener on the SSH server that reaches back to the client.
Example scenario:
- A web application runs on
client1:3000. public.example.comaccepts SSH connections.- A loopback listener on
public.example.com:9000forwards toclient1:3000.
Create the tunnel from client1:
ssh -N -T \
-o ExitOnForwardFailure=yes \
-R 127.0.0.1:9000:127.0.0.1:3000 \
[email protected]127.0.0.1:9000is created on the SSH server.127.0.0.1:3000is contacted from the SSH client.- Keep the remote listener on loopback unless you deliberately need a wider bind.
From public.example.com, verify the listener:
ss -lntp | grep ':9000'Sample output:
LISTEN 0 128 127.0.0.1:9000 0.0.0.0:* users:(("sshd-session",pid=14298,fd=8))Test the forwarded path:
curl http://127.0.0.1:9000For persistent public listeners, firewall rules, and GatewayPorts hardening, see the reverse SSH port forwarding guide.
Expose a remote forward to other hosts
This is a higher-risk configuration. The SSH server must permit a non-loopback bind, normally with:
GatewayPorts clientspecifiedThe client can then request:
ssh -N -T \
-o ExitOnForwardFailure=yes \
-R 0.0.0.0:9000:127.0.0.1:3000 \
[email protected]Also remember:
PermitListencan restrict the allowed listener.firewalldmust allow the remote port when other hosts should connect.- The exposed application should enforce its own authentication.
- Binding a remote forward to all interfaces can publish an internal service publicly.
Configure dynamic SSH port forwarding
-D creates a SOCKS4/SOCKS5 proxy on the SSH client. Applications choose the destination for each connection, and the SSH server opens those destination connections from its side. This is appropriate for reaching services you are authorized to access through an approved SSH gateway—not for evading firewall policy.
Create a loopback-only SOCKS listener:
ssh -N -T \
-o ExitOnForwardFailure=yes \
-D 127.0.0.1:1080 \
[email protected]Confirm the listener:
ss -lntp | grep ':1080'Sample output:
LISTEN 0 128 127.0.0.1:1080 0.0.0.0:* users:(("ssh",pid=14286,fd=4))Test the proxy with curl:
curl --proxy socks5h://127.0.0.1:1080 \
http://web.internal:8080socks5h asks the proxy side to resolve web.internal, which matters when that name exists only on the remote network. For browser and application SOCKS setup, see the SSH SOCKS proxy guide.
Save, background and close SSH tunnels safely
A reusable SSH client config entry keeps the forward settings in one place:
Host app-tunnel
HostName bastion.example.com
User tunneluser
LocalForward 127.0.0.1:5555 web.internal:8080
ExitOnForwardFailure yes
ServerAliveInterval 30
ServerAliveCountMax 3Start the tunnel in the foreground:
ssh -N -T app-tunnelFor a controlled background connection, create a private control-socket directory:
mkdir -p ~/.ssh/controlchmod 700 ~/.ssh/controlStart a multiplexed master connection:
ssh -M \
-S ~/.ssh/control/app-tunnel \
-f -N -T \
-o ExitOnForwardFailure=yes \
app-tunnelCheck whether the master is running:
ssh -S ~/.ssh/control/app-tunnel -O check app-tunnelSample output:
Master running (pid=14480)Close the tunnel cleanly:
ssh -S ~/.ssh/control/app-tunnel -O exit app-tunnelSample output:
Exit request sent.OpenSSH connection multiplexing can add or cancel forwarding channels on an existing master connection, so a brand-new SSH process is not always required for every forward change. For a reusable path across hosts, ControlPath ~/.ssh/control/%C avoids accidental socket-name collisions; OpenSSH recommends including %h, %p, and %r, or %C, so different destinations do not share one socket name.
Do not use kill -9 as the normal way to stop a tunnel. A normal kill, a control-socket exit, or a supervising systemd unit lets SSH shut down forwarding cleanly. For idle disconnects and keepalive tuning, see keep alive SSH sessions in Linux. A boot-persistent tunnel belongs in a supervised unit file—a topic for a separate article.
Optional verification with tcpdump: tcpdump -ni any port 22 shows the encrypted SSH leg on port 22, while the gateway-to-target connection appears with the destination protocol and port.
Troubleshoot SSH port forwarding
Bind address or port already in use
Check whether another process owns the listener port:
ss -lntp | grep ':5555'Choose a free port or stop the conflicting process. ExitOnForwardFailure=yes prevents SSH from continuing silently when it cannot create the listener.
open failed: administratively prohibited
Run the client with verbose output:
ssh -vvv ...On the SSH server, inspect forwarding policy:
sshd -T | grep -Ei \
'allowtcpforwarding|disableforwarding|permitopen|permitlisten'When restrictions may live in a Match block, repeat the check with connection criteria:
sshd -T \
-C user=tunneluser,addr=192.0.2.50,host=client1 \
| grep -Ei \
'allowtcpforwarding|disableforwarding|permitopen|permitlisten'Also check restrictions on the user's public key, such as no-port-forwarding, restrict, or permitopen=.
Tunnel opens but the destination refuses the connection
For local and dynamic forwarding, test the target from the SSH server:
curl http://web.internal:8080For remote forwarding, test the destination from the SSH client. ExitOnForwardFailure=yes confirms that the forwarding listener was created; it does not prove the final service is reachable.
Remote forwarding listens only on localhost
Check the -R bind address and the server's GatewayPorts value. The default server behavior keeps remote forwards on loopback.
Another host cannot reach a forwarded listener
Inspect the bind address:
ss -lntpIdentify the active firewalld zone:
firewall-cmd --get-active-zonesInspect the applicable zone:
firewall-cmd --zone=public --list-allCheck the forwarded listener port directly:
firewall-cmd --zone=public --query-port=5555/tcpReplace public and 5555 with the active zone and listener port on your host. For a public remote forward, use 9000/tcp instead.
Confirm the listener uses the expected address and that the zone allows the port when non-loopback access is required.
Tunnel disconnects after becoming idle
Add keepalive options to ~/.ssh/config:
ServerAliveInterval 30
ServerAliveCountMax 3These detect an unresponsive SSH connection. For automatic restart after failure, supervise the tunnel with systemd rather than an unmanaged background process.
| Symptom | Likely cause | Main check |
|---|---|---|
| Address already in use | Listener port occupied | ss -lntp |
| Administratively prohibited | SSH server restriction | AllowTcpForwarding, PermitOpen, PermitListen |
| Connection refused | Final service unreachable | Test from the side that creates the final connection |
| Remote listener is loopback-only | GatewayPorts no |
sshd -T |
| Other systems cannot connect | Bind address or firewalld | ss, firewall-cmd |
| SOCKS hostname does not resolve | DNS resolved on wrong side | Use socks5h:// |
Conclusion
-Lcreates a listener on the SSH client.-Rcreates a listener on the SSH server.-Dcreates a local SOCKS proxy.- Use loopback bind addresses by default.
- Add
ExitOnForwardFailure=yes. - Verify listeners with
ssand services withcurl. - Restrict forwarding with
AllowTcpForwarding,PermitOpen, andPermitListen. - Use control sockets or systemd instead of
kill -9.
References
- ssh(1) —
-L,-R,-D, and client options - ssh_config(5) —
LocalForward,ExitOnForwardFailure, keepalives,GatewayPorts, and control multiplexing - sshd_config(5) —
AllowTcpForwarding,PermitOpen,PermitListen, andGatewayPorts - RFC 1928 — SOCKS Protocol Version 5

