How to Expose a Local Service With Cloudflare Tunnel (2026)

Nov 10, 2026 · 12 min · Ajay Kumar

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.

How a request reaches your local service through Cloudflare Tunnel Visitor browser requests app.example.com Cloudflare edge TLS terminates here Access policy: PIN or IdP WAF, DDoS, caching no healthy connector: 1033 Tunnel opened outbound by host port 7844, QUIC or HTTP/2 4 connections, 2 sites cloudflared + app Service URL, for example http://localhost:8080 validates Access JWT cannot reach app: 502 The host only dials out, so the router needs no port forward and the origin IP never appears in DNS. The public hostname is a proxied CNAME to UUID.cfargotunnel.com; a stopped tunnel shows error 1033. Create the Access application before the route, or the hostname is public for the gap in between.
Request path as described in Cloudflare's Tunnel overview, firewall and troubleshooting documentation (November 2026).

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:

  1. In the Cloudflare dashboard, go to Networking > Tunnels.
  2. Select Create Tunnel, enter a name such as homelab-web, and select Create Tunnel.
  3. Under Setup Environment, pick your OS and architecture. The page shows an install command containing a long token that starts with eyJ.
  4. 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.

  1. Go to Zero Trust > Access controls > Applications and select Create new application.
  2. Choose Self-hosted and private, then Add public hostname, and enter app with your domain.
  3. 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.
  4. Choose your identity providers (one-time PIN works without any IdP) and a Session Duration, then select Create.
  5. 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:

  1. Go to Networking > Tunnels and select your tunnel.
  2. Under Routes, select Add route, then Published application.
  3. Under Hostname, enter app and pick your domain.
  4. For Service URL, enter the address as seen from cloudflared: http://localhost:8080 on the same host, http://app:8080 in Docker Compose, or http://web.default.svc.cluster.local:80 in Kubernetes.
  5. 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