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.

QUIC out of the office

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 — or Country, 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:

  1. 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.
  2. Terminal SSH. Users install cloudflared on their workstation and add to ~/.ssh/config:
    Host ssh.example.com
      ProxyCommand cloudflared access ssh --hostname %h
    ssh ssh.example.com then 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 cloudflared processes 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-tunnel reissues, then redeploy.
  • For private networks (RDP/SSH/SMB without per-host DNS), cloudflared can advertise a subnet via warp-routing: true and 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.