SSH Port Forwarding Explained: Local, Remote, Dynamic and Jump Hosts

Local, remote and dynamic SSH tunnels with exact OpenSSH syntax, plus jump hosts, tunnels that survive reboots, server-side limits and the common error messages decoded.

Title card reading “SSH tunnels: -L, -R, -D” for the HourlyVPS guide to SSH port forwarding

SSH port forwarding carries TCP connections through an encrypted SSH session. Local forwarding (ssh -L) makes a service on the server’s side appear on a port of your own computer, remote forwarding (ssh -R) makes a service on your computer appear on a port of the server, and dynamic forwarding (ssh -D) turns ssh into a SOCKS proxy that connects wherever your browser or app asks.

All three are built into the OpenSSH client on Linux, macOS and Windows, and the OpenSSH server on Ubuntu allows them by default. This guide gives the exact syntax from the OpenSSH manual pages (OpenSSH 9.6p1 on Ubuntu 24.04 LTS), then jump hosts, tunnels that survive drops and reboots, the server settings that control forwarding, and the exact error messages with their fixes. New to SSH? Start with how to connect to a VPS with SSH.

Key takeaways

  • -L listens on your computer and connects onward from the server, -R listens on the server and connects onward from your computer, and -D turns ssh into a SOCKS proxy; all three bind to loopback unless you give an address.
  • Reach databases and admin panels that listen on 127.0.0.1 through a tunnel, such as ssh -N -L 5433:127.0.0.1:5432 user@server, instead of opening their ports in the firewall.
  • With the server default GatewayPorts no, sshd binds -R 0.0.0.0:8080 to loopback without any error; put Caddy in front of the loopback port for public HTTPS, or set GatewayPorts clientspecified.
  • A tunnel that stays up needs ServerAliveInterval, ExitOnForwardFailure=yes and a supervisor; on Linux a systemd unit with Restart=always does the job autossh used to do.
  • Give unattended tunnels a restricted key (restrict,port-forwarding,permitopen=...) and keep SOCKS and forwarded ports off public interfaces, because ssh’s SOCKS server has no password.

SSH port forwarding cheat sheet: -L, -R, -D and -J

Each flag answers two questions: which machine opens the listening port, and which machine makes the onward connection. Read a forward spec from left to right: first the port that listens, then where its connections go.

FlagSyntax (ssh(1))Listens onOnward connection made byTypical job
-L-L [bind_address:]port:host:hostportYour computer (loopback by default)The SSH serverOpen a database or admin panel that listens only on the server’s localhost
-R-R [bind_address:]port:host:hostportThe SSH server (loopback by default)Your computerShow an app running on your laptop through your VPS
-D-D [bind_address:]portYour computer, as a SOCKS4/SOCKS5 serverThe SSH server, to any host the app asks forBrowse or test from the server’s IP address
-J-J destination, written [user@]host[:port]NothingThe jump host, to the next SSH serverReach a server whose SSH port only a bastion can reach
-N-Nn/an/aOpen the forwards without running a remote shell
Syntax from the ssh(1) manual page for OpenSSH 9.6p1 (Ubuntu 24.04 LTS). -L and -R also accept Unix socket paths in place of ports.
Local and remote SSH port forwarding. With ssh -L, your computer listens on port 5433 and the VPS makes the onward connection to PostgreSQL on 127.0.0.1:5432. With ssh -R, the VPS listens on 127.0.0.1:8080 and your computer makes the onward connection to your app on port 3000. Only the SSH leg is encrypted.ssh -L 5433:127.0.0.1:5432 alex@vpsYour computerlistens on 127.0.0.1:5433SSH, encryptedVPS: sshdconnects onwardPostgreSQL127.0.0.1:5432ssh -R 8080:localhost:3000 alex@vpsYour applocalhost:3000Your computer: sshconnects onwardSSH, encryptedVPS: sshdlistens on 127.0.0.1:8080Thick arrow: inside the SSH connection. Thin arrow: a plain TCP connection made at that end.

Three rules prevent most of the confusion:

  • The destination is resolved at the far end. In -L 5433:localhost:5432, localhost means the server, not your laptop. In -R 8080:localhost:3000 it means your computer.
  • Loopback is the default. Without a bind address, -L and -D listen on your computer’s loopback interface, and -R listens on the server’s loopback interface. Exposing a port to other machines takes an explicit address, -g, or a GatewayPorts setting.
  • Only the SSH leg is encrypted. When the destination is a third machine, the hop from the SSH server to it is an ordinary TCP connection.

