Cloudflare Tunnel Setup for a Static Site Without Opening Any Ports
Setup guide for installing cloudflared, creating a named tunnel, routing a hostname to it, and verifying the public hostname reaches your local origin over the tunnel.
A Cloudflare Tunnel lets you serve a website from a laptop, a VPS, or a Docker container without ever opening an inbound port. Cloudflare’s edge handles the public DNS and TLS; a small daemon called cloudflared on your machine makes an outbound-only connection to Cloudflare and bridges incoming requests to a local HTTP server.
This guide gives you the layered path: install cloudflared, prove it runs, create one named tunnel, route one hostname to it, point it at a local service, and verify from the public internet. By the end you’ll know what command proves each layer works, and where to look when one of them doesn’t.
What a Cloudflare Tunnel actually is
The mental model that causes most setup failures is “I have to port-forward to a public IP.” You do not. A Cloudflare Tunnel is two things:
- A persistent outbound connection from
cloudflaredon your machine to Cloudflare’s edge. It uses QUIC/HTTP2 over 443, the same port every browser uses. No inbound ports on your host are required. - A DNS route in your Cloudflare zone that points a hostname to a specific tunnel. When a visitor resolves the hostname, Cloudflare’s edge proxies the request back over the outbound connection to the local service you named in the tunnel config.
Two practical consequences fall out of this model. First, your origin service only ever needs to listen on 127.0.0.1 (loopback), not on a public interface. Second, if the tunnel goes down for any reason, the public hostname stops resolving to your service — there is no degraded “host header pointing somewhere else” mode that would expose the wrong thing.
This guide assumes you already have a domain on Cloudflare’s DNS and a static HTTP server you want to expose (a built Astro site, a Hugo site, a plain python -m http.server, or a Node service). The tunnel is the missing piece that turns “it works on localhost” into “it works at https://example.com”.
| Layer | What it does | Do this when |
|---|---|---|
cloudflared binary | Makes the outbound connection to Cloudflare edge | First |
| Tunnel identity | A UUID + per-tunnel credential, scoped to your account | First |
| DNS route | Tells Cloudflare edge which hostname goes to which tunnel | First |
| Ingress config | Maps hostnames to local services and ports on the same daemon | After DNS works |
| Service management | Runs cloudflared as a long-lived process | After one hostname works |
That order matters. A broken DNS route can look like a tunnel outage. A missing ingress entry can look like a DNS outage. A killed daemon can look like either. Each layer has one cheap check.
Command map: what each command is doing
The guide uses a small set of commands. Read this table before copy-pasting.
| Command | Plain-English job | What success looks like | If it fails |
|---|---|---|---|
cloudflared --version | Prints the local cloudflared build. Confirms the binary is on PATH. | cloudflared version YYYY.M.D (...). | Reopen the terminal, reinstall, or fix PATH. |
cloudflared tunnel login | Opens a browser to bind this machine to your Cloudflare account. Writes cert.pem to ~/.cloudflared/. | Browser shows “Success” and a cert.pem file exists. | Re-run; make sure the headless host has a way to copy the URL to a browser (ssh -L works). |
cloudflared tunnel create <NAME> | Creates a named tunnel, returns a UUID, writes <UUID>.json credential to ~/.cloudflared/. | A new UUID is printed and a credential JSON file exists. | Confirm login succeeded and you own the zone. |
cloudflared tunnel route dns <NAME> <hostname> | Creates the DNS CNAME at Cloudflare edge pointing <hostname> to <UUID>.cfargotunnel.com. | dig CNAME <hostname> returns <UUID>.cfargotunnel.com. | Run cloudflared tunnel info <NAME>; the zone must be on the same Cloudflare account. |
cloudflared tunnel run <NAME> | Starts the tunnel connector process using the config and credentials. | Logs show “Connection established” and the process stays up. | Check config YAML, credential path, and Cloudflare account access. |
cloudflared tunnel info <NAME> | Reports connector health, version, and last-seen time. | At least one connector listed with a recent timestamp. | The connector crashed; check journalctl/container logs. |
cloudflared tunnel list | Lists all tunnels in this account, their types, and creation dates. | Shows the tunnel you created. | Usually a credential/auth issue; rerun cloudflared tunnel login. |
curl -sS -I https://<hostname>/ | Proves the public hostname reaches your local origin over the tunnel. | HTTP/2 200, server: cloudflare, and origin’s Content-Type. | DNS not routed yet, ingress not matching, or local service down. |
The pattern is: install → login → create → route → run → verify. Fix the failing row before moving to the next one.
Before you start
You need four things:
- A domain on Cloudflare’s DNS. Free tier is fine. The nameservers must be pointing at Cloudflare (
dig NS example.comshows Cloudflare nameservers). - A static HTTP origin running locally. Even
python3 -m http.server 8080is enough to prove the tunnel works. The origin must bind to127.0.0.1(or0.0.0.0if it must listen on all interfaces — but loopback is safer). - A terminal you can restart. Several install paths require a fresh shell so the
cloudflaredbinary is on PATH. - A small definition of success. For the first run, success is not “the whole site is in production.” Success is:
curl -sS -I https://<hostname>/returnsHTTP/2 200from your local server over the tunnel.
If you don’t yet have a domain on Cloudflare, you can still install cloudflared and run cloudflared --version to prove the binary works. The tunnel creation step requires the domain.
Install cloudflared
Pick the install path that matches your platform family.
| Platform | Install path | Persistent service mechanism |
|---|---|---|
| macOS | brew install cloudflared | brew services start cloudflared (launchd) |
| Debian/Ubuntu (incl. WSL2) | Add the Cloudflare APT repo, then apt install cloudflared | systemd user unit, requires systemd=true in /etc/wsl.conf for WSL2 |
| Fedora/RHEL | Add the yum repo from pkg.cloudflare.com, then dnf install cloudflared | systemd user unit |
| Docker (any host) | cloudflare/cloudflared:latest image | Container restart policy (restart: unless-stopped); no init needed |
| Native Windows | choco install cloudflared | Task Scheduler or NSSM-wrapped service |
| Termux | Manual binary download from the GitHub releases page | tmux foreground or termux-services |
For the canonical Debian/Ubuntu path, follow the APT-repo instructions on pkg.cloudflare.com. Do not download the .deb directly and dpkg -i it on a fresh host — you’ll miss dependencies and miss auto-updates.
After install, prove the binary works:
cloudflared --version
If the command is not found, open a new terminal or hash -r and try again. Do not proceed until this works.
Log in to Cloudflare
Bind this machine to your Cloudflare account. This step opens a browser window and writes a cert.pem to ~/.cloudflared/ (or %USERPROFILE%\.cloudflared\ on Windows).
cloudflared tunnel login
On a headless server, copy the printed URL into a laptop browser, pick the zone you want to expose, and approve. The cert.pem file is what proves the local machine is allowed to manage DNS records in your account. It is a real secret: treat it like an API token.
After this command succeeds, you should see:
You have successfully logged in.
You can now create a tunnel from this machine.
Verify the file exists:
ls -l ~/.cloudflared/cert.pem
If you skipped this step on a headless box, every later command fails with a confusing “no certificate” error. Do not skip it.
Create a named tunnel
A named tunnel is a persistent identity in your Cloudflare account. It has a UUID and a per-tunnel credential. The credential is what cloudflared uses to authenticate as that specific tunnel.
cloudflared tunnel create my-site
Output looks like:
Tunnel credentials written to /home/you/.cloudflared/<UUID>.json
Created tunnel my-site with id c6f7...-...
Save the UUID. It is the value you’ll use in the config file.
Route a hostname to the tunnel
This is the DNS step. It tells Cloudflare’s edge “when someone asks for blog.example.com, send the request to tunnel <UUID>.”
cloudflared tunnel route dns my-site blog.example.com
This creates a CNAME record in your Cloudflare zone automatically. Verify it:
dig CNAME blog.example.com
You should see blog.example.com. 1 IN CNAME <UUID>.cfargotunnel.com..
DNS propagation usually takes under a minute when Cloudflare manages the zone, but allow up to a few minutes for cache layers outside Cloudflare. If the CNAME is missing after a few minutes, the tunnel was not yet created or the zone is on a different account.
Configure ingress
Write a config file at ~/.cloudflared/config.yml (or /etc/cloudflared/config.yml for system-wide):
tunnel: <UUID>
credentials-file: /home/you/.cloudflared/<UUID>.json
ingress:
- hostname: blog.example.com
service: http://127.0.0.1:8080
- service: http_status:404
The two rules matter:
- The first rule matches the hostname you routed and forwards traffic to a local HTTP service on port 8080.
- The last rule is a catch-all
http_status:404. Cloudflare requires a default service;http_status:404is the safe default for unknown hostnames.
If you want a single command to run everything, that’s not what cloudflared does — cloudflared only proxies traffic. Start your static server separately (Astro dev, nginx, python -m http.server, etc.) and point the tunnel at it. If your local origin uses HTTPS with a self-signed certificate, add noTLSVerify: true to that ingress rule.
For tunnels that should not be public, cloudflared alone does not provide auth. Combine the tunnel with Cloudflare Access (email-OTP, SSO, or device posture) on the same hostname to gate internal tools. The most common reason to set up a tunnel for a non-static use case — Home Assistant, Grafana, a dev server — is to expose an internal tool behind auth, not to make it publicly crawlable.
Run the tunnel
Foreground (for testing):
cloudflared tunnel run my-site
Background, managed by the platform:
| Platform | Persistent run command |
|---|---|
| macOS | brew services start cloudflared (after writing the config) |
Linux (systemd) | systemctl --user enable --now cloudflared (user unit) or /etc/systemd/system/cloudflared.service (system unit) |
| WSL2 | Same as Linux, but enable systemd=true in /etc/wsl.conf first, then wsl --shutdown from PowerShell once. |
| Docker | docker run -d --restart=unless-stopped -v /etc/cloudflared:/etc/cloudflared cloudflare/cloudflared:latest tunnel --no-autoupdate run my-site |
| Native Windows | Task Scheduler task running cloudflared tunnel run my-site at logon |
For Docker (Hostinger, etc.), the right pattern is no init, supervisor via restart policy. Cloudflare publishes a community-maintained Dockerfile for cloudflared, but the official image already runs as a non-root user and respects --no-autoupdate so it does not crash-loop.
The cloudflared Docker image requires config.yml and the per-tunnel credential JSON to be mounted at /etc/cloudflared/ inside the container. A common failure is creating /etc/cloudflared/ on the host, mounting the volume, and forgetting to put both files there — the daemon then exits with a generic “tunnel failed to start” error. Confirm both files are in the host directory before starting the container.
For WSL2, the right pattern is to treat it as Linux. If systemctl --user status cloudflared returns “Failed to connect to bus”, that’s the systemd-in-WSL2 trap — fix /etc/wsl.conf first, then restart WSL.
For native Windows, default to WSL2 instead of native Windows for any tunneling workload. WSL2’s network stack, filesystem behavior, and process model match the docs and the Linux examples. Native Windows is the right answer only if you have a specific Windows-only reason (PowerShell automation tied to the rest of a Windows-only stack, or a tool that won’t run under WSL2).
If you must run native Windows, schedule the Task Scheduler task under the same Windows user account that ran cloudflared tunnel login. A common failure mode is the task running as a service account whose ~/.cloudflared/ resolves to a different path than the user’s, so the credential file cannot be found.
Verify the tunnel is healthy:
cloudflared tunnel info my-site
You should see at least one connector listed with a recent created_at timestamp. If the list is empty, cloudflared tunnel run did not actually start.
Verify from the public internet
curl -sS -I https://blog.example.com/
Success looks like:
HTTP/2 200
server: cloudflare
content-type: text/html
The server: cloudflare header proves the request went through Cloudflare’s edge. The content-type should match your origin’s MIME configuration — text/html for an Astro/Hugo homepage, text/plain for python -m http.server, application/octet-stream if your origin is misconfigured. If the content-type is wrong, the problem is your origin server, not the tunnel.
If curl returns nothing or a connection error, the DNS is not routed yet, the tunnel is not running, or the local service is down. Walk the verification checklist below in order.
Verification checklist
Run these in order. Each step proves one layer.
| # | Command | What it proves |
|---|---|---|
| 1 | cloudflared --version | The binary is installed and on PATH. |
| 2 | ls -l ~/.cloudflared/cert.pem | This machine is logged in to Cloudflare. |
| 3 | cloudflared tunnel list | The named tunnel exists in this account. |
| 4 | dig CNAME <hostname> | The DNS route is published at the edge. |
| 5 | cloudflared tunnel info <NAME> | At least one connector is currently running. |
| 6 | curl -sS -I http://127.0.0.1:<port>/ | Your local origin is responding on loopback. |
| 7 | curl -sS -I https://<hostname>/ | The public hostname reaches your local origin over the tunnel. |
If a row fails, fix that row before moving down. Most tunnel debugging is a row that somebody skipped.
Common failures and fixes
| Symptom | Likely layer | First command to run | Fix |
|---|---|---|---|
cloudflared: command not found | Install / PATH | which cloudflared | Reopen terminal, reinstall, fix PATH. |
tunnel login opens no browser | Headless host | echo $DISPLAY on Linux | Copy the printed URL into a laptop browser, then re-run. |
tunnel create returns “no certificate” | Login | ls -l ~/.cloudflared/cert.pem | Re-run cloudflared tunnel login. |
tunnel route dns returns “zone not found” | Account/zone mismatch | cloudflared tunnel list | The zone must be on the same Cloudflare account as cert.pem. |
DNS does not resolve after route dns | Propagation | dig CNAME <hostname> | Allow up to 5 minutes for cache layers; check the CNAME at the Cloudflare dashboard. |
tunnel run exits immediately | Config/credentials | cloudflared tunnel run my-site (foreground) | Check ~/.cloudflared/config.yml, the tunnel: UUID, the credentials-file path, and that the credential JSON is valid. |
curl https://<hostname>/ returns 1033 | Ingress hostname mismatch | cloudflared tunnel info my-site | The hostname in config.yml must be byte-identical to the one in cloudflared tunnel route dns — case, subdomain, and trailing dot all matter. |
curl https://<hostname>/ returns 502 or 504 | Local origin down or unreachable | curl -sS -I http://127.0.0.1:<port>/ | Start the local service; check the service: URL points at a listening port. |
cloudflared running but no connectors | Auth or account | cloudflared tunnel info my-site | Re-check the credential JSON matches the tunnel UUID. |
| Tunnel works locally, fails after reboot | Service management | systemctl --user status cloudflared (Linux) | Enable the user/system service so it auto-starts. |
How to improve the tunnel over time
Once the first hostname works, scale slowly:
- Add more hostnames by appending to
ingress:and runningcloudflared tunnel route dnsfor each. One tunnel can carry many hostnames. - Use Access if you want to gate internal tools behind email-OTP or SSO. This is a Cloudflare-side setting, not a tunnel change, but the tunnel is what makes Access work for non-public origins.
- Pin the autoupdate cadence with
--autoupdate-freqor--no-autoupdateif you want to control when the binary changes. - Run multiple replicas of the same tunnel on different hosts for high availability;
cloudflared tunnel infowill show multiple connectors. - Move from a per-user
~/.cloudflared/to/etc/cloudflared/when you switch from “running on a laptop” to “running on a VPS”. The file format does not change. - Keep the
cert.pemand per-tunnel credential files out of git. They are real secrets. The config YAML itself (without credentials) is safe to commit.
Security and safety basics
A tunnel is a privileged piece of plumbing. A few non-negotiables:
- Treat
cert.pemand<UUID>.jsonas secrets.cert.pemcan create new tunnels in your account;<UUID>.jsoncan run an existing one. Both belong in a secret store, not in a public repo. - Bind the origin to
127.0.0.1, not0.0.0.0. The tunnel does not need public exposure of the local service. Loopback is the safe default. - Use a catch-all
http_status:404, not a permissive default service. If an attacker probes an unrouted hostname, they get a 404, not a route to one of your services. - Audit ingress rules regularly. Every line in
ingress:is a public path into a local service. If you stop using a hostname, remove the rule and the DNS route. - Watch the connector health.
cloudflared tunnel infowith an empty connector list is your canary. Set a dashboard alert or a cron check on it. - Rotate if leaked. If a credential file leaks, the fastest path is the CLI:
cloudflared tunnel delete <NAME>followed bycloudflared tunnel create <NAME>and re-runningcloudflared tunnel route dnsfor each hostname. The leaked credential cannot be undone, but the rotation is cheap.
Updates and breaking changes
cloudflared updates often. After any upgrade:
- Re-check
cloudflared --versionto confirm the new binary is in use. - Re-run the verification checklist above.
- Read the GitHub releases page for flag renames.
--autoupdate-freqand--no-autoupdateare stable, but new flags appear and old ones get renamed periodically. - If you pinned
--no-autoupdate, schedule a manual upgrade at least monthly.
If a config that worked yesterday returns 503 after an upgrade, the most common cause is a renamed or removed ingress option. Re-read your config.yml against the upgrade notes.
Where to get unstuck beyond the docs
If this guide stops being enough:
- GitHub issues: cloudflare/cloudflared issues — search before opening a new one; the project has been around long enough that most common errors already have a thread.
- Cloudflare community: community.cloudflare.com under the “Tunnels” tag.
- Troubleshooting docs: developers.cloudflare.com/…/troubleshoot-tunnels/.
When filing a bug report, include: cloudflared --version, OS, the exact command that failed, the full output of cloudflared tunnel info <NAME>, the relevant section of config.yml (with secrets redacted), and the curl -I output against both the local origin and the public hostname. That combination gets a useful answer on the first reply.
A note on cost
Cloudflare Tunnels on the free plan cover most static sites, blogs, and small apps. Tunnels themselves do not have a hard request cap; the limits that bite are edge bandwidth and any Zero Trust / Access features you turn on. The billable items are bandwidth through Cloudflare’s edge (free tier covers most blogs) and advanced Zero Trust features (Access policies, device posture, etc.). For a personal blog or a single static site, you should not see a bill. If you start hitting egress limits, the cost usually traces back to embedded video, large file downloads, or scraping traffic — not the tunnel itself.
Sources used for this guide
Official Cloudflare docs
- Cloudflare Tunnel — Get started — high-level overview and architecture.
- Cloudflare Tunnel — Install and setup — install paths and prerequisites.
- Cloudflare Tunnel — Installation — per-platform install commands.
- Cloudflare Tunnel — Local management — config file, credentials, and DNS routing.
- Cloudflare Tunnel — Troubleshoot — diagnostic commands and known errors.
Source repo and package checks
- cloudflared GitHub repo — source of truth, releases, and changelog.
- pkg.cloudflare.com — APT/YUM repo for Debian/Ubuntu/RHEL/Fedora.
- cloudflared Chocolatey package — Windows install path.
See also
- Source notes for this guide — claim audit, source list, and reviewer-driven revisions.
Accuracy note
This guide was written by Hermes (MiniMax M3) using the abs-guides-cron-pipeline skill. Claims were checked against the Cloudflare developer docs, the cloudflared GitHub repo, the official package repos, and live --help output from cloudflared version 2026.5.2 on July 10, 2026. The full source-notes audit and reviewer-driven revision log is at the Source notes page.
Submit a take
Have a different read on this? Drop a comment below — your email isn't published, and I read every one. Nothing leaves the site until I approve it.