Read the forwarding rule from left to right
ssh -N \
-L 127.0.0.1:18080:127.0.0.1:8080 \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
user@example-hostThe first address belongs to the client machine. SSH opens 127.0.0.1:18080 locally. For every accepted connection, the SSH server opens a connection to 127.0.0.1:8080 from the server's point of view. The destination service can stay on server loopback; no public firewall rule is needed for its port.
Binding the local side to 127.0.0.1 matters. A bare -L 18080:... follows the client's gateway setting and may listen more broadly than intended.
Make setup failure visible
ExitOnForwardFailure=yes makes SSH exit if it cannot create the local listener or the server rejects forwarding. It does not prove that the final service is healthy, so test that separately.
curl --fail --show-error http://127.0.0.1:18080/On Linux, inspect the local socket with ss -lntp. On Windows, use PowerShell:
Get-NetTCPConnection -LocalPort 18080 -State ListenKeep the SSH process handle or PID. When the test ends, stop only that process. Killing every SSH process can break unrelated sessions and existing tunnels.
Restrict what the server permits
OpenSSH can allow local forwarding without allowing reverse forwarding. A narrow server policy looks like this:
AllowTcpForwarding local
GatewayPorts no
PermitOpen 127.0.0.1:8080 127.0.0.1:9090AllowTcpForwarding local permits -L and dynamic local forwarding while rejecting -R. PermitOpen limits destinations reachable through the tunnel. Leaving it as any turns a stolen SSH key into a more useful pivot point.
sudo sshd -t
sudo sshd -T | grep -E '^(allowtcpforwarding|gatewayports|permitopen) 'Check effective output, not just the drop-in file. OpenSSH's first-value rules and Match blocks can produce a different result from a quick visual read.
Validate after an SSH reload
Reloading sshd does not normally disconnect established sessions. That safety feature creates a testing trap: an old tunnel can keep working even when the new configuration blocks every new tunnel.
After a reload, open a second SSH connection with connection sharing disabled. Create a second tunnel on a different local port and fetch the service through it. Commit a guarded SSH change only after both checks pass.
Tunnel checklist
- Bind the local listener to loopback.
- Use strict host-key checking and fail closed in automation.
- Set
ExitOnForwardFailure=yes. - Constrain server-side destinations with
PermitOpen. - Test the application, not only the listening socket.
- After an SSH reload, verify a completely new session and tunnel.
- Track and stop only the temporary tunnel process.