Local port forwarding (-L): reach a service that listens on localhost

Local forwarding is the tunnel you will use most. A service that listens only on the server’s loopback address cannot be reached from the internet, which is what you want for databases and admin panels. PostgreSQL works this way out of the box: its listen_addresses setting defaults to localhost. -L lets you reach it anyway, without opening a port in the firewall.

Run this on your own computer, with your server’s user and IP address:

ssh -N -L 5433:127.0.0.1:5432 [email protected]

Leave that terminal open. ssh now listens on port 5433 of your computer; every connection to that port travels through SSH and leaves the server as a connection to 127.0.0.1:5432. In a second terminal, connect as if the database ran locally:

psql -h 127.0.0.1 -p 5433 -U appuser -d appdb
PartMeaning
-NDo not run a remote command; ssh(1) calls it “useful for just forwarding ports”.
5433The local port. Pick any free port above 1023: “Only the superuser can forward privileged ports.”
127.0.0.1The destination host, resolved on the server. Writing the address instead of localhost avoids surprises with IPv6 and with key restrictions (see below).
5432The destination port on that host.
alex@203.0.113.10The SSH login that carries the tunnel.
Anatomy of a local forward.

Why 5433 and not 5432? If PostgreSQL also runs on your computer, it already holds 5432 and the forward fails with “Address already in use”. On the server, PostgreSQL sees each tunneled connection arriving from 127.0.0.1, so the loopback rules in pg_hba.conf apply and port 5432 never has to be opened. Installing and securing the database itself is covered in how to install PostgreSQL on a VPS.

More -L patterns: Docker, several ports, a third host

  • A container’s web UI. Ports that Docker publishes go around UFW, so bind them to 127.0.0.1 on the server and tunnel in with ssh -N -L 8080:127.0.0.1:8080 [email protected], then open http://localhost:8080.
  • Several ports in one session. Repeat the flag, as in the command below.
  • A machine behind the server. ssh -N -L 8443:10.0.0.7:443 [email protected] reaches 10.0.0.7 as the server sees it. SSH does not encrypt the hop from the server to 10.0.0.7, so use it for services that encrypt themselves (HTTPS here) or networks you trust.
  • Sharing a forward with your LAN. -L 0.0.0.0:5433:127.0.0.1:5432 listens on every IPv4 address of your computer (-g opens every address, IPv6 included), so anyone who can reach your computer reaches the database’s login prompt. Keep the default unless you need this.
ssh -N -L 5433:127.0.0.1:5432 -L 8080:127.0.0.1:8080 [email protected]

On Windows: PowerShell or PuTTY

Windows 10 (version 1809 and later) and Windows 11 include Microsoft’s OpenSSH client as an optional feature, so the same ssh -N -L command works in PowerShell or Windows Terminal. In PuTTY, open Connection → SSH → Tunnels, select Local, enter 5433 as the source port and 127.0.0.1:5432 as the destination, click Add, then open the session. The PuTTY manual notes that forwarding starts only after you log in, and the Remote and Dynamic buttons on the same panel correspond to -R and -D.

Remote port forwarding (-R): share a local app through your VPS

Remote forwarding works in the other direction. The server opens the listening port, and each connection to it is carried back through SSH to a service on or near your computer. Typical jobs: showing a client the app on your laptop, receiving a webhook while you develop, or reaching a machine at home that has no public IP address.

Run this on your computer while your app listens on port 3000:

ssh -N -R 8080:localhost:3000 [email protected]

On the server, curl -I http://127.0.0.1:8080 now reaches the app on your laptop. Here localhost is resolved on your computer, because that is where the onward connection starts. If you give port 0 (-R 0:localhost:3000), the server picks a free port and ssh prints it: “Allocated port 41235 for remote forward to localhost:3000” (the number varies).

Why does -R 0.0.0.0:8080 still listen on 127.0.0.1 only?

Because the server decides, and by default it says no. The server’s GatewayPorts option (sshd_config(5)) controls which address a remote forward may bind to:

Server GatewayPorts-R 8080:… (no address)-R 0.0.0.0:8080:…
no (default)Loopback onlyLoopback only. The requested address is ignored, and ssh prints no error
yesAll interfacesAll interfaces
clientspecifiedLoopback only0.0.0.0, every IPv4 address (an empty address or * means every interface)
Binding rules from sshd_config(5) and the bind-address logic in channels.c of OpenSSH 9.6p1.

