All systems operational
Home Services Blog Tools Projects About Contact

Cloudflare Tunnel SSH via Web Browser and Cloudflared Client

auth: Kamandanu Wijaya date: September 22, 2026 read: 3 min read
Zero-trust SSH access diagram using Cloudflare Tunnel and a browser terminal session

A 4 vCPU cloud instance behind a fresh public IP collected 118,000 failed SSH logins in twelve hours. I know the number because I read it straight out of auth.log while the instance was still warm from provisioning, before we had moved it behind our standard access path. The instance had no service running except sshd, no DNS record pointing at it, and still the scanners found it within seventeen minutes of the address being assigned. That is the part people underestimate. Scan coverage of the public IPv4 space runs continuously and it does not care whether anyone published a link to your host.

The usual fixes are a non-standard port, key-only authentication, and a firewall allowlist. Each one helps, and all three share the same assumption. Something is still listening on a public interface and the defence is a rule that decides who may connect. Cloudflare Tunnel inverts that assumption. Nothing listens publicly, and access becomes an identity decision evaluated at Cloudflare’s edge before a single packet reaches your host.

This is the setup I now use for every server that does not need SSH exposed to the whole internet, including the browser-rendered terminal for engineers without a laptop, and the native cloudflared access ssh path for those who refuse to give up their terminal.

What Changes When the Tunnel Is Outbound Only

cloudflared is a daemon that opens outbound connections to Cloudflare’s edge and keeps them alive. Traffic addressed to a hostname you control flows down those connections to the daemon, which forwards it to whatever local service you configured. Because the connection originates from the server, your firewall needs zero inbound rules. Port 22 stays closed at the edge router, on the cloud firewall, and inside the host’s own iptables ruleset.

The diagram below shows the two layers involved, the identity layer that decides whether a request may exist, and the transport layer that carries an approved session to sshd.

Zero-Trust SSH Through Cloudflare Tunnel

There are three properties worth naming, because they explain the failure modes later.

  1. The tunnel is stateful. If the daemon dies, the hostname returns a Cloudflare error page rather than timing out silently.
  2. Access policy evaluation happens before the origin sees anything. A blocked user never reaches sshd, so their attempts never appear in auth.log.
  3. The origin sees a connection from 127.0.0.1. Source IP based rules on the host become meaningless, so all authorisation must move to the edge.

That last point is the one that surprises people mid migration. If you have sshguard, fail2ban, or an iptables rule that permits only your office range, those controls stop seeing real client addresses. You will need to decide which layer owns authorisation before you cut over, not after.

Step 1: Publish the SSH Service Through cloudflared

Start on the server that runs sshd. Install cloudflared from Cloudflare’s repository, then authenticate the daemon.

# Debian 12 or Ubuntu 22.04+
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
  | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
https://pkg.cloudflare.com/cloudflared any main" \
  | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install -y cloudflared

# Create the tunnel and write credentials to /etc/cloudflared/
sudo cloudflared tunnel login
sudo cloudflared tunnel create ssh-edge

The create command prints a tunnel UUID and writes /etc/cloudflared/<uuid>.json. That file is a credential. Treat it like an SSH private key and keep it out of any repository.

Now write the tunnel configuration. The ingress list is evaluated top to bottom, so the SSH hostname rule must come before the catch-all.

# /etc/cloudflared/config.yml
tunnel: ssh-edge
credentials-file: /etc/cloudflared/9f2c1a44-6b3e-4d21-8a77-1c0e5b2d4f88.json

originRequest:
  connectTimeout: 30s
  noTLSVerify: false

ingress:
  - hostname: ssh.dowithsudo.com
    service: ssh://localhost:22
  - hostname: git.dowithsudo.com
    service: http://localhost:3000
  - service: http_status:404

Two settings in that file carry more weight than they look. connectTimeout: 30s gives sshd room to complete its banner exchange on a slow link, and the trailing http_status:404 rule guarantees that any hostname pointed at this tunnel without an explicit rule returns a refusal instead of falling through to some other service. If you run multiple hostnames on one daemon, the ingress patterns are worth reading in full in the Cloudflare Tunnel ports reference at tunnel ports explained.

