Install cloudflared
On Debian / Ubuntu, install from Cloudflare's apt repository:
sudo mkdir -p --mode=0755 /usr/share/keyrings
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 bookworm main' \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update
sudo apt install cloudflared
cloudflared --version
On Arch: pacman -S cloudflared. On macOS: brew install cloudflared. Single-binary downloads are also published on each release page.
Authenticate
The daemon needs a cert tied to a Cloudflare account. Run:
cloudflared tunnel login
This prints a Cloudflare URL — opening it in a browser logs into your Cloudflare account and authorizes cloudflared to manage one of your zones. The resulting cert.pem lands in ~/.cloudflared/cert.pem on the machine.
The zone selected during login is the only one this cert can manage tunnels for. For multiple zones, log in multiple times against different ~/.cloudflared directories, or use the dashboard route below.
Create a tunnel
cloudflared tunnel create my-tunnel
This generates a credentials file at ~/.cloudflared/<tunnel-id>.json (UUID-named) holding the tunnel's secret. Keep that file safe — it's the equivalent of an API token for this tunnel.
Route DNS to the tunnel
One CNAME per public hostname, pointed at the tunnel:
cloudflared tunnel route dns my-tunnel app.example.com
cloudflared tunnel route dns my-tunnel grafana.example.com
Each call creates a proxied CNAME in Cloudflare DNS (orange-cloud) pointing at <tunnel-id>.cfargotunnel.com. Visitors get Cloudflare's anycast IPs back; the origin server never appears in DNS.
Ingress configuration
Create /etc/cloudflared/config.yml describing which hostname maps to which upstream:
tunnel: <tunnel-id>
credentials-file: /etc/cloudflared/<tunnel-id>.json
ingress:
- hostname: app.example.com
service: http://localhost:3000
- hostname: grafana.example.com
service: http://localhost:3001
- hostname: ssh.example.com
service: ssh://localhost:22
- hostname: rdp.example.com
service: rdp://localhost:3389
# Catch-all — required as the last rule
- service: http_status:404
Rules are evaluated top-to-bottom. The last rule must be a catch-all (http_status:404 or a generic upstream) — cloudflared refuses to start without one.
The service: protocol matters. http:///https:// for normal web apps; tcp:// for raw TCP; ssh://, rdp://, smb:// for the corresponding protocols (with Cloudflare WARP or the cloudflared access client on the receiving end).
Move the credentials file from ~/.cloudflared/ to /etc/cloudflared/ alongside the config so the system service can read it.
Run as a service
sudo cloudflared service install # installs and enables the systemd unit
sudo systemctl status cloudflared
journalctl -u cloudflared -f
Within a few seconds, the tunnel shows as healthy in the Cloudflare dashboard (Zero Trust → Access → Tunnels), and the routed hostnames go live.
cloudflared prefers QUIC for the tunnel. If outbound UDP/7844 is blocked by the network the server is on, cloudflared falls back to HTTP/2 over TCP/443 automatically — no config needed. For diagnostics, cloudflared tunnel info my-tunnel shows the active protocol per connection.
Add Cloudflare Access
Access is the zero-trust layer. Each tunnelled route can require an authenticated identity before any traffic reaches the origin.
In the dashboard: Zero Trust → Access → Applications → Add an application. Self-hosted, hostname grafana.example.com, session duration something sensible (8 hours is a reasonable default). Then add a policy:
- Action: Allow
- Include:
Emails ending in @example.com— orCountry,IP range,Login Methods,Service tokens, etc.
Identity providers (Google Workspace, GitHub, Okta, Azure AD, generic OIDC, SAML, one-time-PIN-by-email) are configured under Settings → Authentication. With Access enabled, visiting grafana.example.com first redirects to a Cloudflare-hosted login page; only after authentication does the request hit the tunnel and the upstream Grafana.
SSH-over-tunnel
Two patterns:
- Browser-rendered SSH. Add a self-hosted Access app for the SSH hostname with Browser rendering: SSH. Users hit
https://ssh.example.com, authenticate, and a full SSH terminal opens in the browser. No client install. - Terminal SSH. Users install
cloudflaredon their workstation and add to~/.ssh/config:Host ssh.example.com ProxyCommand cloudflared access ssh --hostname %hssh ssh.example.comthen opens the Access auth flow in a browser, gets a short-lived token, and tunnels SSH through cloudflared to the origin.
Either way, the SSH daemon on the origin never has an open inbound port — the only inbound listener on the box is whatever you have on localhost.
What's worth knowing
- Tunnels can run in multiple
cloudflaredprocesses on different machines simultaneously — Cloudflare load-balances across the connected replicas. Run two or more for HA. - Tunnel credentials are sensitive but rotatable:
cloudflared tunnel token --cred-file ... my-tunnelreissues, then redeploy. - For private networks (RDP/SSH/SMB without per-host DNS),
cloudflaredcan advertise a subnet viawarp-routing: trueand route any RFC1918 traffic from authenticated WARP clients through the tunnel — effectively a Cloudflare-mediated mesh VPN. - Free tier hard limits: 50 tunnels per account, no inbound idle traffic limits at the time of writing, but the Access policy is what really gates abuse.