notes: cloudflare, ssh & homelab
Prior to this setup, I could only SSH into my homelab while at home, since the client machine needed to be on the same local network as the host. The classic fixes all have downsides, port forwarding exposes my port to the whole internet, a VPN is another thing to run and babysit.
idea
The homelab already runs a dozen services (Outline, Excalidraw, Home Assistant, Plex, and more), managed through Portainer — a web-based Docker dashboard — and exposed via a single Cloudflare Tunnel under *.<MY_DOMAIN>.
So I thought: why can’t SSH ride the same tunnel? The box makes an outbound connection to Cloudflare’s edge, so the router never opens a port. No inbound connection reaches the box unless Cloudflare allows it, and Cloudflare Access only allows connections I’ve authenticated myself.

action
- In the cloudflared config in the homelab, I added a rule above the catch-all, mapping the hostname
ssh.<MY_DOMAIN>to the servicessh://localhost:PORT:
sudo nano /etc/cloudflared/config.yml
ingress:
- hostname: ssh.<MY_DOMAIN>
service: ssh://localhost:PORT
# ... existing rules ...
- service: http_status:404
Then validate the config and restart the service:
cloudflared tunnel --config /etc/cloudflared/config.yml ingress validate
sudo systemctl restart cloudflared
- I’d been logging in by password over LAN since first setting up Arrakis, and my server account had no
authorized_keys. Exposing password-auth SSH to the internet, even behind Cloudflare Access, is asking for trouble. So, here is a breakdown of what I did:
- Installed the laptop’s ed25519 public key into
authorized_keyson the server - Verified key login worked — over LAN and through the new tunnel setup.
- Locked down the SSH daemon, via a drop-in conf file with three settings:
PasswordAuthentication no,KbdInteractiveAuthentication no, andPermitRootLogin prohibit-password
Before reloading, I validated the daemon config with its test flag and used reload rather than restart so existing sessions stayed alive as a safety net. Verified from outside afterwards: password attempts now get Permission denied (publickey).
-
Next up, wiring in the security layer. I logged into the Cloudflare Zero Trust dashboard, went to Access → Applications, and added a new self-hosted application for
ssh.<MY_DOMAIN>. I then set up a simple policy: an allow rule scoped to an email I control. I skipped identity provider configuration, so Access falls back to its One-time PIN method — a 6-digit code emailed to me at login.Once saved, any unauthenticated request to the hostname gets a 302 redirect to my cloudflareaccess.com login page — nobody reaches the SSH handshake without passing the email check first. Every attempt, successful or not, shows up in Zero Trust → Logs.
-
On the client machine the whole thing collapses into two entries in
~/.ssh/config(the only prerequisite isbrew install cloudflared):# <NAME> at home, over LAN Host <NAME> HostName 192.168.1.xx User <USER> # <NAME> from anywhere, via Cloudflare Tunnel Host <NAME>-remote HostName ssh.<MY_DOMAIN> User <USER> ProxyCommand cloudflared access ssh --hostname %hThe first entry is a plain host pointing at the LAN IP — for when I’m at home and want the direct, fastest route. The second,
<NAME>-remote, points at the tunnel hostname and adds a ProxyCommand that invokescloudflared access ssh, which tunnels the SSH stream through Cloudflare instead of connecting directly.The first time I ran
ssh <NAME>-remotefrom a new device, a browser popped open for the Access login; once authenticated, the token is cached for the session. After that, it feels identical to plain SSH.
result
- Home router still has zero open ports.
- Homelab only speaks outbound to Cloudflare. But now, from anywhere with an internet connection,
ssh <NAME>-remotegets me a shell - behind an email check, a one-time PIN, and a key that never leaves my laptop.