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.
| Flag | Syntax (ssh(1)) | Listens on | Onward connection made by | Typical job |
|---|---|---|---|---|
-L | -L [bind_ | Your computer (loopback by default) | The SSH server | Open a database or admin panel that listens only on the server’s localhost |
-R | -R [bind_ | The SSH server (loopback by default) | Your computer | Show an app running on your laptop through your VPS |
-D | -D [bind_ | Your computer, as a SOCKS4/SOCKS5 server | The SSH server, to any host the app asks for | Browse or test from the server’s IP address |
-J | -J destination, written [user@]host[:port] | Nothing | The jump host, to the next SSH server | Reach a server whose SSH port only a bastion can reach |
-N | -N | n/a | n/a | Open the forwards without running a remote shell |
-L and -R also accept Unix socket paths in place of ports.Three rules prevent most of the confusion:
- The destination is resolved at the far end. In
-L 5433:localhost:5432,localhostmeans the server, not your laptop. In-R 8080:localhost:3000it means your computer. - Loopback is the default. Without a bind address,
-Land-Dlisten on your computer’s loopback interface, and-Rlistens on the server’s loopback interface. Exposing a port to other machines takes an explicit address,-g, or aGatewayPortssetting. - 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
| Part | Meaning |
|---|---|
-N | Do not run a remote command; ssh(1) calls it “useful for just forwarding ports”. |
5433 | The local port. Pick any free port above 1023: “Only the superuser can forward privileged ports.” |
127. | The destination host, resolved on the server. Writing the address instead of localhost avoids surprises with IPv6 and with key restrictions (see below). |
5432 | The destination port on that host. |
alex@ | The SSH login that carries the tunnel. |
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 openhttp://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:5432listens on every IPv4 address of your computer (-gopens 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. |
|---|---|---|
no (default) | Loopback only | Loopback only. The requested address is ignored, and ssh prints no error |
yes | All interfaces | All interfaces |
clientspecified | Loopback only | 0.0.0.0, every IPv4 address (an empty address or * means every interface) |
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, Port1080, 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 asUseProxyForDNS). - 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.
| Need | SSH tunnel | VPN (WireGuard, Tailscale) |
|---|---|---|
| Reach one service on the server | Yes, with -L; nothing to install | Yes, with more setup |
| Send every app on the device through the server | No, only apps set to use the SOCKS proxy | Yes |
| UDP traffic (games, voice calls) | No | Yes |
| Software on the server | sshd, already running | WireGuard or Tailscale |
| Network allows only TCP | Works over the SSH port | Plain WireGuard cannot connect; Tailscale falls back to relayed connections over HTTPS, more slowly |
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 flag | ssh_config keyword |
|---|---|
-L 5433: | LocalForward 5433 127. |
-R 8080: | RemoteForward 8080 localhost: |
-D 127. | DynamicForward 127. |
-J vps1 | ProxyJump vps1 |
-N | SessionType none |
-f | ForkAfterAuthentication yes |
-g | GatewayPorts yes |
-o ExitOnForwardFailure= | ExitOnForwardFailure yes |
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'
| Option | Default | What it controls |
|---|---|---|
AllowTcpForwarding | yes | yes or all, no, local (only -L and -D) or remote (only -R) |
GatewayPorts | no | Whether -R listeners may bind beyond loopback: no, yes or clientspecified |
PermitOpen | any | Destinations allowed for -L and -D, as host: entries |
PermitListen | any | Addresses and ports that -R may listen on |
AllowStreamLocalForwarding | yes | Unix socket forwarding |
DisableForwarding | no | Turns off all forwarding (TCP, Unix socket, agent, X11) and overrides the options above |
ClientAliveInterval / ClientAliveCountMax | 0 / 3 | Server-side keepalive, which frees stale -R ports |
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
-Nclient never asks for a shell, so the forcednologincommand never runs for the tunnel. It only blocks anyone who tries to log in interactively with that key. permitopenperforms no name lookups. The client must request exactly-L 5433:127.0.0.1:5432; the same forward written withlocalhost:5432is refused with “administratively prohibited”.- When you give
-Rno address, ssh requests the namelocalhost, whichpermitlistentreats differently from127.0.0.1. That is why the second line sayslocalhost: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 toForceCommand(release notes). That is whyrestrictcomes 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
-Ras 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
-Jinstead of-Ato 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 symptom | Cause | Fix |
|---|---|---|
bind [127., then Could not request local forwarding. | Another program, often an earlier tunnel, already holds that local port. ssh still logs in, without the forward | Pick another local port, or find the holder: ss -ltnp on Linux, lsof -nP -iTCP: on macOS, netstat -ano | findstr :5433 on Windows. Add -o ExitOnForwardFailure= so ssh exits instead |
bind [::1]: | IPv6 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 anyway | Harmless if the tunnel works. Silence it with -4 or by writing 127. |
bind [127. | Ports below 1024 need root on the listening machine | Use a port above 1023 and, for public web traffic, Caddy on 80/443 |
channel 2: open failed: connect failed: Connection refused | The tunnel is up, but nothing listens on the destination as the server sees it: wrong port, stopped service, or a service bound to another address | On the server, sudo ss -ltnp. Remember that localhost in -L means the server |
channel 2: open failed: administratively prohibited: open failed | The server refused a -L or -D connection: AllowTcpForwarding is no or remote, PermitOpen, DisableForwarding, or a key’s restrict or permitopen | sudo 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, PermitListen | sudo 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. works only from the server itself | GatewayPorts no binds it to loopback without any error | Set GatewayPorts clientspecified, or keep loopback and put Caddy in front |
Public -R port times out from outside | UFW blocks the port | sudo ufw allow 8080/, and delete the rule afterwards |
Timeout, server 203. | Keepalives went unanswered: the network or the server went away | Expected behavior; let systemd or autossh reconnect |
| Tunnel freezes after some idle minutes | A router or firewall dropped the idle connection | ServerAliveInterval 30 in ~/.ssh/ |
Browsing through -D still uses your local DNS | The app resolves names itself | --socks5-hostname in curl, “Proxy DNS when using SOCKS v5” in Firefox, the resolver flag in Chrome |
Bad local forwarding specification | A missing field, or an IPv6 address without brackets | Follow port:; write IPv6 as [2001: |
commandline disabled after ~C | EnableEscapeCommandline is off by default since OpenSSH 9.2 | Reconnect with -o EnableEscapeCommandline= |
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
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.