To publish a remote forward directly, set GatewayPorts clientspecified on the server (shown in server settings below), allow the port with sudo ufw allow 8080/tcp, and ask for the address explicitly with ssh -N -R 0.0.0.0:8080:localhost:3000 [email protected]. Visitors then reach your laptop over plain HTTP. Remove the firewall rule with sudo ufw delete allow 8080/tcp when the demo ends.

A cleaner way to go public: keep loopback and put Caddy in front

If Caddy already runs on the server (set up in the Caddy reverse proxy guide), leave GatewayPorts at no and add a site that proxies to the tunnel’s loopback port. Visitors get HTTPS with a publicly trusted certificate, only ports 80 and 443 stay open, and the app disappears the moment you close the tunnel. Add this block to /etc/caddy/Caddyfile, with a hostname whose DNS points at the server:

dev.example.com {
	reverse_proxy localhost:8080
}

Reload Caddy with sudo systemctl reload caddy, start the -R tunnel from your laptop, and share https://dev.example.com. While the tunnel is down, Caddy answers 502 Bad Gateway instead of exposing anything. The directive is documented in Caddy’s reverse_proxy reference.

A remote forward with only a port, such as -R 1080, makes ssh act as a SOCKS proxy for programs on the server, letting them reach the network your computer sits on. OpenSSH added this in version 7.6 and implements it entirely in the client; PermitRemoteOpen in ssh_config limits where it may connect.

Cost: a tunnel endpoint needs SSH and, optionally, Caddy, so the smallest plan is enough. A four-hour demo on Quartz Q1 at $0.01/hour costs $0.04 in total. An always-on bastion is billed the same way, by the hour, but never costs more than $5.00 in a billing period (one month from your order date): the monthly cap applies by itself, with no plan to switch to. A stopped server is still billed, because its vCPU, memory, disk and IP addresses stay reserved for you; only deleting the server stops billing. The hourly billing guide explains the cap and the initial credit each new server is ordered with.

Dynamic port forwarding (-D): a SOCKS proxy through your server

With -D, ssh becomes a SOCKS4 and SOCKS5 server on your computer. Apps that support SOCKS hand each connection to it, and the SSH server opens the onward connection to whatever host the app asked for, so websites see the server’s IP address instead of yours. Common uses: checking how a site behaves from the server’s country, opening an admin page that only allows your server’s IP, or getting through an untrusted Wi-Fi network for an afternoon.

ssh -N -D 127.0.0.1:1080 [email protected]

Writing 127.0.0.1 keeps the proxy private to your computer even if your ssh_config sets GatewayPorts yes. Test it from a second terminal. curl’s –socks5-hostname option lets the proxy resolve hostnames, so DNS lookups also travel through the tunnel:

curl -sI --socks5-hostname 127.0.0.1:1080 https://example.com

