A7C

Zero-trust SSH access via Cloudflare Tunnel

We replaced an open port-22 firewall rule with a Cloudflare Tunnel and Access policy. Developers SSH through Cloudflare's network, and the server holds only an outbound connection to Cloudflare's edge.

Zero-trust SSH access via Cloudflare Tunnel

Problem

A production server needed SSH access for a two-person team while keeping port 22 off the public internet. The previous setup used key auth only with Fail2ban running, and it logged over 15,000 failed login attempts per day in auth.log. Key auth held the exposure in check, but the firewall churn and per-server key cleanup slowed down every access change.

Approach

We install cloudflared on the server, create a named Cloudflare Tunnel, and put Cloudflare Access in front of it with an identity policy. The server keeps an outbound connection to Cloudflare while port 22 stays closed to the internet. Developers add a single ProxyCommand to their SSH config, and the rest of their workflow stays the same.

Architecture

cloudflared runs as a systemd service on the target server and keeps an outbound connection to Cloudflare's network. Cloudflare Access enforces identity at the edge with email OTP or GitHub OAuth before traffic reaches the tunnel. On the developer side, cloudflared acts as an SSH ProxyCommand. Traffic path: developer machine → Cloudflare edge → tunnel → server localhost:22.

Outcome

We closed port 22 on three production environments. Brute-force noise in auth.log dropped to zero, and the Access policy now controls developer access. Setup on a fresh server stays under 30 minutes, and the pattern has run in production since November 2024.

At a glance

15K/day
Blocked attempts
< 30 min
Setup per server
3
Environments

The starting point

The client ran a small VPS for a production workload. Port 22 was open, SSH accepted keys, and Fail2ban watched the logs. The credentials held up, but auth.log still showed around 15,000 failed login attempts per day, most of them from automated scanners probing cloud-provider IP ranges. Fail2ban banned the offending IPs, new scanner IPs took their place within minutes, and the firewall kept cycling through scanner traffic while developers followed the same path as before.
Revoking access had the same operational drag. Keys lived on developer machines, so removing a person meant finding their public keys, editing ~/.ssh/authorized_keys on each server, and confirming that every environment was clean. For a two-person team the process was easy to follow but too manual for production access control.

Why not a VPN

WireGuard or OpenVPN would have solved the exposure and revocation problems, but this client had two developers and a handful of servers. Running a VPN means maintaining the server, managing peer config files, rotating keys when someone leaves, and keeping another network service patched and available. Cloudflare already handled DNS for the domain, which made a Tunnel and Access policy the smaller system to operate.

How Cloudflare Tunnel works

cloudflared runs on the target server and makes an outbound connection to Cloudflare's network. It registers a named tunnel and holds that connection open. When a developer reaches the server over SSH, the traffic flows back through that existing channel: Cloudflare edge → tunnel → server. You can drop the public firewall rule for port 22 from 0.0.0.0/0.
Cloudflare Access sits in front of the tunnel as an identity gate. You create an Access Application for the SSH hostname, and a policy controls who can authenticate. Authentication happens at Cloudflare's edge. A developer connecting for the first time goes through a browser-based auth flow (email OTP, GitHub, Google, or similar) that sets a signed session token. The cloudflared ProxyCommand on their machine reads that token and passes the authenticated session through. Later connections need no prompt.
Cloudflare Tunnel SSH flow

Cloudflare Tunnel SSH flow

Server setup

Installing cloudflared

Install the cloudflared Debian package from Cloudflare's GitHub releases, authenticate the daemon to the Cloudflare account, and create a named tunnel:
curl -L --output cloudflared.deb \
  https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
dpkg -i cloudflared.deb

cloudflared tunnel login
cloudflared tunnel create prod-ssh
The tunnel create command writes a credentials JSON file to ~/.cloudflared/. Back that file up, since it holds the tunnel's identity credential.

Tunnel config and DNS

Write the tunnel config to /etc/cloudflared/config.yml:
tunnel: <tunnel-uuid>
credentials-file: /root/.cloudflared/<tunnel-uuid>.json

ingress:
  - hostname: ssh.example.com
    service: ssh://localhost:22
  - service: http_status:404
Route the subdomain and install cloudflared as a system service:
cloudflared tunnel route dns prod-ssh ssh.example.com
cloudflared service install
systemctl enable --now cloudflared
The tunnel is now live and the subdomain resolves to Cloudflare's edge via a CNAME. You can firewall port 22 on the server from the internet, and SSH keeps working through the tunnel.

Cloudflare Access setup

In the Cloudflare dashboard, under Zero Trust → Access → Applications, create a Self-hosted application for ssh.example.com. Set the application type to SSH. Add a policy with an Allow action and the identity rules for the team: specific email addresses, a GitHub organisation, or a Google Workspace domain.
Access changes now happen at the policy layer. To add a developer, you add them to the policy. To remove one, you delete that entry, and the server-side SSH configuration stays untouched.

Developer setup

Each developer installs cloudflared on their machine (macOS via Homebrew, Linux via the same .deb, Windows via installer). Then one block in ~/.ssh/config:
Host ssh.example.com
  ProxyCommand cloudflared access ssh --hostname %h
  User deploy
  IdentityFile ~/.ssh/id_ed25519
The first ssh ssh.example.com opens a browser window for the Access auth flow. After that, cloudflared caches the session token and the connection behaves like the direct SSH workflow developers already know. VS Code Remote, rsync, and scp run through the same ProxyCommand.

What changed in production

We firewalled port 22 on two environments in November 2024, and the brute-force entries in auth.log stopped at once. A third environment moved to the same setup in February 2025.
We evaluated short-lived SSH certificates during the rollout. Cloudflare Access can act as a certificate authority and issue certificates for the session duration, which moves the design away from long-lived SSH keys. That path needs changes to the server's sshd_config so it trusts Cloudflare's CA. The client's key management was already in order, and the extra sshd_config complexity fit larger teams better than this two-person environment.
auth.log before and after

auth.log before and after

© 2017 - 2026 · A7C