guide · computers

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.

July 10, 2026 · By Alastair Fraser

A laptop sits behind a Cloudflare Tunnel, exposing a local HTTP server to the public internet without opening any inbound ports.

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:

  1. A persistent outbound connection from cloudflared on 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.
  2. 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”.

LayerWhat it doesDo this when
cloudflared binaryMakes the outbound connection to Cloudflare edgeFirst
Tunnel identityA UUID + per-tunnel credential, scoped to your accountFirst
DNS routeTells Cloudflare edge which hostname goes to which tunnelFirst
Ingress configMaps hostnames to local services and ports on the same daemonAfter DNS works
Service managementRuns cloudflared as a long-lived processAfter 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.

CommandPlain-English jobWhat success looks likeIf it fails
cloudflared --versionPrints the local cloudflared build. Confirms the binary is on PATH.cloudflared version YYYY.M.D (...).Reopen the terminal, reinstall, or fix PATH.
cloudflared tunnel loginOpens 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 listLists 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:

  1. A domain on Cloudflare’s DNS. Free tier is fine. The nameservers must be pointing at Cloudflare (dig NS example.com shows Cloudflare nameservers).
  2. A static HTTP origin running locally. Even python3 -m http.server 8080 is enough to prove the tunnel works. The origin must bind to 127.0.0.1 (or 0.0.0.0 if it must listen on all interfaces — but loopback is safer).
  3. A terminal you can restart. Several install paths require a fresh shell so the cloudflared binary is on PATH.
  4. 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>/ returns HTTP/2 200 from 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.

PlatformInstall pathPersistent service mechanism
macOSbrew install cloudflaredbrew services start cloudflared (launchd)
Debian/Ubuntu (incl. WSL2)Add the Cloudflare APT repo, then apt install cloudflaredsystemd user unit, requires systemd=true in /etc/wsl.conf for WSL2
Fedora/RHELAdd the yum repo from pkg.cloudflare.com, then dnf install cloudflaredsystemd user unit
Docker (any host)cloudflare/cloudflared:latest imageContainer restart policy (restart: unless-stopped); no init needed
Native Windowschoco install cloudflaredTask Scheduler or NSSM-wrapped service
TermuxManual binary download from the GitHub releases pagetmux 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:404 is 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:

PlatformPersistent run command
macOSbrew services start cloudflared (after writing the config)
Linux (systemd)systemctl --user enable --now cloudflared (user unit) or /etc/systemd/system/cloudflared.service (system unit)
WSL2Same as Linux, but enable systemd=true in /etc/wsl.conf first, then wsl --shutdown from PowerShell once.
Dockerdocker run -d --restart=unless-stopped -v /etc/cloudflared:/etc/cloudflared cloudflare/cloudflared:latest tunnel --no-autoupdate run my-site
Native WindowsTask 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.

#CommandWhat it proves
1cloudflared --versionThe binary is installed and on PATH.
2ls -l ~/.cloudflared/cert.pemThis machine is logged in to Cloudflare.
3cloudflared tunnel listThe named tunnel exists in this account.
4dig CNAME <hostname>The DNS route is published at the edge.
5cloudflared tunnel info <NAME>At least one connector is currently running.
6curl -sS -I http://127.0.0.1:<port>/Your local origin is responding on loopback.
7curl -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

SymptomLikely layerFirst command to runFix
cloudflared: command not foundInstall / PATHwhich cloudflaredReopen terminal, reinstall, fix PATH.
tunnel login opens no browserHeadless hostecho $DISPLAY on LinuxCopy the printed URL into a laptop browser, then re-run.
tunnel create returns “no certificate”Loginls -l ~/.cloudflared/cert.pemRe-run cloudflared tunnel login.
tunnel route dns returns “zone not found”Account/zone mismatchcloudflared tunnel listThe zone must be on the same Cloudflare account as cert.pem.
DNS does not resolve after route dnsPropagationdig CNAME <hostname>Allow up to 5 minutes for cache layers; check the CNAME at the Cloudflare dashboard.
tunnel run exits immediatelyConfig/credentialscloudflared 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 1033Ingress hostname mismatchcloudflared tunnel info my-siteThe 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 504Local origin down or unreachablecurl -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 connectorsAuth or accountcloudflared tunnel info my-siteRe-check the credential JSON matches the tunnel UUID.
Tunnel works locally, fails after rebootService managementsystemctl --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 running cloudflared tunnel route dns for 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-freq or --no-autoupdate if you want to control when the binary changes.
  • Run multiple replicas of the same tunnel on different hosts for high availability; cloudflared tunnel info will 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.pem and 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.pem and <UUID>.json as secrets. cert.pem can create new tunnels in your account; <UUID>.json can run an existing one. Both belong in a secret store, not in a public repo.
  • Bind the origin to 127.0.0.1, not 0.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 info with 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 by cloudflared tunnel create <NAME> and re-running cloudflared tunnel route dns for each hostname. The leaked credential cannot be undone, but the rotation is cheap.

Updates and breaking changes

cloudflared updates often. After any upgrade:

  1. Re-check cloudflared --version to confirm the new binary is in use.
  2. Re-run the verification checklist above.
  3. Read the GitHub releases page for flag renames. --autoupdate-freq and --no-autoupdate are stable, but new flags appear and old ones get renamed periodically.
  4. 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:

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

Source repo and package checks

See also

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.

Sources

#cloudflare#tunnel#static-site#devops#setup#homelab

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.

Your email address will not be published. Required fields are marked.