Guides Cloudflare Tunnel

SSH through a Cloudflare Tunnel: shell access with no open port 22

Publish sshd through a tunnel, protect it with Access, wire up your SSH client with a ProxyCommand and harden sshd so the tunnel is not the only line of defence.

By Micheal Garner. Published 1 November 2026. 4 minute read.

Port 22 open to the internet attracts attention within minutes. Logs fill with login attempts from addresses all over the world, and even if your keys hold, you are now running a public service that has to be patched the instant a flaw appears.

A Cloudflare Tunnel removes the open port. sshd stays bound to your server, cloudflared carries the connection outward to Cloudflare, and your SSH client reaches it through Cloudflare using a small helper. This guide builds that, and it assumes you already have a working tunnel from Cloudflare Tunnel from scratch.

The shape of it

ssh client ── cloudflared (client) ──► Cloudflare ◄── cloudflared (server) ── sshd on localhost:22

Both ends of the connection are cloudflared. The one on your laptop wraps SSH in a connection Cloudflare understands, and the one on the server unwraps it and hands it to sshd. Nothing is listening on the internet.

Step 1: add the ingress rule

On the server, add an ssh:// service to the tunnel’s config.yml, above the catch-all:

ingress:
  - hostname: ssh.example.com
    service: ssh://localhost:22
  - service: http_status:404

Validate it, route the hostname and restart the tunnel:

cloudflared tunnel --config /etc/cloudflared/config.yml ingress validate
cloudflared tunnel route dns homelab ssh.example.com
sudo systemctl restart cloudflared

Step 2: protect it before you use it

Stop here and put an Access policy on ssh.example.com before you go any further. A tunnelled SSH hostname with no Access application is reachable by anyone who runs the client helper, which makes it almost as exposed as an open port. The steps are in putting a login in front of a tunnelled app: create a self-hosted application for ssh.example.com with an Allow policy for your own identity.

Step 3: configure your SSH client

On the machine you connect from, install cloudflared the same way you did on the server. Then tell SSH to use it for this hostname by adding a block to ~/.ssh/config:

Host ssh.example.com
  ProxyCommand /usr/bin/cloudflared access ssh --hostname %h

Two details here are easy to get wrong:

  • The path must be right. ProxyCommand runs the exact path you give it. On Linux with the apt package it is /usr/bin/cloudflared. On other systems, run which cloudflared, or where cloudflared on Windows, and use what it prints.
  • %h is the hostname SSH is connecting to. Leave it as it is. SSH replaces it for you.

Now connect:

ssh [email protected]

The first time, cloudflared opens your browser so Access can authenticate you, then caches a token so later connections do not ask again until the session expires. Your normal SSH key authentication still happens afterwards. Access gets you to the door, and SSH decides whether to let you in.

Step 4: harden sshd as if the tunnel did not exist

A tunnel hides the service. It does not make sshd secure. Treat the tunnel as one layer, and set sshd up so that it would still be defensible on its own.

Create a drop-in file rather than editing the main config, so package upgrades do not fight you:

sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
AllowUsers anon
MaxAuthTries 3
EOF
sudo sshd -t && sudo systemctl reload ssh

sshd -t checks the configuration for errors before you reload, so a typo cannot lock you out. Keep your current session open and test a second connection in a new terminal before you close the first. If the new one fails, you still have a way in to fix it.

On Debian and Ubuntu the service is called ssh. On some other distributions it is sshd.

Make sure you have put a public key in ~/.ssh/authorized_keys before you disable passwords, or you will have disabled your own access.

Optional: stop listening on the network at all

Once the tunnel works reliably, sshd no longer needs to accept connections from your LAN. You can bind it to the loopback address, which is the only place cloudflared connects from:

ListenAddress 127.0.0.1

Put that in the same drop-in file. The catch is that you can no longer SSH in over the LAN if the tunnel is down. If this is a remote machine that you cannot walk up to, leave it out. If you can plug in a keyboard, it is a worthwhile tightening.

When it does not work

  • ssh_exchange_identification: Connection closed or a hang: the tunnel is down, or the hostname is not routed. Run cloudflared tunnel info homelab and check the service logs.
  • websocket: bad handshake: commonly an Access or WebSocket configuration issue. The tunnel error guide lists the causes.
  • A browser login loop: the Access policy does not include your identity, or your browser is blocking the cookie.
  • Permission denied (publickey): the tunnel is fine and the problem is in SSH itself. Run ssh -v [email protected] and read what it offers.

Is it better than a VPN?

For reaching one or two machines, yes: nothing to open, per-user login and audit logging in Cloudflare. For reaching many hosts or whole subnets, a mesh or VPN is usually simpler, because each host would otherwise need its own hostname and rule. The comparison lays out the trade.

Sources: Cloudflare’s documentation on SSH through Cloudflare Tunnel and the tunnel configuration file.