Publish the hostname and start the service.

sudo cloudflared tunnel route dns ssh-edge ssh.dowithsudo.com
sudo cloudflared service install
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared --no-pager
● cloudflared.service - cloudflared
     Active: active (running) since Mon 2026-09-22 03:14:07 WIB
    Process: 2114 ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run
   Main PID: 2114 (cloudflared)
    Connections: 4 active edge connections

Four active connections is the healthy default. Two would still work, one means you are running without redundancy, and zero means the daemon registered but never established a data path.

Step 2: Close the Door on Port 22

The tunnel is useless if sshd still accepts connections from the internet. Bind it to loopback and keep the tunnel as the only path in.

# /etc/ssh/sshd_config.d/99-local-only.conf
ListenAddress 127.0.0.1
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
AllowUsers deploy ops-readonly
ClientAliveInterval 300
ClientAliveCountMax 2
sudo sshd -t && sudo systemctl reload ssh
sudo ss -tlnp | grep -E ':22\b'
LISTEN 0  128  127.0.0.1:22  0.0.0.0:*  users:(("sshd",pid=982,fd=3))

If that output shows 0.0.0.0:22 or [::]:22, a drop-in earlier in the include order is overriding your file. Files in sshd_config.d/ are read in lexical order, so name yours 99- and confirm with sshd -T | grep listenaddress.

While you are in the host’s SSH configuration, the broader baseline matters more than any single directive. The list I apply to every new node lives in the Linux server hardening checklist at hardening best practices, and it pairs well with this change.

Closing the port is the security decision. Everything else in this article is convenience layered on top of it.

Step 3: Put an Identity Policy in Front

In the Cloudflare Zero Trust dashboard, create a self-hosted application for the hostname, then attach at least one policy. The shape I use for production servers is two policies: one group-based rule for engineers, one break-glass rule for a single account with email OTP and a short session.

SettingValueReason
Application typeSelf-hosted, browser renderedEnables the web terminal
Session duration8 hoursOne work shift, no endless sessions
Identity providerGoogle Workspace SSOMFA inherited from the IdP
Policy 1Access group sre-oncallRoutine operations
Policy 2Email OTP, single accountBreak glass when the IdP is down
Browser renderingEnabledTerminal without local tooling

Policies are evaluated in order and the first match wins, so put the narrower rule above the wider one. Session duration is worth thinking about honestly. A twelve hour session on a database host means a stolen laptop keeps working through the night, while a one hour session means your on-call engineer re-authenticates during an incident. Eight hours is the compromise I have settled on, with the break-glass account set to one hour.

Step 4: Browser Terminal and Native CLI Access

With browser rendering enabled, visiting https://ssh.dowithsudo.com after authentication opens a terminal in the page. No client software, no key distribution, and the workspace survives a laptop swap because the credential is the SSO session.

For engineers who want their real terminal, configure the client side once.

# ~/.ssh/config on the workstation
Host ssh-edge
    HostName ssh.dowithsudo.com
    User deploy
    ProxyCommand cloudflared access ssh --hostname %h
    ServerAliveInterval 30
# Optional: issue a short-lived certificate so the command skips the browser flow
cloudflared access ssh-gen --hostname ssh.dowithsudo.com
ssh -o ProxyCommand="cloudflared access ssh --hostname %h" deploy@ssh.dowithsudo.com

Short-lived certificates are the better default for humans. They expire on their own, they are scoped to one hostname, and they remove the temptation to copy a private key onto a third device. Service accounts are a different problem, and for those I prefer a Cloudflare Access service token over a shared key.

Failure Modes and Troubleshooting

1. Cloudflare error 1033 on the hostname

Symptom: the browser shows “Argo Tunnel error 1033” and the tunnel shows as unhealthy in the Zero Trust dashboard.

sudo systemctl status cloudflared --no-pager
sudo journalctl -u cloudflared -n 50 --no-pager
sudo cloudflared tunnel info ssh-edge

