Panel access through an SSH tunnel
A panel nobody can reach from the Internet cannot be attacked from it.
Overview
Your apps are served by sh0 on ports 80 and 443. The panel, which controls all of them, does not have to be. In tunnel-only mode the panel listens on 127.0.0.1:9000 alone: the only way in is an SSH connection to the server, which your firewall already restricts and which you already protect with a key.
Why not a public panel domain
Every TLS certificate is published in public Certificate Transparency logs, so a panel domain such as panel.example.com is listed on crt.sh within minutes of its certificate being issued. Scanners read those logs: a public panel is found, then probed, whatever its password policy.
Install in tunnel-only mode
On a fresh server, as root, with your firewall already limited to SSH, 80 and 443:
curl -fsSL https://get.sh0.dev | bash -s -- --harden --panel-tunnel-onlyBefore sh0 starts for the first time, the installer writes a systemd drop-in that binds the panel to 127.0.0.1, turns off the *.sh0.app registration (a proxy cannot reach a panel on localhost), and with --harden does not open port 9000 in ufw. It ends by printing the exact ssh command to paste on your own computer.
Open the tunnel
1. On your own computer (not on the server), open the tunnel and leave the command running:
ssh -N -L 9000:127.0.0.1:9000 root@SERVER_IP2. Browse to http://localhost:9000. The traffic travels inside SSH: nothing crosses the network in clear, even though the address starts with http://. Ctrl+C closes the tunnel.
3. To avoid retyping it, add a block to ~/.ssh/config on your computer:
Host sh0-panel
HostName SERVER_IP
User root
LocalForward 9000 127.0.0.1:9000
SessionType none
ExitOnForwardFailure yesThen ssh sh0-panel opens the tunnel. SessionType none needs OpenSSH 8.7 or later; with an older client, remove that line and run ssh -N sh0-panel.
Common failures
| Symptom | Cause and fix |
|---|---|
| ssh hangs, then "Connection timed out" | SSH itself is filtered: your IP is not allowed by the provider firewall, or the server listens on another port (add -p PORT). The tunnel is not the problem. |
| "bind [127.0.0.1]:9000: Address already in use" | Port 9000 is already taken on your computer, often by another tunnel. Use another local port: -L 9001:127.0.0.1:9000, then http://localhost:9001. |
| Browser: "connection refused" on localhost:9000 | The tunnel is not open (the ssh command has stopped), or sh0 is not running on the server: systemctl status sh0. |
| "channel 2: open failed: connect failed" in the ssh window | SSH works but nothing answers on 127.0.0.1:9000 on the server, or AllowTcpForwarding is off in sshd. Check ss -ltn | grep 9000 and sshd -T | grep allowtcpforwarding. |
Switch an existing server
A server that already runs sh0 is switched by running the installer again with the same options. It keeps your apps, writes the drop-in, removes an existing ufw rule for 9000 (with --harden), and restarts sh0 once, which also withdraws its *.sh0.app address. Later re-runs of the installer, for an update, keep the server in tunnel-only mode as long as the drop-in exists.
curl -fsSL https://get.sh0.dev | bash -s -- --harden --panel-tunnel-onlyYour apps keep answering on their domains: they are served by Caddy on 80 and 443, which the switch does not touch. Expect a few seconds of interruption while sh0 restarts. If a panel domain is set, the installer stops before changing anything and tells you why (next section).
What the tunnel does not close
sudo sh0 panel-domain # show
sudo sh0 panel-domain clear # remove the route and the settingBrowser access without a tunnel
If a tunnel is not an option for your team, keep a panel domain but put Cloudflare Access (or an equivalent identity-aware proxy) in front of it, and restrict port 443 at the provider firewall to Cloudflare's IP ranges. Without that second step, Access is bypassed by connecting to the server's IP directly. That restriction applies to every app on the server: each of their domains must then be proxied by Cloudflare too, or it stops answering. Do not combine this with --panel-tunnel-only.
Check the result
On the server, the panel must listen on the loopback address only, no panel domain must be set, and sh0 doctor must report no failure. From another network, port 9000 must be closed.
ss -ltn | grep ':9000' # 127.0.0.1:9000 only
cat /etc/systemd/system/sh0.service.d/panel-tunnel-only.conf
sudo sh0 panel-domain # Domain: not set
sudo sh0 doctorGoing back
To make the panel reachable on the server's address again, delete the drop-in, restart sh0 and reopen the port:
rm /etc/systemd/system/sh0.service.d/panel-tunnel-only.conf
rm /etc/systemd/system/sh0.service.d/cloud-registration.conf # only to get *.sh0.app back
systemctl daemon-reload && systemctl restart sh0
ufw allow 9000/tcp # if the firewall is ufwThe rest of the host (SSH keys, firewall, fail2ban) is covered in Securing your server