TL;DR:
- To open a dashboard that listens only on the server's
127.0.0.1, runssh -N -L 8080:127.0.0.1:8080 deploy@YOUR_SERVER_IPon Windows 10 and 11, macOS or Linux, then browse tohttp://localhost:8080. - The dashboard port stays closed in UFW. The SSH tunnel carries its traffic, so the server needs only the SSH port (22/tcp by default).
ss -tlnshows what each service binds:127.0.0.1is private, while0.0.0.0,[::]and*mean every interface. Docker's-p 3000:3000binds every interface by default and bypasses UFW.- Save forwards as
LocalForwardlines in~/.ssh/configwithServerAliveInterval 30, and restart dropped tunnels with autossh 1.4g. - An
ssh -Ltunnel is not a VPN: it forwards TCP connections for the ports you name, while WireGuard routes IP packets for whole networks. - Keep
-Dand-Rbound to localhost. A0.0.0.0bind turns-Dinto an open proxy, andGatewayPorts yespublishes an-Rport on the server's public address.
Applies to: Ubuntu 24.04 LTS and 26.04 LTS servers (OpenSSH 9.6 and 10.2) · OpenSSH client on Windows 10 and 11, macOS 26 (OpenSSH 10.3) and Linux · PuTTY 0.85 · autossh 1.4g · checked October 2026
An SSH tunnel is an encrypted channel inside an SSH connection that carries TCP traffic between a port on your computer and an address on the server; OpenSSH calls the feature port forwarding. This tutorial opens dashboards that listen only on the server's loopback address, such as Open WebUI, the OpenClaw Control UI and wg-easy, with one ssh -L command, saves it in ~/.ssh/config and keeps it connected. It also covers the Docker port trap, reverse and dynamic tunnels, and when HTTPS or WireGuard fits better.
What is an SSH tunnel?
In an SSH tunnel, one end listens on a port, and each connection to it travels through the encrypted session and leaves the other end as a new TCP connection. OpenSSH has three forward types:
| Type | Flag | Listens on | Connects to | Typical use |
|---|---|---|---|---|
| Local | -L | Your computer | An address as the server sees it | A dashboard on the server's 127.0.0.1 |
| Remote | -R | The server | An address as your computer sees it | Let the server reach an app on your laptop |
| Dynamic | -D | Your computer, as a SOCKS proxy | Whatever each app requests, from the server | Browse out through the server |
Is an SSH tunnel a VPN?
Not in the port-forwarding form this tutorial uses. A local forward carries TCP connections for the ports you list, from the computer that runs ssh, and leaves the rest of your traffic unchanged. A VPN such as WireGuard adds a network interface (wg0) and routes IP packets, TCP and UDP, for whole subnets or all of a device's traffic. OpenSSH's -w option can forward tun devices as a VPN, and sshd's PermitTunnel setting turns it off by default.
SSH tunnel (ssh -L) | WireGuard VPN | |
|---|---|---|
| Carries | TCP connections to the ports you name | IP packets for the subnets you route |
| Port open on the server | The SSH port | 51820/udp in the WireGuard tutorial |
| Server setup | None | Package, keys and a peer per device |
Prerequisites
- A Linux server, such as Ubuntu 24.04 LTS or 26.04 LTS, with a dashboard that listens on
127.0.0.1. Arct Cloud Linux VPS images include root SSH and accept an SSH public key at deploy. - A sudo user that logs in with an SSH key, called
deployhere. See How to Generate an SSH Key on Windows, macOS, and Linux. - An SSH client: OpenSSH in Windows 10 (build 1809 or later) and 11, macOS and Linux, or PuTTY on Windows.
Step 1 runs on the server; Steps 2 to 6 run on your computer. Replace values in capitals, such as YOUR_SERVER_IP, with your own.
Step 1: Check what the dashboard listens on
The address a service binds decides whether it is private. On the server, list the listening TCP sockets:
ss -tln
The output looks similar to this:
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 2048 127.0.0.1:8080 0.0.0.0:*
LISTEN 0 511 127.0.0.1:18789 0.0.0.0:*
LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:*
LISTEN 0 4096 [::]:3000 [::]:*
In the Local Address column, 127.0.0.1 or [::1] is the loopback address: only programs on the server, and your tunnel, can connect. 0.0.0.0, [::] or * means every interface, so port 3000 above answers on the public IPv4 address, and on IPv6 if your server has a public IPv6 address. sudo ss -tlnp adds each socket's process.
Fix a Docker port published on every interface
Docker's documentation states that publishing container ports is insecure by default. A mapping such as "3000:3000" binds 0.0.0.0 and [::], and Docker routes it through NAT rules that run before the chains UFW uses, so a UFW deny rule does not close it. Put the loopback address in front of the mapping in compose.yaml:
ports:
- "127.0.0.1:3000:3000"
Recreate the container with docker compose up -d, and ss -tln shows 127.0.0.1:3000. The Docker installation tutorial sets a loopback default for new ports in daemon.json. Before Docker Engine 28.0.0, hosts on the same layer-2 segment could reach ports published on 127.0.0.1, so keep Docker updated.
Step 2: Open the dashboard with ssh -L
ssh -L listens on a port on your computer and sends each connection through the SSH session. Run this in Terminal on macOS and Linux or in PowerShell on Windows:
ssh -N -L 8080:127.0.0.1:8080 deploy@YOUR_SERVER_IP
If PowerShell reports that ssh is not recognized, add the OpenSSH Client optional feature.
-L 8080:127.0.0.1:8080reads as local port, destination address, destination port. The server resolves the destination, so127.0.0.1is the server's own loopback address.-Nopens no remote shell. ssh prints nothing after login and holds the tunnel until you press Ctrl+C.
Open http://localhost:8080 in your browser. Use Chrome or Firefox for tunnel addresses; both accept secure cookies on http://localhost, which the Open WebUI login from the Ollama tutorial needs. Port 8080 stays closed on the server's public address.
ssh binds the local end to your computer's loopback address, because the client setting GatewayPorts defaults to no. Do not add -g or a bind address such as 0.0.0.0:8080:127.0.0.1:8080. Both let other devices on your network into the dashboard through your tunnel.
The port numbers can differ: -L 18080:127.0.0.1:8080 serves the dashboard at http://localhost:18080 when 8080 is taken locally. Pick a local port above 1023, as in this example. Keep 8080 for Open WebUI from the Ollama tutorial, whose CORS_ALLOW_ORIGIN lists http://localhost:8080.
Open an SSH tunnel on Windows with PuTTY
- On the Session panel of PuTTY 0.85, enter the server's IP address in Host Name.
- Open Connection > SSH > Tunnels. Enter
8080in Source port and127.0.0.1:8080in Destination, keep Local selected, and click Add. - Open Connection > SSH > Auth > Credentials and select your
.ppkfile under Private key file for authentication. To convert an OpenSSH key, see How to Generate an SSH Key. - Back on Session, type a name under Saved Sessions, click Save, then Open, and log in as
deploy.
PuTTY starts the forward after login and logs a line similar to Local port 8080 forwarding to 127.0.0.1:8080 in its Event Log. Leave both checkboxes under Port forwarding unchecked, so only your computer can use the forwarded port.
Step 3: Forward several ports in one command
Press Ctrl+C to close the Step 2 tunnel, then repeat -L once per dashboard. One SSH connection carries all of them:
ssh -N \
-L 8080:127.0.0.1:8080 \
-L 18789:127.0.0.1:18789 \
-L 51821:127.0.0.1:51821 \
deploy@YOUR_SERVER_IP
In PowerShell, put the command on one line; a trailing backslash does not continue a line there. These are the loopback ports in the tutorials on this site:
| App | Server address | Open on your computer | Tutorial |
|---|---|---|---|
| Open WebUI | 127.0.0.1:8080 | http://localhost:8080 | Ollama with Open WebUI |
| OpenClaw Control UI | 127.0.0.1:18789 | http://127.0.0.1:18789/ | Install OpenClaw |
| OpenCode web UI | 127.0.0.1:49374 | The link opencode pair prints | Install OpenCode |
| wg-easy | 127.0.0.1:51821 | http://localhost:51821 | WireGuard VPN server |
| Nginx Proxy Manager admin panel | 127.0.0.1:81 | http://localhost:8081, with -L 8081:127.0.0.1:81 | Nginx Proxy Manager with Docker |
| LiteLLM Admin UI | 127.0.0.1:4000 | http://localhost:4000/ui | LiteLLM Proxy with Docker Compose |
| TeamSpeak ServerQuery | 127.0.0.1:10022 | ssh -p 10022 [email protected] | TeamSpeak 6 server with Docker |
The tunnel controls who can reach each page, and each app still asks for its own login.
Step 4: Save the tunnel in ~/.ssh/config
Put the tunnel's settings under one name in your SSH config file, ~/.ssh/config on macOS and Linux or C:\Users\YOUR_USER\.ssh\config (no file extension) on Windows. Place the block above any Host * block:
Host vps-dash
HostName YOUR_SERVER_IP
User deploy
IdentityFile ~/.ssh/id_ed25519
LocalForward 8080 127.0.0.1:8080
LocalForward 18789 127.0.0.1:18789
LocalForward 51821 127.0.0.1:51821
ExitOnForwardFailure yes
LocalForward takes the local port, a space, then address:port. ExitOnForwardFailure yes makes ssh quit when it cannot open a forward, such as when a local port is in use, instead of connecting without it. Close the Step 3 tunnel with Ctrl+C, then open this one:
ssh -N vps-dash
Keep this alias apart from your shell-login alias: a second login with the same LocalForward lines tries to bind ports the tunnel already holds. ssh -G vps-dash prints the parsed block without connecting, including localforward 8080 [127.0.0.1]:8080.
Step 5: Keep the tunnel alive
ssh sends no server alive messages through the encrypted channel by default (ServerAliveInterval 0), so a dropped network path can leave a tunnel that looks open and carries nothing. Add two lines to the vps-dash block:
ServerAliveInterval 30
ServerAliveCountMax 3
ssh now asks the server for a response after 30 seconds without data. After 3 unanswered requests, about 90 seconds, it prints Timeout, server 203.0.113.10 not responding. and exits.
Restart the tunnel with autossh
autossh starts ssh again when the connection drops. Install it on your computer with sudo apt install autossh on Ubuntu and Debian or brew install autossh on macOS. On Windows, rerun ssh -N vps-dash after a drop.
autossh -M 0 -N vps-dash
-M 0 turns off autossh's monitoring port and relies on the ServerAlive lines to make ssh exit. Load your key into ssh-agent first, so no passphrase prompt blocks a restart. If the first ssh run fails within 30 seconds, autossh treats it as a setup error and stops.
To run a plain tunnel in the background on macOS or Linux, use ssh -f -N vps-dash. Stop it with pkill -f "ssh -f -N vps-dash".
Step 6: Verify the tunnel and the closed port
Close any tunnel from Steps 2 to 5 first: press Ctrl+C in its window, or run pkill -f "ssh -f -N vps-dash" for a background tunnel. Start the tunnel with -v to see each forward open:
ssh -v -N vps-dash
The output includes lines similar to these:
...
debug1: Local connections to LOCALHOST:8080 forwarded to remote address 127.0.0.1:8080
debug1: Local forwarding listening on ::1 port 8080.
...
debug1: Local forwarding listening on 127.0.0.1 port 8080.
...
Load http://localhost:8080, and ssh logs Connection to port 8080 forwarding to 127.0.0.1 port 8080 requested. Then test the server's public address from your computer:
curl -m 5 http://YOUR_SERVER_IP:8080
In PowerShell, type curl.exe, since curl is an alias there. The request times out after 5 seconds or is refused. A page or an HTTP status code means the port is public: go back to Step 1.
Keep reverse and dynamic tunnels on localhost
A dynamic forward (-D) turns ssh into a SOCKS4 and SOCKS5 proxy on your computer. Apps that use it connect out from the server, so websites see the server's IP address:
ssh -N -D 127.0.0.1:1080 deploy@YOUR_SERVER_IP
Set the browser's SOCKS v5 proxy to 127.0.0.1, port 1080. With -g, 0.0.0.0: or *:, any device that reaches your computer can relay traffic through your server, which makes it an open proxy. Use the proxy only for lawful purposes and within your provider's acceptable use policy.
A remote forward (-R) opens a port on the server that leads back to your computer:
ssh -N -R 9000:127.0.0.1:3000 deploy@YOUR_SERVER_IP
Only programs on the server can reach your local port 3000, at 127.0.0.1:9000, because sshd binds remote forwards to loopback while GatewayPorts is no, the default. Check the active values on the server:
sudo sshd -T | grep -E '^(gatewayports|allowtcpforwarding|disableforwarding) '
With OpenSSH's default settings, the output is:
gatewayports no
allowtcpforwarding yes
disableforwarding no
GatewayPorts yes, or clientspecified with a request such as -R 0.0.0.0:9000:127.0.0.1:3000, publishes your local service on the server's public address. If you never use -R, allow local forwards only; -L and -D keep working:
echo 'AllowTcpForwarding local' | sudo tee /etc/ssh/sshd_config.d/10-forwarding.conf
sudo sshd -t
sudo systemctl restart ssh
sshd's manual notes that users with shell access can still run their own forwarders, so this setting limits sshd only.
When to use a reverse proxy or WireGuard instead
| Option | Fits when | Opens on the server | Guide |
|---|---|---|---|
| SSH tunnel | One admin opens a dashboard now and then | Nothing beyond the SSH port | This tutorial |
| Reverse proxy with HTTPS | Other people, phones or outside services need the app on a domain | 80/tcp and 443/tcp | Nginx Proxy Manager with Docker |
| WireGuard VPN | Several devices, including phones, reach several private services | 51820/udp | WireGuard VPN server |
Webhooks need a public HTTPS address, so the n8n tutorial uses a reverse proxy. The Nginx, PostgreSQL, and Redis guide for Ubuntu 24.04 sets up Nginx as a reverse proxy for an app on 127.0.0.1:3000. Admin pages such as the OpenClaw Control UI stay on loopback.
Troubleshoot common SSH tunnel errors
Run ssh with -v as in Step 6. On the server, sudo journalctl -u ssh shows what sshd refused.
| Symptom | Cause | Fix |
|---|---|---|
bind [127.0.0.1]:8080: Address already in use, then Could not request local forwarding. | Another program or an earlier tunnel holds port 8080 on your computer | Close it, or pick a free local port such as -L 18080:127.0.0.1:8080. For Open WebUI, also add http://localhost:18080 to CORS_ALLOW_ORIGIN |
channel 2: open failed: connect failed: Connection refused (the channel number varies) when the page loads | Nothing listens on that server port, or the service binds another address | Run Step 1 and match the -L destination to the ss output |
channel 2: open failed: administratively prohibited: open failed | sshd refuses TCP forwarding (AllowTcpForwarding no or remote, or DisableForwarding yes) | Remove DisableForwarding yes and any other AllowTcpForwarding line from /etc/ssh/sshd_config and /etc/ssh/sshd_config.d/, set AllowTcpForwarding local in 10-forwarding.conf, run sudo sshd -t and sudo systemctl restart ssh, then check the values with sudo sshd -T |
Timeout, server 203.0.113.10 not responding. | The Step 5 server alive requests got no answer | Reconnect, or run the tunnel under autossh |
Warning: remote port forwarding failed for listen port 9000 | The server port is in use, or sshd refuses -R | Pick another port, or check sudo sshd -T |
FAQ
Is SSH tunneling legal?
Port forwarding is a built-in, documented feature of OpenSSH, and this tutorial uses it to reach a server you control. What you send through a tunnel must be lawful where you are and must follow the acceptable use rules of your provider and of the network you connect from.
What is the difference between SSH and SSH tunneling?
SSH is the encrypted protocol and client you use to log in and run commands on a server. SSH tunneling, also called port forwarding, uses the same connection to carry other TCP traffic, such as a browser session to a dashboard on the server.
How do I create an SSH tunnel on Windows?
Run ssh -N -L 8080:127.0.0.1:8080 deploy@YOUR_SERVER_IP in PowerShell. Windows 10 (build 1809 or later) and Windows 11 include OpenSSH Client as an optional feature. In PuTTY, add the forward under Connection > SSH > Tunnels before you click Open.
Can one SSH tunnel forward multiple ports?
Yes. Repeat -L for each port in one ssh command, or add one LocalForward line per port to a Host block in ~/.ssh/config. All forwards share one SSH connection and close together.
How do I use an SSH tunnel through a jump host?
Add -J with the jump host: ssh -N -J deploy@YOUR_JUMP_HOST -L 8080:127.0.0.1:8080 deploy@YOUR_TARGET_HOST. The 127.0.0.1 in -L then refers to the target server. In ~/.ssh/config, a ProxyJump line does the same.
Can I use an SSH tunnel as a proxy?
Yes. ssh -N -D 127.0.0.1:1080 deploy@YOUR_SERVER_IP starts a SOCKS proxy on your computer, and sites see the server's IP address. Keep it bound to 127.0.0.1 and use it only for lawful purposes.
What is a reverse SSH tunnel?
A reverse tunnel (-R) opens a port on the server that leads back to a service on your computer. sshd binds it to the server's loopback address while GatewayPorts is no, the default. GatewayPorts yes publishes that service on the server's public address.
Next steps
- How to Install Nginx Proxy Manager with Docker on Ubuntu 24.04, to serve an app on your own domain with HTTPS
- How to Set Up a WireGuard VPN Server on Ubuntu 24.04, for phones and several private services
- How to Install and Configure Fail2ban on Ubuntu 24.04 and 26.04, to ban addresses that keep failing SSH logins on the one port a tunnel needs
- ssh(1) and ssh_config(5) manual pages for every forwarding option
Arct Cloud Linux images deploy in under 30 seconds with root SSH on every plan. Compare plans.
This work is licensed under CC BY-NC-SA 4.0.