Quick answer
A Cloudflare Tunnel runs a small daemon, cloudflared, on your server that dials out to Cloudflare’s edge and holds the connection open. Requests arrive over that outbound link, so your firewall keeps every inbound port closed and your origin needs no public IP. Create the tunnel in the dashboard, run the install command with its token, then map a hostname to a local service.
Most of the Cloudflare Tunnel tutorials still ranking for this were written around 2022. They walk you through cloudflared tunnel login, a hand-written config.yml and a DNS route command, and then stop. That path still works, but it is no longer the one Cloudflare puts in front of you, and following it blind means you write config on a box that the dashboard then has no idea about.
This post covers both routes properly: the dashboard-managed tunnel that Cloudflare now treats as the default, and the locally-managed file-based tunnel that is still the better answer when your infrastructure lives in Git. It also covers the parts the old guides skip entirely: ingress rule ordering, how a tunnel interacts with Cloudflare’s cache, what breaks in WordPress behind TLS termination, and a real troubleshooting section.
Access policies, identity providers and who is allowed through the door are a separate subject. That is covered in the guide to Zero Trust access policies. This one is about transport and mechanics.
What a Cloudflare Tunnel is, and what it does not protect
A Cloudflare Tunnel is a persistent outbound connection from your server to Cloudflare’s network, created by a daemon called cloudflared, over which Cloudflare forwards inbound requests to a local service. Cloudflare’s own description is blunt about the direction of travel: cloudflared initiates an outbound connection through your firewall from the origin to the Cloudflare global network, and traffic then flows both ways over that link.
The inversion matters. In a normal setup you open 80 and 443, publish an A record pointing at your IP, and every scanner on the internet starts knocking within minutes. With a tunnel there is nothing to knock on. Your firewall’s inbound rules can be empty. Your origin does not need a public IP at all, and the IP it does have never appears in DNS.
That removes a real class of attack: direct-to-origin traffic that bypasses Cloudflare, IP-based scanning, brute force against SSH or wp-login on the raw address, and the whole category of “someone found the origin IP in an old DNS history record and now hits it directly.” Origin IP leakage stops being a problem because there is no origin IP in play.
Here is what it does not do. A tunnel is a pipe, not a filter. Anything Cloudflare routes down that pipe still reaches your application, so a vulnerable plugin is still vulnerable, a weak admin password is still weak, and an unauthenticated endpoint is still unauthenticated. It does not patch anything, it does not rate limit by itself, and it does not stop a compromised server from making outbound connections. If you want authentication in front of the app, that is Access policy work, not tunnel work. Standard WordPress hardening steps apply exactly as before.
A tunnel changes who can reach your server. It does not change what happens once they do.
Installing cloudflared on Debian, Docker and systemd
Install cloudflared from Cloudflare’s own apt repository on Debian and Ubuntu, from the cloudflare/cloudflared image on Docker, and register it with systemd so it survives a reboot. The apt repository is the version you want on a long-lived server, because package-managed installs are upgraded by apt rather than by the binary updating itself.
Debian and Ubuntu, via the Cloudflare apt repository
# Add the Cloudflare GPG key
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
# Add the repository
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
# Install
sudo apt-get update && sudo apt-get install cloudflared
# Confirm
cloudflared --version
Upgrading later is two commands, because the package manager owns the binary and the service needs a restart to pick up the new one.
sudo apt-get update && sudo apt-get install --only-upgrade cloudflared
sudo systemctl restart cloudflared.service
Docker
docker run --pull always cloudflare/cloudflared:latest \
tunnel --no-autoupdate run --token <TUNNEL_TOKEN>
The --no-autoupdate flag belongs here specifically. In a container you want the image to be the unit of upgrade, pulled by --pull always or by your orchestrator, not a binary that replaces itself inside a running container. Cloudflare notes that an update restarts cloudflared and that this affects traffic in flight, so if you cannot tolerate that, run more than one replica of the connector.
As a systemd service
# Dashboard-managed tunnel: pass the token
sudo cloudflared service install <TUNNEL_TOKEN>
# Locally-managed tunnel: point at your config explicitly
sudo cloudflared --config /home/<USER>/.cloudflared/config.yml service install
sudo systemctl start cloudflared
sudo systemctl status cloudflared
Watch out
Running service install under sudo without --config is the classic own goal. Root’s home is /root, your config is in /home/you, and the service starts with an empty ingress list and no obvious error. Always pass the absolute config path.
Dashboard-managed or locally-managed: pick one before you start
Choose dashboard-managed if you want routing to be editable from a browser and you are running one or two connectors; choose locally-managed if your configuration belongs in version control alongside the rest of your infrastructure. The two are not interchangeable halfway through. A tunnel created with a token ignores a local config.yml ingress block, and a tunnel created locally will show no routes in the dashboard UI.
| Aspect | Dashboard-managed (remote) | Locally-managed (config.yml) |
|---|---|---|
| Where routing lives | Cloudflare, edited in the UI or API | A YAML file on the origin |
| How the connector authenticates | A tunnel token | cert.pem plus a credentials JSON file |
| Changing a route | Save in the UI, applies without a restart | Edit the file, restart the service |
| Version control | Not natively; use the API if you need it | Native, the file is the source of truth |
| Secrets on disk | One token, often in the service unit | Credentials JSON plus account cert.pem |
| Running several replicas | Same token on every host | Same credentials file on every host |
| Best fit | Homelab, a client staging box, quick exposure of one service | Config-managed fleets, Ansible, containers built from a repo |
How to set up a dashboard-managed Cloudflare Tunnel
Create the tunnel in the Cloudflare One dashboard, copy the generated install command onto your origin, then add a route mapping a hostname to a local service URL. The whole flow is four screens and one terminal command.
- In the Cloudflare dashboard, open Networking > Tunnels. Older tutorials will tell you to look under Access; the section has moved, so navigate by the word Tunnels rather than by the path you remember.
- Select Create a tunnel, give it a name that identifies the machine rather than the site, and confirm. One tunnel per host is a cleaner model than one per site.
- Pick your operating system. Cloudflare generates an install command containing the tunnel token. Run it on the origin. On Linux that command is the
cloudflared service install <TUNNEL_TOKEN>form shown above. - Wait for the connector to show as connected, then continue.
- On the tunnel’s Routes tab choose Add route, then Published application. Enter the subdomain and pick the domain, then set the service URL to the local address, for example
http://localhost:8080. Save.
The DNS record is created for you, proxied, as a CNAME pointing at <TUNNEL_UUID>.cfargotunnel.com. Once that route is saved, anyone on the internet can reach the application at that hostname. If that is not what you want, this is the exact point where access policies belong, and that is the sibling post’s job.
How to set up a locally-managed tunnel with config.yml
A locally-managed tunnel is four commands and one file: authenticate, create the tunnel, write the ingress rules, route DNS, then run. Start by logging in, which writes a cert.pem into ~/.cloudflared that authorises this machine to create tunnels on your account.
cloudflared tunnel login
cloudflared tunnel create wpcolt-staging
cloudflared tunnel list
The create command prints a tunnel UUID and writes a credentials JSON file, normally ~/.cloudflared/<UUID>.json. That file is the tunnel’s private key material. Treat it like an SSH key: it does not go in a public repository, and if it leaks you delete the tunnel and create a new one.
Now the config file. This one exposes three services from a single host, which is the realistic case rather than the single-service example every stale tutorial uses.
tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /root/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
# Applies to every rule unless a rule overrides it
originRequest:
connectTimeout: 30s
ingress:
# 1. Long-running exports on the app, given a longer timeout
- hostname: staging.example.com
path: ^/wp-admin/export\.php
service: http://localhost:8080
originRequest:
connectTimeout: 60s
# 2. The WordPress staging site itself
- hostname: staging.example.com
service: http://localhost:8080
# 3. An internal dashboard on a self-signed cert
- hostname: metrics.example.com
service: https://localhost:3000
originRequest:
noTLSVerify: true
# 4. A legacy vhost that only answers to its own Host header
- hostname: legacy.example.com
service: http://127.0.0.1:8081
originRequest:
httpHostHeader: old-internal.local
# 5. Mandatory catch-all. Without this, cloudflared will not start.
- service: http_status:404
Then create the DNS records and start the tunnel. Run route dns once per hostname.
cloudflared tunnel route dns wpcolt-staging staging.example.com
cloudflared tunnel route dns wpcolt-staging metrics.example.com
cloudflared tunnel route dns wpcolt-staging legacy.example.com
cloudflared tunnel ingress validate
cloudflared tunnel run wpcolt-staging
Pro tip
Run cloudflared tunnel ingress validate before every restart, and cloudflared tunnel ingress rule https://staging.example.com/wp-admin/ to see which rule a given URL actually matches. That second command settles most ordering arguments in one line.
How ingress rules match traffic
Ingress rules are evaluated top to bottom and the first match wins, which makes ordering the single most consequential thing in the file. Cloudflare’s configuration file reference states the matching rules plainly: a rule with no hostname matches all hostnames, and a rule with no path matches all paths.
Hostname and path matching
- Wildcards work at the subdomain level, as in
*.example.com, but not in the middle of a hostname. - Paths are regular expressions in Go syntax, not glob patterns.
/apimatches anything containing that substring; anchor it as^/api/if that is what you mean. - A rule with both hostname and path must satisfy both.
- The last rule must be a catch-all with no hostname and no path.
http_status:404is the usual choice. Omit it and the tunnel refuses to start.
The ordering trap is putting a bare hostname: staging.example.com rule above a more specific path rule for the same hostname. The broad rule matches first, the specific one never fires, and you spend twenty minutes wondering why your timeout override does nothing.
The originRequest options that actually matter
| Option | Default | Use it when |
|---|---|---|
noTLSVerify | false | The origin serves HTTPS with a self-signed or internal cert. Only safe because the hop is localhost or a trusted LAN. |
originServerName | empty | The origin cert is valid but issued for a different name than the address you connect to. Preferred over noTLSVerify. |
httpHostHeader | empty | The local vhost only responds to a specific Host header, common with legacy Apache or Nginx name-based vhosts. |
connectTimeout | 30s | A slow origin, a cold PHP-FPM pool, or an import script that takes longer than half a minute to answer. |
caPool | empty | You have an internal CA and want real verification instead of switching it off. |
http2Origin | false | The origin speaks HTTP/2 and you want cloudflared to use it rather than HTTP/1.1. |
Cloudflare Tunnel vs ngrok, Tailscale Funnel and the alternatives
A tunnel is the right tool when you need a permanent public hostname on a domain you own, without opening ports; the alternatives win on ephemerality, on private-only access, or on control. The table below is the honest version of that comparison.
| Option | Open inbound ports | Static IP | Custom domain | Cost, hobby | Cost, team | TLS | Best fit |
|---|---|---|---|---|---|---|---|
| Cloudflare Tunnel | None | Not needed | Yes, any domain on your Cloudflare account | Free | Free for the connector | Cloudflare terminates at the edge; you choose http or https to the local service | Permanent public hostnames, staging sites, homelab services |
| ngrok | None | Not needed | Paid tiers only | Free tier, or 10 USD per month Hobbyist | From 20 USD per month plus usage | ngrok terminates on its edge | Short-lived demos, webhook testing against a laptop |
| Tailscale Funnel | None | Not needed | No, ts.net names only | Free Personal plan | 8 USD per user per month Standard | Tailscale-issued cert, ports 443, 8443 and 10000 only | Sharing one service out of an existing tailnet |
| Reverse proxy on a VPS | Yes, on the VPS | Yes | Yes | Cost of the VPS | Cost of the VPS | Yours to manage, usually Let’s Encrypt | Full control of headers, buffering and routing logic |
| Router port forwarding | Yes, on your firewall | Yes, or dynamic DNS | Yes | Free, plus any static IP fee | Not appropriate | Yours to manage on the origin | Almost nothing, in 2026 |
Two entries deserve a note. Tailscale Funnel cannot serve a custom domain, so it is out of the running the moment a client needs to see staging.theirbrand.com. And port forwarding is not merely old-fashioned; it publishes your residential or office IP in DNS and invites everything that follows.
Running WordPress behind a tunnel without breaking your URLs
WordPress behind a tunnel needs two things fixed: the site URL must match the tunnel hostname, and WordPress must be told the request arrived over HTTPS even though the connector delivered it to Apache or Nginx over plain HTTP. Skip either and you get a redirect loop or a page of mixed-content warnings.
The mechanism is simple. Cloudflare terminates TLS at the edge, cloudflared forwards the request to http://localhost:8080, and PHP therefore sees no HTTPS. WordPress builds every canonical URL, script tag and stylesheet link with http://, the browser refuses to load them over an HTTPS page, and half your admin styling disappears. That is the classic mixed content problem arriving through a new door.
Fix it in wp-config.php, above the line that says to stop editing. The proxy check follows the pattern WordPress documents for sites behind a reverse proxy that terminates TLS.
define( 'WP_HOME', 'https://staging.example.com' );
define( 'WP_SITEURL', 'https://staging.example.com' );
define( 'FORCE_SSL_ADMIN', true );
// X-Forwarded-Proto can be a comma separated list, so test for https
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] )
&& strpos( $_SERVER['HTTP_X_FORWARDED_PROTO'], 'https' ) !== false ) {
$_SERVER['HTTPS'] = 'on';
}
Three details people get wrong here. WP_HOME and WP_SITEURL override the database values without changing them, which is exactly what you want on a staging clone: pull the production database, and the constants keep the clone answering on the tunnel hostname instead of redirecting visitors to the live site. Both must include the scheme and no trailing slash. And if you clone production without setting them, the very first admin request bounces you to the production domain, which is where “my staging site keeps redirecting to live” comes from.
Existing content still carries absolute http:// URLs from wherever it was cloned. Constants do not rewrite post content, so run a search-replace over the database as well. If you are testing this pattern locally before you tunnel it, the same header logic applies to running HTTPS on localhost for WordPress.
What the tunnel does to your Cloudflare cache
A tunnelled hostname is a proxied record, so every Cloudflare caching feature applies to it exactly as it would to a normal origin. That is easy to forget because you never touched the orange cloud yourself, and it produces a specific and embarrassing failure: you push a fix to a staging site, the client reloads, and Cloudflare serves them the version from before the fix.
Set a Cache Rule that bypasses cache for the entire staging hostname before you hand the link to anyone. Development Mode is a three-hour switch, not a policy, so it is the wrong instrument for a site that lives for weeks. If you are not certain which layer is answering, the Cache Inspector in the WPColt toolbox will tell you what the response headers actually say, and the walkthrough on diagnosing Cloudflare cache bypass covers the header combinations that mean a rule is working versus quietly being ignored.
Troubleshooting cloudflared, and the mistakes behind each error
Almost every tunnel failure resolves to one of three questions: is the connector running, is Cloudflare routing the hostname to it, and can the connector reach the local service. Read the logs first, because they answer all three. Cloudflare’s common errors reference is the authoritative list; below is how the frequent ones present in practice.
# Service logs
sudo journalctl -u cloudflared -f
# Verbose run in the foreground while you reproduce
cloudflared tunnel --loglevel debug run wpcolt-staging
# Which connectors are actually attached
cloudflared tunnel info wpcolt-staging
Error 1033
Error 1033 means Cloudflare cannot find a healthy cloudflared instance for that hostname. Check the tunnel’s status in the dashboard: Inactive means no connector ever registered, Down means the process is not running, Degraded means some connections are failing. The second most common cause is a hostname that resolves through Cloudflare but was never routed to the tunnel, so the edge has the record and nothing behind it.
Tunnel healthy, but the site returns 502
A 502 with a connected tunnel means cloudflared reached Cloudflare but could not reach your local service. Test from the origin itself with curl -v http://localhost:8080. The usual causes are a service bound to a different port than the ingress rule names, a service listening only on a container network rather than localhost, or an ingress rule using https:// against an origin that only speaks plain HTTP.
DNS record already exists
The error “An A, AAAA, or CNAME record with that host already exists” means the hostname is taken, usually by the A record from the setup you are replacing. Delete the old record in the Cloudflare DNS tab, or pick a different hostname, then run cloudflared tunnel route dns again. Editing the existing record by hand to a CNAME pointing at <UUID>.cfargotunnel.com also works.
WebSocket upgrades failing
A “bad handshake” on a WebSocket usually is not the tunnel at all. Cloudflare lists several causes: WebSockets disabled on the zone, SSL/TLS mode set to Off, Bot Fight Mode intercepting the upgrade, the Binding Cookie enabled, or a Worker route overlapping the hostname. Work down that list before you touch the ingress file. In WordPress this bites live preview, some page builders and any plugin using a persistent connection.
Certificate errors and the noTLSVerify shortcut
An x509 error means the origin presents a certificate cloudflared does not trust, typically self-signed or issued for a different name. Reaching for noTLSVerify: true is the reflex, and it is acceptable when the connector and the service are on the same host. When they are not, use originServerName to match the cert’s actual name, or caPool to point at your internal CA, so verification still happens. The same misconfiguration is a documented cause of redirect loops, and a redirect loop with a valid config is worth checking against the usual too many redirects causes in WordPress before blaming the tunnel.
Unexplained restarts and connection churn
If the connector drops and reconnects on its own, autoupdate is a likely explanation. cloudflared checks for updates on a 24 hour default frequency and restarts into the new version, gracefully but not invisibly. That behaviour does not apply to package-manager installs, which is a good reason to prefer apt on servers. In Docker, pass --no-autoupdate and control versions through the image tag. Cloudflare also notes that idle connections dropped by a firewall’s UDP timers cause similar symptoms; testing with --protocol http2 is the documented way to isolate that.
Four mistakes that cost people an afternoon
- Editing config.yml on a token-managed tunnel. The ingress block is ignored, nothing errors, and you conclude the tunnel is broken. Check which kind you created before editing anything.
- Putting the catch-all first. Every request gets a 404 and every real rule below it is dead. The catch-all is last, always.
- Committing the credentials JSON. It is the tunnel’s key material. Anyone with it can serve traffic on your hostname. Delete the tunnel and recreate it if it ever lands in a repository.
- Leaving a staging tunnel publicly reachable. A tunnel with no access policy is a public website with a fresh, indexable hostname. Add a policy, or at minimum keep it out of the index deliberately rather than by accident.
Verdict: when a tunnel is the right answer
Use a Cloudflare Tunnel when you need a stable public hostname on a domain you control, pointing at a machine you would rather not expose. That covers client staging sites, a home server, an app on a NAS, a development box behind CGNAT, and any origin whose IP you want out of DNS permanently. It is free, it is the least work of anything in the comparison table, and the outbound-only model is genuinely better security than a firewall rule you have to maintain.
Do not use one if you need control at the proxy layer. Custom buffering, request rewriting, non-HTTP protocols on arbitrary ports, per-route rate limiting in your own config: a reverse proxy you run yourself does all of that and a tunnel does not. Nor should you use one purely for private access. If the only people who need the service are your own team, a real VPN or a private tailnet is a better shape than publishing a hostname and then defending it. Publishing and then restricting is more moving parts than never publishing.
And do not treat it as a security product on its own. It closes ports. It does not patch your plugins, it does not authenticate anyone, and it does not stop a bad request that Cloudflare happily forwards. Pair it with access policies if the thing behind it is not meant to be public, keep the origin patched, and remember that the moment the hostname is proxied, Cloudflare’s cache is in the path whether you configured it or not.
Frequently asked questions
Does a Cloudflare Tunnel protect against a vulnerable WordPress plugin?
No. A tunnel is a pipe, not a filter: it closes inbound ports and hides your origin IP, but anything Cloudflare routes down that pipe still reaches your application exactly as before. A vulnerable plugin stays vulnerable, a weak admin password stays weak, and an unauthenticated endpoint stays unauthenticated. Authentication in front of the app is Access policy work, not something the tunnel itself provides.
Can I switch a dashboard-managed tunnel to use a local config.yml file?
Not by simply editing the file. A tunnel created with a token ignores a local config.yml ingress block entirely, and nothing errors to tell you that, so the ingress rules just never apply. The two management modes are not interchangeable partway through; decide dashboard-managed or locally-managed before you start, and check which kind you created before editing any config.
What happens if the catch-all rule is not last in the ingress list?
cloudflared refuses to start without a catch-all rule with no hostname and no path as the final entry, and if that catch-all is placed first instead, every request matches it and returns a 404 before any real rule below it is ever evaluated. The catch-all, usually http_status:404, always belongs at the very bottom of the ingress list.
Why does my WordPress admin lose its styling after moving behind a tunnel?
Cloudflare terminates TLS at the edge, but cloudflared forwards the request to the local service over plain HTTP, so PHP never sees HTTPS. WordPress then builds canonical URLs, script tags and stylesheet links with http, and the browser refuses to load them on an HTTPS page. Fix it with the X-Forwarded-Proto check and the WP_HOME and WP_SITEURL constants in wp-config.php.
Does Tailscale Funnel work as a Cloudflare Tunnel alternative for a client site?
Only if the client does not need a custom domain. Tailscale Funnel cannot serve a hostname like staging.theirbrand.com, it only offers ts.net names, which rules it out the moment a client needs to see their own domain. A Cloudflare Tunnel supports any domain already on your Cloudflare account, which is why it fits client staging sites better.
Why would a staging site behind a Cloudflare Tunnel show an old, already-fixed page?
A tunnelled hostname is a proxied DNS record, so every normal Cloudflare caching feature applies to it even though you never touched the orange cloud yourself. Cloudflare can serve a cached version from before your fix. Set a Cache Rule that bypasses cache for the entire staging hostname, since Development Mode only lasts three hours and is not a real fix for a site that lives for weeks.
What does a 502 error mean when the tunnel itself shows as healthy?
It means cloudflared reached Cloudflare successfully but could not reach your local service. Test from the origin with curl against the local address named in the ingress rule. Usual causes are the service listening on a different port than the ingress rule specifies, the service bound only to a container network rather than localhost, or an https rule pointed at an origin that only speaks plain HTTP.
Is it safe to leave noTLSVerify enabled if the connector and the service run on different hosts?
Not ideally. noTLSVerify is acceptable when the connector and the service share the same host, but when they are on different machines it removes real certificate verification entirely. Use originServerName instead when the origin’s certificate is valid but issued for a different name, or caPool to point at your internal CA, so verification still happens rather than being switched off outright.