The common causes are a stale credential file after tunnel create was rerun, a config.yml with the wrong tunnel name, or outbound UDP 7844 blocked by the host firewall. If your provider filters non-standard outbound UDP, add --protocol http2 on the service command line and restart.

2. Access allows the user, but the session closes after a few seconds

Symptom: authentication succeeds, the terminal opens, and then drops with an empty banner.

This is almost always an sshd problem rather than an Access problem. Check journalctl -u ssh -n 30 for Connection reset by peer or Timeout before authentication. Raise LoginGraceTime to 120 seconds and confirm that ClientAliveInterval is set, because idle sessions on a mobile link get reaped aggressively by intermediate NAT.

3. The policy is bypassed because someone kept a public port open

Symptom: the Zero Trust logs show no authentication events for a user who claims they connected fine.

sudo ss -tlnp | grep ':22\b'
sudo nft list ruleset | grep -A3 'dport 22'

A leftover sshd instance on a second address, or a host firewall rule that still permits 22 from a management range, gives you a path around Access that produces no audit trail. This is the one failure mode I would call a security incident rather than an operational annoyance. Close it before you tell anyone the tunnel is live.

Production Verification Checklist

CheckCommand or locationExpected
No public SSH listenerss -tlnp | grep :22Only 127.0.0.1:22
Tunnel healthycloudflared tunnel info ssh-edge4 active connections
Service persistentsystemctl is-enabled cloudflaredenabled
Access policy attachedZero Trust dashboardAt least 2 policies
Session durationZero Trust dashboard8 hours or less
Browser rendering onZero Trust dashboardEnabled
Extra hostnames refusedcurl -sI https://random.dowithsudo.comHTTP 404 from the catch-all
Auth auditableZero Trust logsLogin events with identity
Host firewall cleannft list ruleset | grep 'dport 22'No allow rule from WAN

Run the first and last rows after every configuration change. They are the two checks that catch a tunnel that looks healthy while the old access path is still standing open, which is exactly how the previous setup in this article went wrong in the first place.

Closing Thoughts

If you are weighing this against a mesh VPN, the two solve different problems. A mesh gives you a routable private network between devices, which is what you want for database admin over arbitrary ports. A tunnel gives you one hostname, one identity policy, and a browser session, which is what you want for shell access from a machine you do not control. The comparison in WireGuard vs Tailscale covers the mesh side in more depth.

My advice is to pick the target host that generates the loudest auth.log, move it behind a tunnel this week, and then run grep -c 'Failed password' /var/log/auth.log thirty days later. On the instance from the opening story, that count went from 118,000 failed logins to zero. Not fewer. Zero, because there is no longer anything for a scanner to reach.

Implementation Checklist

  • Replicate the steps in a controlled lab before production changes.
  • Document configs, versions, and rollback steps.
  • Set monitoring + alerts for the components you changed.
  • Review access permissions and least-privilege policies.

Need a Hand?

If you want this implemented safely in production, I can help with assessment, execution, and hardening.

Contact Me
$ partner-recommendation $200 Free Credit (60 Days)

Deploy on Cloud Infrastructure with $200 Free Credit

Spin up high-performance SSD cloud Droplets, managed Kubernetes, and databases in seconds. Test your containers and production workloads with $200 in free credits.

Claim $200 Free Credit →

Transparency: We independently test and operate all recommended infrastructure. If you use our partner links, you receive free promotional credits and support our testing lab at zero extra cost to you.

~$ subscribe --weekly-runbooks

Weekly Production DevOps Runbooks

Join 1,000+ infrastructure engineers receiving real-world Linux troubleshooting, Proxmox clustering setups, and Docker optimization runbooks every Tuesday. Zero spam, unsubscribe anytime.

Kamandanu Wijaya

About the Author

Kamandanu Wijaya

IT Infrastructure & Network Administrator

Infrastructure & network administrator with 15+ years of enterprise experience, focused on stability, security, and automation.

Certifications: Google IT Support, Cisco Networking Academy, DevOps.

$ share

Need IT Solutions?

DoWithSudo is ready to help setup servers, VPS, and your security systems.

Contact Us
[ 01 ] // More from the log

Related Posts

WhatsApp