Cloudflare renames Zero Trust navigation fairly often — Access has lived under
Access, Zero Trust, and Cloudflare One; Tunnels has moved
between Access and Networks. Paths below reflect the current layout. If a label
doesn't match, look for the same noun one level up or down — the structure has been stable even
as the names shift.
Before you start
Four prerequisites. Missing any one of them causes a failure much later that looks unrelated.
| Requirement | How to confirm |
|---|---|
| Domain on Cloudflare | The zone appears in your account and its status is Active. The nameservers at your registrar must point to Cloudflare — a domain merely added but not delegated will not work. |
| Zero Trust onboarded | Visit one.dash.cloudflare.com. First visit asks you to choose a team name, which becomes <team>.cloudflareaccess.com. Free plan covers 50 users. |
| Same account owns both | The zone and the Zero Trust organization must be under the same account. If you have several, check the account switcher. Apps created in the wrong account silently protect nothing. |
| App listening locally | On the Debian box: ss -tlnp shows your service, e.g. 127.0.0.1:8000. Note the exact port — you need it in step 06. |
What you are building
The connector on your Debian server opens outbound connections to Cloudflare's edge. Visitors reach a hostname on your domain, Cloudflare checks their identity against your Access policy, and only then forwards the request down the existing tunnel to your local port. Your firewall never opens an inbound port, and the server's IP is never published.
Create the tunnel
Dashboard-managed, so configuration lives in Cloudflare rather than a local YAML file.
- Choose connector type Cloudflared and select Next.
- Give the tunnel a name that identifies the machine, not the app — one tunnel commonly serves several hostnames.
debian-homeis a better name thangrafana. - Select Save tunnel.
The next screen shows install commands containing a long tunnel token. Leave this page open; you need it in the next step.
That token alone is enough for any machine to join the tunnel as a connector. Don't paste it into chats, screenshots, or a git repo. If it leaks, delete the tunnel and create a new one — the token cannot be rotated independently.
Remotely-managed vs locally-managed
Creating the tunnel in the dashboard makes it remotely-managed: hostnames and routes are
edited in the UI, and the server holds only the token. The alternative, cloudflared tunnel create
on the server, is locally-managed and reads a config.yml on disk. The two modes
do not mix — a remotely-managed tunnel ignores local ingress rules entirely, which is a common source
of "my config changes do nothing".
Install the connector on Debian
Use the apt repository rather than the one-off .deb so the connector receives updates.
Add Cloudflare's repository
# keyring directory (already present on most systems)
sudo mkdir -p --mode=0755 /usr/share/keyrings
# repository signing key
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
# add the repo ("any main" is correct for all Debian releases)
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-get update && sudo apt-get install -y cloudflared
Confirm the binary is present and reports a recent version:
cloudflared --version
Install the service with your token
Copy the command from the dashboard, or substitute the token yourself:
sudo cloudflared service install <YOUR_TUNNEL_TOKEN>
This writes a systemd unit, enables it at boot, and starts it. Verify:
systemctl status cloudflared
journalctl -u cloudflared -n 40 --no-pager
Healthy logs contain Registered tunnel connection lines — usually four, to two
Cloudflare data centres. Back in the dashboard the tunnel status flips to Healthy
within a few seconds. Do not continue until it does.
The connector needs outbound access on TCP 7844 and UDP 7844. On a
restrictive network, allow those egress ports. If UDP is blocked you'll see QUIC errors in the log;
forcing --protocol http2 is the usual workaround.
Alternative: Cloudflare Mesh
A different shape of connectivity. Replaces steps 01, 02 and 06 — not an addition to them.
Cloudflare Mesh is the current name for WARP Connector; existing
deployments keep working unchanged. Instead of publishing a hostname, every participant — Linux
servers, laptops, phones — receives a private Mesh IP from
100.96.0.0/12 and can reach the others directly over TCP, UDP or ICMP.
Which one fits
| Cloudflare Tunnel | Cloudflare Mesh | |
|---|---|---|
| Traffic | Inbound only, to published services | Bidirectional, device to device |
| Layer | L7 — HTTP, SSH, RDP via hostname | L3/L4 — any TCP, UDP or ICMP |
| Addressing | Public hostname on your domain | Private Mesh IP, nothing published |
| Who reaches it | Anyone on the internet who passes Access | Only enrolled participants |
| Enforced by | Access applications and policies | Gateway network policies and device posture |
| Connections | Proxied over WebSocket | Long-lived TCP preserved |
| Best for | A web app you want to reach from any browser | Databases, replication, ERP, SMB, LAN-to-LAN |
Mesh has no public hostname, so steps 04–05 of this runbook — the Access application and its policy — have nothing to attach to. Authorisation moves to Gateway network policies under Networks › Policies, matched on identity, device posture, destination IP and port. Traffic still crosses Cloudflare, so policy is still centrally enforced; it's simply a different control surface.
Prerequisites
- Zero Trust subscription — the Free plan is eligible.
- Debian 12 or newer (also RHEL 9+, Ubuntu 22.04 LTS+), amd64 or arm64. Note this is stricter than
cloudflared, which runs happily on Debian 11. - 75 MB disk, 35 MB memory. An MTU of 1381 is recommended on the node's network.
- The device profile must use MASQUE, not WireGuard — see the caveat at the end.
Create the node
- Name the node after the machine, e.g.
db-server. - Select Create node.
- The wizard shows a connector token. Capture it now — it is single-use, and you'll need the node detail page to retrieve it if you navigate away.
Enable IP forwarding on Debian
Required before installing, so the node can route subnets rather than only answer for itself:
printf 'net.ipv4.ip_forward = 1\nnet.ipv6.conf.all.forwarding = 1\nnet.ipv6.conf.all.accept_ra = 2\n' \
| sudo tee /etc/sysctl.d/99-zzz-cloudflare-warp-connector.conf
sudo sysctl --system
Install the Cloudflare One Client
A different package and repository from cloudflared — this is cloudflare-warp:
curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg \
| sudo gpg --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $(. /etc/os-release && echo $VERSION_CODENAME) main" \
| sudo tee /etc/apt/sources.list.d/cloudflare-client.list
sudo apt-get update && sudo apt-get install -y cloudflare-warp
Join the mesh, headless
sudo warp-cli --accept-tos connector new <CONNECTOR_TOKEN>
sudo warp-cli --accept-tos connect
warp-cli --accept-tos status
The node appears in Networking › Mesh with its assigned Mesh IP. No interactive login happens — the token is what authenticates the machine.
Advertise the subnets behind it
A node can publish CIDR routes, making an entire backend subnet reachable by Mesh IP from any enrolled participant. Add them on the node's detail page. Client devices — laptops, phones — can join the mesh but cannot advertise routes; only nodes can.
Enrol the devices that will connect
Install the Cloudflare One Client on each laptop or phone, choose Zero Trust security, enter your team name, and authenticate with your identity provider. Then check the device profile under Settings › WARP Client › Profile settings:
- Protocol set to MASQUE.
- Split Tunnel in Include mode, with
100.96.0.0/12routed through Cloudflare. Include mode matters — it keeps the rest of the device's connectivity untouched.
Verify
nc -vz <MESH-IP> 22 # port open through the mesh
ping <MESH-IP> # ICMP works too, unlike a tunnel
On a WireGuard device profile you silently lose hostname routes, IPv6 CIDR routes, and high availability. Nothing errors — the features simply don't work — so confirm the protocol before debugging anything else.
100.96.0.0/12 is CGNAT space, chosen so it won't collide with RFC 1918 ranges already in
use on your LANs. If it does conflict with something in your environment, the subnet is configurable
on the Mesh overview page.
Add an identity provider
Do this before creating the application, so the provider is selectable when you need it.
Option A — One-time PIN
Enabled by default and requires no configuration. Cloudflare emails a six-digit code to the address the visitor enters, and the policy decides whether that address is allowed. For a single-user private app this is genuinely sufficient — choose it unless you have a reason not to.
Option B — Google
Smoother day to day, but requires registering an OAuth client first.
- In Google Cloud Console › APIs & Services › Credentials, create an OAuth client ID of type Web application.
- Set the authorised redirect URI to exactly:
https://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/callback - Copy the Client ID and Client secret into the Cloudflare Google login method.
- Select Save, then Test. Fix it here — a provider that fails its own test will fail behind an application too, but with a far more confusing error.
Google rejects the callback on any mismatch, including a trailing slash or http instead of
https. Your team name is visible under Settings › Custom Pages if you
don't remember it.
Create the Access application
Create this before the hostname exists. Order matters, and this is why.
The moment a public hostname routes to your tunnel, the app is reachable by anyone on the internet. If the Access application doesn't exist yet, there is nothing to stop them. Creating the application first means the hostname comes up already behind the login wall, with no exposure window at all.
Application configuration
| Field | Value and reasoning |
|---|---|
| Application name | Anything readable, e.g. Grafana. Shown on the login screen and in the App Launcher. |
| Session duration | 24 hours is a sensible default. Shorter means more logins; Until browser closes suits shared machines. |
| Subdomain | e.g. grafana. Must not be blank — an empty subdomain binds the application to the apex domain and it will not match your hostname. |
| Domain | Your zone, e.g. example.com. |
| Path | Leave empty to protect the whole app. A path here protects only that path and leaves everything else open. |
Identity providers
Untick Accept all available identity providers and select only the one you configured. Leaving it ticked means every enabled method is offered, including One-time PIN — which is fine if intended, and a surprise if not.
With exactly one provider selected, enable Automatically redirect to identity to skip the chooser screen and send visitors straight to the login.
Write the policy
An application without an attached policy protects nothing. This is the step most often left half-finished.
On the policies step choose Add a policy › Create new policy.
Action — the field that matters most
| Action | Effect |
|---|---|
| Allow | Matching users may enter after authenticating. This is the one you want. |
| Block | Matching users are refused. Used for exceptions layered above an Allow. |
| Bypass | Matching requests skip authentication entirely. Bypass with an Everyone include makes the app fully public — the single most common way a "protected" app turns out not to be. |
| Service Auth | Machine-to-machine only, via service token headers. No human login is possible. |
Rules
- Include — match any one of these and the policy applies. Put your identity here: selector
Emails, value your address. - Require — additional conditions that must all hold, e.g.
Countryor a valid device posture. Optional. - Exclude — carve-outs that override an Include match.
A minimal single-user policy:
| Setting | Value |
|---|---|
| Policy name | Allow owner |
| Action | Allow |
| Include | Emails → you@example.com |
For a team, prefer Emails ending in with your domain, or create a reusable
Access group under Access › Groups and include the group instead —
then one edit updates every application.
Saving the policy is not saving the application. Continue through the remaining steps and select Save application on the final screen. Then confirm the app is listed under Access › Applications. If it isn't there, it wasn't created, and the hostname you route in the next step will be wide open.
Route the public hostname
This publishes the app. With steps 04–05 done, it comes up already protected.
| Field | Value |
|---|---|
| Subdomain | grafana — must match the Access application exactly |
| Domain | example.com |
| Path | empty |
| Type | HTTP for a plain local service; HTTPS if your app terminates TLS itself |
| URL | localhost:8000 — the address as the Debian box sees it |
Save. Cloudflare creates the proxied CNAME to
<tunnel-id>.cfargotunnel.com automatically — you do not add a DNS record by hand.
Additional application settings
- No TLS Verify — required when the origin is
HTTPSwith a self-signed certificate, which is typical for Proxmox, Unifi, and similar appliances. - Origin Server Name / HTTP Host Header — set when the app does name-based virtual hosting and returns the wrong site or a redirect loop.
- Connect timeout — raise for slow-starting applications.
localhost means the connector's own network namespace. If cloudflared runs in a
container and the app runs in another, localhost resolves to the wrong place — use the
compose service name, e.g. http://grafana:3000, on a shared network.
Verify honestly
Most verification methods quietly test the wrong thing. This one doesn't.
If the device you're testing from runs the WARP client enrolled in the same Zero Trust organization, Cloudflare recognises it and may satisfy the policy from device identity alone — no login shown. Incognito does not help: private windows drop cookies but still egress through the same WARP tunnel, so identity still applies. A protected app and a wide-open app look identical from such a device.
The test that actually works
Use a device outside the WARP tunnel — a phone on mobile data with WARP off is easiest — and open the hostname.
- Login screen appears → Access is enforcing. Sign in; the app should load.
- App loads immediately → not protected. Go to step 08's checklist.
From a shell, on a machine without WARP
curl -sI https://grafana.example.com | head -1
| Response | Meaning |
|---|---|
| 302 | Correct. Redirect to <team>.cloudflareaccess.com — Access is intercepting. |
| 200 | The app answered without authentication. Either it's unprotected, or this machine carries WARP identity. |
| 502 | Access passed you through, but the origin is unreachable — check the local service. |
| 530 | Tunnel is down. Check systemctl status cloudflared. |
To see whether a 200 came from device identity, inspect the cookie Cloudflare issued:
curl -sI https://grafana.example.com | grep -i set-cookie
A CF_Authorization JWT whose payload contains "warp_as_auth": true means Access
did run and authenticated you as a known device — the app is protected, and you simply can't
observe that from this machine.
An off-network device is challenged for login, and the app loads after you authenticate. Nothing short of that confirms the setup.
Harden the origin
The tunnel protects one path in. These close the others.
Stop the app listening on the network
A service bound to 0.0.0.0 is still reachable from your LAN, and from the internet if a
router forwards to it — neither path touches Access. Bind to loopback instead:
# check what is exposed
ss -tlnp
Anything showing 0.0.0.0:<port> that only needs to serve the tunnel should be rebound to
127.0.0.1 in the app's own configuration. Where that isn't possible, block the port:
sudo ufw allow from 127.0.0.1 to any port 8000
sudo ufw deny 8000
Remove any port-forward on the router for these services — the tunnel replaces it entirely.
Worth doing next
- Default-deny. Add a final policy with action
Blockand includeEveryone, placed lowest in precedence, so a mistake in an Allow rule fails closed. - Service tokens for API clients — Access › Service Auth, then a
Service Authpolicy including that token. Keeps automation working without weakening the human policy. - Verify the JWT at the origin for anything sensitive, so a request that somehow bypasses the edge is still rejected by the app.
- Watch the logs — Logs › Access records every allow and deny with identity and policy.
Troubleshooting
Symptoms in the order you're likely to meet them.
| Symptom | Cause and fix |
|---|---|
| App is public despite an app | Subdomain field left blank, a path was entered, the policy action is Bypass, the policy was never attached, or the application sits in a different Cloudflare account from the zone. Check in that order. |
| Error 1033 HTTP 530 | Tunnel not connected. systemctl status cloudflared, then journalctl -u cloudflared -n 50. Often egress to port 7844 is blocked. |
| HTTP 502 | Tunnel is up, origin is not. Confirm the service is listening and that the URL in the public hostname matches exactly, including scheme and port. |
| Origin TLS errors | Self-signed certificate on an HTTPS origin. Enable No TLS Verify, or point at HTTP if the app offers it. |
| Redirect loop after login | App forces its own redirect to a different hostname, or sets a conflicting cookie. Set HTTP Host Header to the public hostname; check the app's own "site URL" setting. |
| Login succeeds, then denied | Authentication worked but no Allow policy matched that identity. Compare the email in Logs › Access against the policy — alias or capitalisation mismatches are common. |
| Config edits do nothing | The tunnel is remotely-managed, so a local config.yml is ignored. Edit the public hostname in the dashboard instead. |
| Works locally, fails elsewhere | Usually the reverse of the WARP trap — you're testing from an enrolled device. Retest on mobile data with WARP off. |
Useful commands
systemctl status cloudflared # service state
journalctl -u cloudflared -f # live logs
sudo systemctl restart cloudflared # after config changes
ss -tlnp # what is listening locally
cloudflared --version # connector version