How to Expose a Local Service With Cloudflare Tunnel (2026)
This guide is for anyone who wants a service on a laptop, a homelab box or a cluster reachable at a real HTTPS hostname without opening a port on the router. By the end you will have a remotely managed Cloudflare Tunnel publishing app.example.com, running as a service (systemd, Docker Compose or Kubernetes), with a Cloudflare Access policy in front so only people you name can reach it. Commands match cloudflared 2026.10.7, released on 30 October 2026, and the Cloudflare dashboard as documented in November 2026. You need a Cloudflare account, a domain whose DNS is on Cloudflare, and a local HTTP service; if you are still choosing between Cloudflare, ngrok, Tailscale Funnel and frp, read the comparison of Cloudflare Tunnel vs ngrok vs Tailscale Funnel first.
Step 1: Install cloudflared
On macOS, Homebrew carries the current release. On Debian and Ubuntu, use Cloudflare's package repository so apt upgrade keeps you inside the support window, which Cloudflare defines as versions within one year of the latest release.
# macOS
brew install cloudflared
# Debian / Ubuntu (stable channel, any Debian-based distro)
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 any main' \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared
# Check the version
cloudflared --version
# expected: cloudflared version 2026.10.7 (built 2026-10-30T...)
If you see an older version on macOS, run brew upgrade cloudflared. Older builds such as 2026.6.x are missing flags used below, including --allowed-mail.
Step 2: Try a Quick Tunnel (optional, development only)
Before touching DNS, check that your app works through Cloudflare with a Quick Tunnel. It needs no account and prints a random trycloudflare.com URL. Since 2 October 2026 you can require an emailed one-time PIN with --allowed-mail.
# Start your app first, e.g. python3 -m http.server 8080
cloudflared tunnel --url http://localhost:8080
# Restrict to named people or a whole domain (quote wildcards)
cloudflared tunnel --url http://localhost:8080 \
--allowed-mail alice@example.com \
--allowed-mail '*@example.com'
Quick Tunnels cap each tunnel at 200 in-flight requests (extra requests get HTTP 429), do not support Server-Sent Events, have no uptime guarantee and change hostname on every run. Use them for a demo, not for webhooks you have registered with a third party.
Step 3: Create a remotely managed tunnel
A remotely managed tunnel keeps its routing in Cloudflare, so the host only needs a token. Following Cloudflare's get started page:
- In the Cloudflare dashboard, go to Networking > Tunnels.
- Select Create Tunnel, enter a name such as
homelab-web, and select Create Tunnel. - Under Setup Environment, pick your OS and architecture. The page shows an install command containing a long token that starts with
eyJ. - Copy the token somewhere safe and do not run the command yet if you plan to use Docker or Kubernetes.
The same thing through the API, useful in Terraform-free automation, needs an API token with Cloudflare Tunnel edit and DNS edit permissions:
# config_src "cloudflare" = remotely managed. Response contains "id" and "token".
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel" \
--request POST \
--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
--json '{"name": "homelab-web", "config_src": "cloudflare"}'
Treat the tunnel token like a password. Anyone holding it can run a connector for your tunnel and receive its traffic.
Step 4: Create the Access application before the route
Cloudflare's self-hosted application guide recommends creating the Access application first, because a published app without one is reachable by anyone. The Zero Trust free plan covers up to 50 users.
- Go to Zero Trust > Access controls > Applications and select Create new application.
- Choose Self-hosted and private, then Add public hostname, and enter
appwith your domain. - Under Access policies, create an Allow policy, for example an Emails rule with your address, or Emails ending in
@example.com. Access denies by default, so nobody else gets in. - Choose your identity providers (one-time PIN works without any IdP) and a Session Duration, then select Create.
- Copy the application's Application Audience (AUD) Tag; you need it in Step 8.
Step 5: Publish the application (public hostname route)
Now map the hostname to your local service, as described in Publish an application:
- Go to Networking > Tunnels and select your tunnel.
- Under Routes, select Add route, then Published application.
- Under Hostname, enter
appand pick your domain. - For Service URL, enter the address as seen from cloudflared:
http://localhost:8080on the same host,http://app:8080in Docker Compose, orhttp://web.default.svc.cluster.local:80in Kubernetes. - Select Add route.
Cloudflare creates a proxied CNAME from app.example.com to <UUID>.cfargotunnel.com. If the dashboard says a record already exists, delete the old A or CNAME record for that name first.
Step 6: Run cloudflared as a systemd service
On a Linux host, the token goes straight into the service installer, which creates cloudflared.service:
sudo cloudflared service install eyJhIjoi...your-token...
sudo systemctl status cloudflared --no-pager
# expected: Active: active (running)
journalctl -u cloudflared -n 20 --no-pager
# expected (illustrative): INF Registered tunnel connection connIndex=0 ... protocol=quic
# (four of these, connIndex 0 to 3, is a healthy tunnel)
To add run flags such as --protocol http2 for networks that block UDP, edit the unit with sudo systemctl edit --full cloudflared.service and append the flag to the cloudflared tunnel run line, then sudo systemctl restart cloudflared. Run parameters are listed on the run parameters page. On macOS, sudo cloudflared service install <TOKEN> installs a launch daemon instead.
The host needs outbound port 7844 (UDP for QUIC, TCP for HTTP/2) to region1.v2.argotunnel.com and region2.v2.argotunnel.com, per the firewall page. Nothing inbound.
Step 7: Run it in Docker Compose or Kubernetes instead
In containers, pin the image tag rather than using latest, and pass the token through an environment variable read from a file you do not commit. TUNNEL_TOKEN is the documented variable for --token.
# compose.yaml (docker compose v2). Put TUNNEL_TOKEN=eyJ... in .env and gitignore it.
services:
app:
image: nginx:1.29-alpine # stand-in for your service, listens on 80
cloudflared:
image: cloudflare/cloudflared:2026.10.7
command: tunnel --no-autoupdate --metrics 0.0.0.0:2000 run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
restart: unless-stopped
depends_on:
- app
# In the dashboard, the Service URL must be http://app:80, not http://localhost:80:
# inside the cloudflared container, localhost is the cloudflared container itself.
For Kubernetes, Cloudflare's Kubernetes guide runs cloudflared as its own Deployment with two replicas and a liveness probe on the /ready metrics endpoint, which returns 200 only while it has an active connection to Cloudflare. This is that manifest with the tag pinned and two security fields added (the image already runs as UID 65532):
# cloudflared.yaml
# Create the secret without writing the token to disk:
# kubectl create secret generic tunnel-token --from-literal=token='eyJ...'
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: default
spec:
replicas: 2 # for availability; do not autoscale cloudflared
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
securityContext:
runAsNonRoot: true
sysctls:
- name: net.ipv4.ping_group_range # lets ICMP reach private resources
value: "65532 65532"
containers:
- name: cloudflared
image: cloudflare/cloudflared:2026.10.7
args: ["tunnel", "--no-autoupdate", "--loglevel", "info",
"--metrics", "0.0.0.0:2000", "run"]
env:
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: tunnel-token
key: token
securityContext:
allowPrivilegeEscalation: false
livenessProbe:
httpGet:
path: /ready
port: 2000
failureThreshold: 1
initialDelaySeconds: 10
periodSeconds: 10
Cloudflare's guide explicitly advises against autoscaling cloudflared: removing a replica breaks the user connections it carries, and replicas exist for availability, not load balancing. If you keep the token in Git, encrypt it first, as in encrypting Kubernetes secrets with SOPS and age.
Step 8: Make cloudflared enforce Access too
The Access policy at the edge is the main control. The second one is the tunnel route's access origin setting, shown in the dashboard as Protect with Access: cloudflared validates the Access JWT before proxying, so a request that somehow skips the policy (a deleted Access app, a new hostname nobody protected) is refused at your host. Turn it on in the route's origin settings with your team name and the AUD tag from Step 4. For a locally managed tunnel, the same thing in config.yml looks like this:
# ~/.cloudflared/config.yml (locally managed tunnels only)
tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /home/ajay/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
ingress:
- hostname: app.example.com
service: http://localhost:8080
originRequest:
access:
required: true
teamName: your-team-name
audTag:
- your-application-aud-tag
- service: http_status:404 # catch-all rule is mandatory
cloudflared 2026.10.0 added URL normalization before ingress matching for rules that require Access, and Cloudflare's changelog says new releases turn it on by default from 25 January 2027. If you route by path, test with TUNNEL_DISABLE_PATH_NORMALIZATION=false before then.
Step 9: Verify from outside
Test from a phone on mobile data or any machine outside your network:
curl -sI https://app.example.com | head -5
# expected (illustrative): HTTP/2 302
# location: https://your-team-name.cloudflareaccess.com/cdn-cgi/access/login/app.example.com?...
# On the host: is the connector ready?
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:2000/ready # 200 when connected
A 302 to cloudflareaccess.com means the policy is in front. A 200 with your app's content from an unauthenticated curl means it is not, and you should fix that before sharing the URL.
Troubleshooting
Most of these come straight from Cloudflare's troubleshooting page and common errors page.
| Symptom | Likely cause | Fix |
|---|---|---|
| Error 1033 in the browser | No healthy cloudflared connected for this tunnel |
Check tunnel status in Networking > Tunnels; start the service; if Active, confirm the CNAME points at the right <UUID>.cfargotunnel.com |
| Error 1016 | Cloudflare cannot resolve the origin: the hostname's DNS record is missing or its CNAME target does not resolve | Re-add the published application route so the CNAME points at the current <UUID>.cfargotunnel.com |
502, log shows connect: connection refused |
App not running, or wrong port in Service URL | curl -v http://localhost:8080 on the host; ss -tlnp to see the real port |
| 502 only in Docker or Kubernetes | Service URL uses localhost, which is the cloudflared container |
Use the Compose service name or the cluster DNS name of the Service |
502, log shows malformed HTTP response |
http:// used for an HTTPS origin, or the reverse |
Match the scheme in the Service URL to the origin |
x509: certificate is valid for ..., not localhost |
Origin cert does not cover the service address | Set Origin Server Name (originServerName) or caPool; avoid noTLSVerify except briefly |
ERR_TOO_MANY_REDIRECTS |
Origin redirects HTTP to HTTPS while the route uses http:// |
Point the route at the HTTPS origin, or stop the origin redirect |
Failed to dial a quic connection then http2 fallback, or i/o timeout on 7844 |
Firewall blocks UDP or TCP 7844 outbound | Allow 7844 out; force --protocol http2 if only TCP is open |
edge discovery: error looking up Cloudflare edge IPs |
Host or container DNS cannot resolve the SRV record | dig SRV _v2-origintunneld._tcp.argotunnel.com @1.1.1.1; fix the resolver |
| Quick Tunnel returns 429, or SSE stream never arrives | Quick Tunnel limits: 200 in-flight requests, no SSE | Create a named tunnel (Steps 3 to 6) |
If a 502 is coming from somewhere other than the tunnel, the general 502 and 504 troubleshooting guide walks through proxies and load balancers.
FAQ
What is the difference between a Quick Tunnel and a named tunnel?
A Quick Tunnel needs no account and gives you a random trycloudflare.com URL that changes every run, limited to 200 in-flight requests with no SSE. A named, remotely managed tunnel uses your own domain, keeps its hostname, runs as a service and can sit behind Access policies.
How do I rotate a Cloudflare Tunnel token?
Select Rotate token on the tunnel, then reinstall each connector with the new token: sudo cloudflared service uninstall followed by sudo cloudflared service install <NEW_TOKEN>. Per Cloudflare's token docs, existing connectors keep running until restarted, so for a leaked token also force-disconnect existing connections through the API.
Can I run several services through one tunnel?
Yes. Add one published application route per hostname to the same tunnel; Cloudflare documents roughly 5,000 to 10,000 published applications per tunnel, limited by the 1 MB configuration size. One tunnel per host is the usual pattern.
Do I need to open any ports?
No inbound ports. The host needs outbound port 7844 to Cloudflare, over UDP for QUIC or TCP for HTTP/2, and optionally 443 for update checks and, if you use the access origin setting, Access JWT validation.
When something breaks in production, the first thing I check is the tunnel status in the dashboard, because it splits the problem in half: Healthy with four connections means look at the origin and the Service URL (that is a 502), anything else means look at the host, its DNS and port 7844 (that is a 1033). The second is an unauthenticated curl -I against the hostname, because the failure that hurts is not the outage, it is the internal tool that quietly became public. Official references: Cloudflare Tunnel docs, cloudflared releases and Cloudflare Access applications.
Related: Cloudflare Tunnel vs ngrok vs Tailscale Funnel in 2026, How to Set Up a k3s Cluster for a Homelab and How to Set Up Nginx as a Reverse Proxy With Let's Encrypt.
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Harden Ubuntu 24.04 With an Ansible Playbook (2026)
Step-by-step Ansible playbook to harden Ubuntu 24.04: pinned ansible-core 2.21.6, inventory, SSH drop-in that beats 50-cloud-init.conf, ufw, unattended-upgrades, sudo, fail2ban, auditd, sysctl, --check --diff, ansible-lint and fixes for common errors.
13 minNov 10, 2026How to Set Up a WireGuard VPN Server on Ubuntu 24.04 (2026)
Step-by-step WireGuard server setup on Ubuntu 24.04: generate keys, write wg0.conf, enable IP forwarding, NAT with nftables or ufw, client configs and QR codes, split vs full tunnel, wg-quick under systemd, and fixes for handshake did not complete.
12 minOct 31, 2026How to Deploy to Cloud Run with GitHub Actions: Step-by-Step (2026)
Deploy a container to Google Cloud Run from GitHub Actions in 2026 with no service account keys: Artifact Registry, Workload Identity Federation with auth@v3, deploy-cloudrun@v3, Secret Manager, tagged canary revisions, rollbacks, custom domains, and fixes for the PORT 8080 startup error.
15 min