Then point your browser at it:

  • Firefox: open Settings → General and click Configure proxy under Proxy settings (Network Settings → Settings… in older versions). Choose Manual proxy configuration, enter SOCKS Host 127.0.0.1, Port 1080, select SOCKS v5 and make sure Proxy DNS when using SOCKS v5 is ticked; current releases tick it by default (Mozilla’s policy documentation lists the same switch as UseProxyForDNS).
  • Chrome and other Chromium browsers: quit any running instance first (open chrome://quit), as Chromium’s guide to command-line flags says, then start the browser with the two flags below. Chromium’s SOCKS documentation explains that the proxy resolves hostnames for page loads, and that the second flag stops other components, such as the DNS prefetcher, from resolving names locally. On macOS and Windows, pass the same flags to the Chrome executable.
google-chrome --proxy-server="socks5://127.0.0.1:1080" --host-resolver-rules="MAP * ~NOTFOUND , EXCLUDE 127.0.0.1"

Warning: ssh’s SOCKS server has no password; OpenSSH 9.6p1 offers only SOCKS5’s “no authentication” method. A SOCKS or forwarded port on 0.0.0.0, whether on your laptop via -g or on the server via a remote SOCKS forward, lets anyone who finds it send traffic out from your server’s IP address. Our Acceptable Use Policy bans open proxies that let unauthenticated third parties route traffic through your server, so keep these ports on 127.0.0.1. Local laws and the rules of the network you are on still apply.

SSH tunnel vs VPN: which do you need?

A SOCKS tunnel carries only the apps you point at it, and only TCP: OpenSSH forwards TCP connections and Unix sockets, and its SOCKS server handles the CONNECT command only. Anything that uses UDP needs a VPN.

NeedSSH tunnelVPN (WireGuard, Tailscale)
Reach one service on the serverYes, with -L; nothing to installYes, with more setup
Send every app on the device through the serverNo, only apps set to use the SOCKS proxyYes
UDP traffic (games, voice calls)NoYes
Software on the serversshd, already runningWireGuard or Tailscale
Network allows only TCPWorks over the SSH portPlain WireGuard cannot connect; Tailscale falls back to relayed connections over HTTPS, more slowly
Pick the tunnel for single services and quick jobs, the VPN for whole devices.

For a whole-device VPN, see WireGuard on a VPS for a trip or a Tailscale exit node on a VPS, and WireGuard vs OpenVPN compares the two common VPN protocols. The server’s location also sets the latency every tunneled request pays, which choosing a VPS location covers.

Jump hosts with -J and ProxyJump

A jump host, or bastion, is a server you pass through to reach another one. -J logs in to the jump host first, then opens a TCP forward from there to the target, and your client runs a second, separate SSH session with the target through that forward. The jump host only relays encrypted bytes, and your private keys never leave your computer. The option arrived in OpenSSH 7.3.

ssh -J [email protected] [email protected]

A common pattern is to let only the bastion reach a database server’s SSH port. On the database server (198.51.100.20), allow SSH from the bastion’s address:

sudo ufw allow proto tcp from 203.0.113.10 to any port 22

Then, from a second terminal and while your current session stays open, confirm that the -J login works, and only then remove the general rule with sudo ufw delete allow OpenSSH. If you lock yourself out anyway, the VNC console in the portal still works (see the lockout recovery steps).

Forwards combine with -J and apply to the final host, so 127.0.0.1 below is the database server’s own loopback address:

ssh -J [email protected] -N -L 5433:127.0.0.1:5432 [email protected]

Separate several hops with commas (-J hop1,hop2). ssh(1) notes that options typed on the command line apply to the destination, not to the jump hosts, so put a jump host’s User, Port or IdentityFile in ~/.ssh/config. Prefer -J over agent forwarding (-A): the manual warns that anyone able to bypass file permissions on the remote host can use your forwarded agent, and names a jump host as the safer alternative.

Save tunnels in ~/.ssh/config

Every flag has a keyword in ssh_config(5). Note the space, not a colon, between the listening side and the destination in LocalForward and RemoteForward:

Host vps1
    HostName 203.0.113.10
    User alex
    IdentityFile ~/.ssh/id_ed25519

Host db-tunnel
    HostName 198.51.100.20
    User deploy
    ProxyJump vps1
    LocalForward 5433 127.0.0.1:5432
    SessionType none
    ExitOnForwardFailure yes

Host socks
    HostName 203.0.113.10
    User alex
    DynamicForward 127.0.0.1:1080
    SessionType none

Host share-dev
    HostName 203.0.113.10
    User alex
    RemoteForward 8080 localhost:3000
    SessionType none
    ExitOnForwardFailure yes

Host *
    ServerAliveInterval 30
    ServerAliveCountMax 3

Now ssh db-tunnel opens the database tunnel through the bastion, ssh socks starts the proxy and ssh share-dev publishes port 3000 on the server. Give tunnels their own aliases: if vps1 itself carried a LocalForward, a second ssh vps1 would try to bind the same port again. ssh uses the first value it reads for each option, so keep Host * last, and run ssh -G db-tunnel to print the settings it will use. SessionType and ForkAfterAuthentication arrived in OpenSSH 8.7 (2021); an older client stops with “Bad configuration option”, so check ssh -V, and on an old client drop those lines and run ssh -N db-tunnel instead. Aliases themselves are explained in the SSH connection guide.

Command-line flagssh_config keyword
-L 5433:127.0.0.1:5432LocalForward 5433 127.0.0.1:5432
-R 8080:localhost:3000RemoteForward 8080 localhost:3000
-D 127.0.0.1:1080DynamicForward 127.0.0.1:1080
-J vps1ProxyJump vps1
-NSessionType none
-fForkAfterAuthentication yes
-gGatewayPorts yes
-o ExitOnForwardFailure=yesExitOnForwardFailure yes
Equivalents from ssh(1) and ssh_config(5), OpenSSH 9.6p1.

How do you keep an SSH tunnel alive?

Idle tunnels die for two reasons: a router or firewall on the path forgets an idle connection, or the network drops and ssh keeps waiting without noticing. Keepalives fix the first and detect the second; a supervisor restarts the tunnel when it exits.

Keepalives: ServerAliveInterval and ClientAliveInterval

ServerAliveInterval 30 makes the client send a message through the encrypted channel after 30 seconds without data from the server. After ServerAliveCountMax unanswered messages (default 3), ssh prints “Timeout, server 203.0.113.10 not responding.” and exits, about 90 seconds after the server went away. ssh_config(5) points out that these messages cannot be spoofed, unlike the TCP keepalives of TCPKeepAlive. Ubuntu’s and Debian’s builds also default ServerAliveInterval to 300 seconds when BatchMode is on.

The server has the mirror image: ClientAliveInterval and ClientAliveCountMax, off by default (ClientAliveInterval 0). They matter for -R. When a client vanishes, sshd keeps its old listening port until it notices, and the reconnecting client meanwhile gets “remote port forwarding failed”. The autossh manual recommends ClientAliveInterval on the server for this reason. On the server, add a drop-in file:

sudo tee /etc/ssh/sshd_config.d/10-tunnels.conf > /dev/null <<'EOF'
# Close sessions whose client stopped answering (about 90 seconds),
# so their -R listening ports are freed for the next connection.
ClientAliveInterval 30
ClientAliveCountMax 3
EOF
sudo sshd -t && sudo systemctl reload ssh

Files in /etc/ssh/sshd_config.d/ load in name order and sshd keeps the first value it reads, so make sure no earlier file sets the same options. The same commands work on Debian 12, where ssh.service runs without socket activation.

Run a tunnel in the background, then stop it cleanly

-f sends ssh to the background after you log in. With ExitOnForwardFailure=yes, it exits instead when a forward cannot be set up, and it waits for remote forwards to be confirmed before it backgrounds. A control socket (-M -S) gives you a handle on that exact tunnel, so you can check or close it later without hunting for its process ID:

ssh -f -N -M -S ~/.ssh/db-tunnel.sock -o ExitOnForwardFailure=yes -L 5433:127.0.0.1:5432 [email protected]
ssh -S ~/.ssh/db-tunnel.sock -O check [email protected]
ssh -S ~/.ssh/db-tunnel.sock -O exit [email protected]

The Win32-OpenSSH project lists background mode and client ControlMaster as not supported on Windows, so there you keep the tunnel in its own terminal window or use PuTTY. On Linux and macOS, a tunnel started with -f but no control socket is an ordinary process: find it with pgrep -af 'ssh -f' and stop it with kill and its process ID.

In an interactive session you can also add a forward without reconnecting: press Enter, then ~C, and type a spec such as -L 8080:127.0.0.1:8080; ~# lists the forwarded connections and ~. closes the connection with all its forwards. Since OpenSSH 9.2 that command line is off by default (EnableEscapeCommandline) and answers “commandline disabled”, so start the session with ssh -o EnableEscapeCommandline=yes [email protected] if you want it.

Survive reboots with a systemd service, no autossh required

For a tunnel that should always be up, such as a home machine that keeps a -R port open on your VPS, let systemd restart plain ssh. Keepalives make ssh exit when the link dies, ExitOnForwardFailure makes it exit when the port cannot be opened, and systemd’s Restart=always starts it again 10 seconds later. Create the unit on the machine that starts the tunnel:

sudo tee /etc/systemd/system/ssh-tunnel-vps1.service > /dev/null <<'EOF'
[Unit]
Description=Reverse SSH tunnel: vps1 port 8080 to local port 3000
Wants=network-online.target
After=network-online.target

[Service]
User=alex
ExecStart=/usr/bin/ssh -N -o BatchMode=yes -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R 8080:localhost:3000 [email protected]
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now ssh-tunnel-vps1.service
systemctl status ssh-tunnel-vps1.service

BatchMode=yes stops ssh from waiting at a password or host-key prompt that nobody will answer. So connect once by hand as alex to accept the server’s host key, and give the service a key it can use without typing a passphrase, ideally the restricted tunnel key described below. Unit files, logs and restart limits are covered in how to create a systemd service.

When autossh still helps

autossh (sudo apt install autossh) starts ssh, watches it and restarts it. Its manual says that with a recent OpenSSH, turning its monitoring port off (-M 0) and relying on ServerAliveInterval and ServerAliveCountMax “may be a better solution” than its own monitor. It is useful where no service manager restarts ssh for you:

autossh -M 0 -f -N -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" -o ExitOnForwardFailure=yes -L 5433:127.0.0.1:5432 [email protected]

Two details from the manual: without -f, autossh gives up if ssh fails within its first 30 seconds (the “starting gate”; set AUTOSSH_GATETIME=0 when you start it at boot), and with -f, ssh cannot ask for passwords or passphrases.

Server settings: AllowTcpForwarding, GatewayPorts, PermitOpen and PermitListen

Forwarding is the server’s decision. Ubuntu 24.04’s shipped /etc/ssh/sshd_config leaves AllowTcpForwarding and GatewayPorts commented out at their OpenSSH defaults, so TCP forwarding is allowed and remote forwards stay on loopback. Print the values sshd actually uses:

sudo sshd -T | grep -Ei 'allowtcpforwarding|gatewayports|permitopen|permitlisten|disableforwarding|clientalive'
OptionDefaultWhat it controls
AllowTcpForwardingyesyes or all, no, local (only -L and -D) or remote (only -R)
GatewayPortsnoWhether -R listeners may bind beyond loopback: no, yes or clientspecified
PermitOpenanyDestinations allowed for -L and -D, as host:port entries
PermitListenanyAddresses and ports that -R may listen on
AllowStreamLocalForwardingyesUnix socket forwarding
DisableForwardingnoTurns off all forwarding (TCP, Unix socket, agent, X11) and overrides the options above
ClientAliveInterval / ClientAliveCountMax0 / 3Server-side keepalive, which frees stale -R ports
Defaults from sshd_config(5), OpenSSH 9.6p1 on Ubuntu 24.04 LTS. All of these options exist since OpenSSH 7.8, so Debian 12 accepts them too.

To allow the explicit -R 0.0.0.0:8080 form from the remote forwarding section, append one line to the drop-in you created for keepalives, then test and reload as before:

echo 'GatewayPorts clientspecified' | sudo tee -a /etc/ssh/sshd_config.d/10-tunnels.conf

Sessions that are already open keep their old settings, so reconnect the tunnel after the reload. To restrict one account rather than one key, put AllowTcpForwarding, PermitOpen or PermitListen in a Match User block at the end of /etc/ssh/sshd_config; a Match block applies until the next Match line or the end of the file.

Give an unattended tunnel its own restricted key

A key that a script or service uses for a tunnel should open that tunnel and nothing else. sshd(8) lets you restrict a key in ~/.ssh/authorized_keys: restrict turns off forwarding, terminal allocation and ~/.ssh/rc; port-forwarding turns TCP forwarding back on; permitopen pins -L to one destination and permitlisten pins -R to one listening port. Create a separate key pair for the tunnel, then add one line per key on the server (each entry is a single line):

restrict,port-forwarding,permitopen="127.0.0.1:5432",command="/usr/sbin/nologin" ssh-ed25519 AAAAC3...db-key db-tunnel
restrict,port-forwarding,permitlisten="localhost:8080",command="/usr/sbin/nologin" ssh-ed25519 AAAAC3...home-key home-tunnel
  • A -N client never asks for a shell, so the forced nologin command never runs for the tunnel. It only blocks anyone who tries to log in interactively with that key.
  • permitopen performs no name lookups. The client must request exactly -L 5433:127.0.0.1:5432; the same forward written with localhost:5432 is refused with “administratively prohibited”.
  • When you give -R no address, ssh requests the name localhost, which permitlisten treats differently from 127.0.0.1. That is why the second line says localhost:8080.
  • A forced command alone does not stop forwarding: sshd(8) notes that the client may still request TCP forwarding unless it is explicitly prohibited, for example with restrict, and OpenSSH 10.3 added the same warning next to ForceCommand (release notes). That is why restrict comes first.

Is SSH port forwarding secure?

The tunnel is as strong as your SSH session: the same encryption, host-key check and login. The risks come from where the ends listen and who can use them.

  • Keep listeners on loopback. Use the default or 127.0.0.1. On a shared machine every local user can still connect to a loopback port, so the service behind it should keep its own password.
  • Know which leg is encrypted. Only client to SSH server. Prefer destinations on the server itself, or services that use TLS.
  • Treat -R as a door into your computer. Anything that reaches the server-side port reaches your local service. Keep it on loopback, close it when you are done, and never publish your laptop’s own SSH port.
  • Do not lend your agent. Use -J instead of -A to hop through servers.
  • Forwarding settings are not a shell lock. sshd_config(5) notes that disabling TCP forwarding “does not improve security unless users are also denied shell access, as they can always install their own forwarders.” Use restricted keys for tunnel-only access.
  • Start from a hardened server. Tunnels let you keep databases and admin panels off the internet, but only if SSH itself is locked down: keys only, no root login, UFW on. Run the VPS security checklist first, and see how security duties are split on an unmanaged server.
  • Clean up temporary endpoints. When a demo server has served its purpose, remove its keys from the services that trusted it and delete it; the delete VPS checklist lists what to revoke.

SSH port forwarding not working? Errors and fixes

OpenSSH prints a different message for each stage that can fail, so read it exactly. Add -v to watch each forward being set up; with -v, messages the server sends show up as lines starting with “Remote:”. The messages below are quoted from the OpenSSH 9.6p1 source (ssh.c, channels.c, clientloop.c and serverloop.c).

Message or symptomCauseFix
bind [127.0.0.1]:5433: Address already in use, then Could not request local forwarding.Another program, often an earlier tunnel, already holds that local port. ssh still logs in, without the forwardPick another local port, or find the holder: ss -ltnp on Linux, lsof -nP -iTCP:5433 -sTCP:LISTEN on macOS, netstat -ano | findstr :5433 on Windows. Add -o ExitOnForwardFailure=yes so ssh exits instead
bind [::1]:5433: Cannot assign requested addressIPv6 is off on your computer. OpenSSH 9.6 shows this only with -v, unless IPv6 is the last address it tries; the IPv4 listener usually works anywayHarmless if the tunnel works. Silence it with -4 or by writing 127.0.0.1:5433:…
bind [127.0.0.1]:80: Permission deniedPorts below 1024 need root on the listening machineUse a port above 1023 and, for public web traffic, Caddy on 80/443
channel 2: open failed: connect failed: Connection refusedThe tunnel is up, but nothing listens on the destination as the server sees it: wrong port, stopped service, or a service bound to another addressOn the server, sudo ss -ltnp. Remember that localhost in -L means the server
channel 2: open failed: administratively prohibited: open failedThe server refused a -L or -D connection: AllowTcpForwarding is no or remote, PermitOpen, DisableForwarding, or a key’s restrict or permitopensudo sshd -T | grep -i forwarding; sudo journalctl -u ssh shows “refused local port forward” or “but the request was denied”
Warning: remote port forwarding failed for listen port 8080 (with ExitOnForwardFailure: Error: …)The server cannot or will not listen: port in use (often a stale listener from your previous session), port below 1024, AllowTcpForwarding no or local, PermitListensudo ss -ltnp on the server; set ClientAliveInterval so stale sessions close; with -v, “Remote: Server has disabled port forwarding.” points to AllowTcpForwarding, the key or the port number, and “Remote: port forwarding refused” to PermitListen or a key’s permitlisten
-R 0.0.0.0:8080 works only from the server itselfGatewayPorts no binds it to loopback without any errorSet GatewayPorts clientspecified, or keep loopback and put Caddy in front
Public -R port times out from outsideUFW blocks the portsudo ufw allow 8080/tcp, and delete the rule afterwards
Timeout, server 203.0.113.10 not responding.Keepalives went unanswered: the network or the server went awayExpected behavior; let systemd or autossh reconnect
Tunnel freezes after some idle minutesA router or firewall dropped the idle connectionServerAliveInterval 30 in ~/.ssh/config
Browsing through -D still uses your local DNSThe app resolves names itself--socks5-hostname in curl, “Proxy DNS when using SOCKS v5” in Firefox, the resolver flag in Chrome
Bad local forwarding specificationA missing field, or an IPv6 address without bracketsFollow port:host:hostport; write IPv6 as [2001:db8::5]
commandline disabled after ~CEnableEscapeCommandline is off by default since OpenSSH 9.2Reconnect with -o EnableEscapeCommandline=yes
Messages from OpenSSH 9.6p1 (Ubuntu 24.04 LTS). Channel numbers, ports and addresses vary.

If SSH itself will not connect, the problem is not the forward: work through the SSH connection errors first. Practicing on a throwaway server costs little: deploy an hourly VPS, break things, and delete it; billing stops when you do.

Deploy this setup

Run an SSH tunnel endpoint for an afternoon demo

Quartz Q1 · 1 shared vCPU · 1 GB RAM · 25 GB NVMe · Istanbul

  • Per hour$0.01/hourFor this job
  • Per day (24 h)$0.24/day
  • Monthly cap$5.00/month
Deploy Quartz Q1

Starts with a $5 initial credit, which goes into the server’s balance and pays for its hours.

Billed by the hour, never more than $5.00 per billing period. Delete the server and billing stops.

FAQ

What is the difference between SSH local and remote port forwarding?

Local forwarding (-L) opens the listening port on your computer and the SSH server makes the onward connection, so you reach things on the server’s side. Remote forwarding (-R) opens the listening port on the server and your computer makes the onward connection, so the server’s side reaches things on yours.

Is SSH port forwarding encrypted?

Between your SSH client and the SSH server, yes: forwarded data travels inside the same encrypted session as your shell. Beyond the server, for example from the server to a third machine, the connection is ordinary TCP, so prefer destinations on the server itself or services that use TLS.

Can SSH port forwarding carry UDP traffic?

No. OpenSSH forwards TCP connections and Unix sockets, and its SOCKS proxy supports only TCP connections. For UDP traffic such as games or voice calls, use a VPN like WireGuard, which itself runs over UDP.

How do I check whether port forwarding is enabled on an SSH server?

On the server, run sudo sshd -T | grep -i allowtcpforwarding: yes or all allows every direction, local allows only -L and -D, and remote allows only -R. From the client, a refused local forward shows “administratively prohibited”, and a refused remote forward shows “remote port forwarding failed”.

How do I run an SSH tunnel in the background?

Add -f and -N, plus -o ExitOnForwardFailure=yes so a broken forward makes ssh exit instead of idling. Start it with a control socket (-M -S path) if you want to close it later with ssh -S path -O exit, or run it as a systemd service if it must survive reboots.

Do I still need autossh?

On most Linux machines, no: ServerAliveInterval and ExitOnForwardFailure make ssh exit when the tunnel breaks, and systemd’s Restart=always starts it again. autossh remains useful where nothing else restarts ssh, and its own manual recommends -M 0 with those keepalive options.

Is an SSH tunnel the same as a VPN?

No. An SSH tunnel forwards chosen TCP ports, or the traffic of apps you point at its SOCKS proxy, while a VPN such as WireGuard routes a whole device’s traffic, UDP included. OpenSSH can also build a tun-device VPN with -w, but the server must allow it with PermitTunnel, which is off by default.

Sources

  1. ssh(1): OpenSSH remote login client, Ubuntu 24.04 LTS (noble)Ubuntu Manpages (Canonical) · manpages.ubuntu.com · checked
  2. ssh_config(5): OpenSSH client configuration file, Ubuntu 24.04 LTSUbuntu Manpages (Canonical) · manpages.ubuntu.com · checked
  3. sshd_config(5): OpenSSH daemon configuration file, Ubuntu 24.04 LTSUbuntu Manpages (Canonical) · manpages.ubuntu.com · checked
  4. sshd(8): OpenSSH daemon, AUTHORIZED_KEYS file format, Ubuntu 24.04 LTSUbuntu Manpages (Canonical) · manpages.ubuntu.com · checked
  5. OpenSSH release notes (ProxyJump 7.3, reverse dynamic forwarding 7.6, PermitListen 7.8, SessionType 8.7, EnableEscapeCommandline 9.2)OpenSSH project · openssh.org · checked
  6. OpenSSH portable 9.6p1 source (ssh.c, channels.c, clientloop.c, serverloop.c: forwarding messages and bind rules)OpenSSH project (GitHub mirror) · github.com · checked
  7. autossh(1): monitor and restart ssh sessions, Ubuntu 24.04 LTSUbuntu Manpages (Canonical) · manpages.ubuntu.com · checked
  8. PuTTY User Manual: Using port forwarding in SSHSimon Tatham (PuTTY project) · the.earth.li · checked
  9. Configuring a SOCKS proxy server in ChromeThe Chromium Projects · chromium.org · checked
  10. PostgreSQL 18: Connections and Authentication (listen_addresses, port)PostgreSQL Global Development Group · postgresql.org · checked
All posts