Cloudflare Tunnel + Zero Trust — Expose Internal Apps to the Internet Securely and for Free

Posted on: 4/18/2026 3:11:25 AM

Building a web app on your personal machine and want a teammate to test it without deploying to a server? Or running an internal service on your corporate network that an external partner needs to reach securely — but you don't want to open firewall ports? Cloudflare Tunnel combined with Zero Trust Access solves exactly this problem — for free, with no static IP, no open ports, and enterprise-grade security baked in.

330+ Global Cloudflare data centers
$0 Tunnel cost — unlimited bandwidth
50 users Zero Trust free tier
QUIC Default protocol since 2025

1. The Problem: Exposing Internal Services to the Internet

The traditional way to expose an internal app (localhost, private network) on the Internet usually requires: buying a static IP, configuring port forwarding on the router, opening firewall rules, and setting up TLS certificates. Every open port is a potential attack surface — especially risky when exposing sensitive services like admin panels, database UIs, or internal APIs.

graph LR
    subgraph Traditional["Traditional approach ⚠️"]
        direction LR
        A["Internet"] -->|"Port 443 open"| B["Router/Firewall"]
        B -->|"Port Forward"| C["Internal server"]
    end
    subgraph Tunnel["Cloudflare Tunnel ✅"]
        direction LR
        D["Internet"] -->|"HTTPS"| E["Cloudflare Edge"]
        F["cloudflared"] -->|"Outbound QUIC"| E
        F --- G["Internal server"]
    end
    style Traditional fill:#fff5f5,stroke:#e94560
    style Tunnel fill:#f0fff0,stroke:#4CAF50
    style A fill:#e94560,stroke:#fff,color:#fff
    style D fill:#4CAF50,stroke:#fff,color:#fff
    style E fill:#2c3e50,stroke:#fff,color:#fff
    style B fill:#ff9800,stroke:#fff,color:#fff
    style C fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style F fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style G fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50

Traditional (open port) vs Cloudflare Tunnel (outbound-only)

With the traditional model, every exposed service faces DDoS, brute force, port scanning, and countless security risks. Cloudflare Tunnel flips the model completely: instead of letting traffic in, you create an outbound connection from the server to the Cloudflare Edge — no ports are opened on your firewall.

2. Cloudflare Tunnel — Architecture and How It Works

Cloudflare Tunnel is built around the cloudflared daemon — a lightweight program running on the same network as your internal service. The daemon establishes outbound-only connections to Cloudflare's global network, forming a secure "tunnel".

sequenceDiagram
    participant User as 👤 User (Internet)
    participant CF as ☁️ Cloudflare Edge
    participant CD as 🔧 cloudflared
    participant App as 🖥️ Internal app

    Note over CD,CF: Step 1 — cloudflared initializes the tunnel
    CD->>CF: Outbound connection via QUIC (HTTP/3)
    CF-->>CD: Authenticate tunnel token
    CD->>CF: Open multiple connections to different edge servers

    Note over User,CF: Step 2 — user request arrives
    User->>CF: HTTPS request to app.example.com
    CF->>CF: DNS resolve → CNAME to tunnel UUID
    CF->>CF: Apply WAF + DDoS Protection
    CF->>CD: Forward request over QUIC tunnel
    CD->>App: Proxy to localhost:3000
    App-->>CD: Response
    CD-->>CF: Return via tunnel
    CF-->>User: HTTPS response (automatic TLS)

Cloudflare Tunnel flow — from initialization to request handling

2.1. Outbound-only: No port opening required

The key architectural idea: cloudflared only establishes outbound connections. Your firewall allows outbound traffic by default, so no extra configuration is needed. All inbound traffic from the Internet passes through the Cloudflare Edge first — filtered by the WAF and DDoS protection — before being forwarded over the tunnel to your internal server.

2.2. QUIC — The default protocol

Since 2025, cloudflared uses QUIC (HTTP/3) as its default protocol, replacing the previous HTTP/2 over TCP. QUIC brings several advantages:

CharacteristicHTTP/2 over TCP (old)QUIC (new)
Head-of-Line BlockingAffects all streams on the same connectionStreams are independent — no cross-impact
Connection setupTCP handshake + TLS handshake (2-3 RTT)0-RTT or 1-RTT (TLS 1.3 built in)
Reconnecting after network lossMust re-establish everythingConnection migration — preserves session
MultiplexingAffected by TCP HoL blockingTrue multiplexing at the transport layer
On flaky networksSlow, frequent timeoutsResilient, recovers quickly

Why does QUIC matter for Tunnel?

Cloudflare Tunnel opens multiple concurrent connections to different edge servers. With HTTP/2, a single TCP packet loss blocks every stream on the same connection (HoL blocking). QUIC eliminates that, making the tunnel significantly more stable over high-latency or lossy networks — perfect for home labs or mobile 4G/5G.

3. Setting Up Cloudflare Tunnel from Scratch

There are two ways to manage a tunnel: Dashboard-managed (recommended, configured in the cloud) and Locally-managed (configured with a YAML file, ideal for GitOps). Both are covered below.

3.1. Installing cloudflared

macOS
brew install cloudflared
Linux (Debian/Ubuntu)
curl -L https://pkg.cloudflare.com/install.sh | sudo bash
sudo apt install cloudflared
Windows (winget)
winget install Cloudflare.cloudflared
Docker
docker run -d --name cf-tunnel \
  --restart unless-stopped \
  cloudflare/cloudflared:latest \
  tunnel --no-autoupdate run --token YOUR_TUNNEL_TOKEN

This is the simplest approach — everything configured on the Cloudflare Dashboard:

Step 1: Sign in at one.dash.cloudflare.comNetworksTunnelsCreate a tunnel

Step 2: Pick Cloudflared as the connector and name the tunnel (e.g. my-homelab)

Step 3: Copy the token and run it on your server:

cloudflared service install eyJhbGciOi...YOUR_TOKEN
# Or run directly (without installing a service):
cloudflared tunnel --no-autoupdate run --token eyJhbGciOi...YOUR_TOKEN

Step 4: Back in the Dashboard, add a Public Hostname:

FieldExample valueDescription
SubdomainappThe subdomain you want
Domainexample.comA domain already on Cloudflare
Service TypeHTTPThe internal service's protocol
URLlocalhost:3000The internal service address

Cloudflare automatically creates a DNS CNAME record pointing to the tunnel UUID. A TLS certificate is issued automatically too — your internal service runs plain HTTP, but users reach it via HTTPS.

3.3. Locally-managed Tunnel (GitOps)

If you prefer managing configuration via files (version control, IaC), use this approach:

Terminal
# Log in and create a tunnel
cloudflared tunnel login
cloudflared tunnel create my-homelab

# Add DNS routes
cloudflared tunnel route dns my-homelab app.example.com
cloudflared tunnel route dns my-homelab api.example.com
~/.cloudflared/config.yml
tunnel: my-homelab
credentials-file: /home/user/.cloudflared/<TUNNEL-UUID>.json

ingress:
  - hostname: app.example.com
    service: http://localhost:3000
  - hostname: api.example.com
    service: http://localhost:8080
    originRequest:
      connectTimeout: 30s
      noTLSVerify: true
  - hostname: ssh.example.com
    service: ssh://localhost:22
  # Catch-all rule — required
  - service: http_status:404

The catch-all rule is required

The config.yml file must end with a rule that has no hostname (the catch-all). Without it, cloudflared refuses to start. This rule handles requests that don't match any hostname — usually returning 404.

Terminal
# Run the tunnel
cloudflared tunnel run my-homelab

# Or install it as a system service (auto-starts with the OS)
sudo cloudflared service install
sudo systemctl enable cloudflared
sudo systemctl start cloudflared

4. Zero Trust Access — Protecting Services with Authentication

The tunnel exposes your service to the Internet, but you don't want just anyone to reach it. Cloudflare Zero Trust Access puts an authentication layer in front of the service — like a "security gate" users must sign in at before entering.

graph TB
    subgraph ZeroTrust["Cloudflare Zero Trust"]
        direction TB
        A["User hits app.example.com"] --> B{"Access Policy"}
        B -->|"Not signed in"| C["Login Page
(Email OTP / Google / GitHub)"] C --> D["Authenticate identity"] D -->|"Pass"| E["Allow access"] D -->|"Fail"| F["Blocked — 403"] B -->|"Has session"| E E --> G["Forward through Tunnel"] G --> H["Internal service"] end style ZeroTrust fill:#f8f9fa,stroke:#e0e0e0 style A fill:#e94560,stroke:#fff,color:#fff style B fill:#ff9800,stroke:#fff,color:#fff style C fill:#2c3e50,stroke:#fff,color:#fff style E fill:#4CAF50,stroke:#fff,color:#fff style F fill:#e94560,stroke:#fff,color:#fff style H fill:#2c3e50,stroke:#fff,color:#fff

Zero Trust Access flow — users pass the "security gate" before reaching the service

4.1. Configuring an Access Application

On the one.dash.cloudflare.com dashboard:

Step 1: Go to AccessApplicationsAdd an application → pick Self-hosted

Step 2: Enter the application domain: app.example.com

Step 3: Create an Access Policy:

FieldValueDescription
Policy nameAllow team membersPolicy name
ActionAllowAllow access if matched
Include — Emails*@company.comAllow the whole company email domain
Include — Emailpartner@external.comOr allow a specific email address

Step 4: Pick an Identity Provider — the default is One-time PIN (sends an OTP via email, no setup needed). Or integrate Google, GitHub, Okta, Azure AD under SettingsAuthentication.

The free tier is enough for small teams

Cloudflare Zero Trust's free tier supports up to 50 users — more than enough for a startup, development team, or personal home lab. It includes Access policies, email OTP, and even browser-rendered SSH/VNC. Need more than 50 users? Pay-as-you-go starts at around $7/user/month.

4.2. Supported Identity Providers

ProviderSetupBest for
One-time PIN (Email OTP)None — built inQuick setup, personal use, demos
Google WorkspaceOAuth Client ID + SecretCompanies on Google
GitHubOAuth AppDev teams, open source
Azure AD (Entra ID)App Registration + Tenant IDMicrosoft 365 enterprises
OktaOIDC/SAMLEnterprise SSO
SAML 2.0 GenericEntity ID + SSO URLAny SAML-capable IdP

5. Real-world Use Cases

5.1. Home Lab — Safe self-hosting

You're running Home Assistant, Nextcloud, Grafana, or a media server (Jellyfin, Plex) on a NAS or Raspberry Pi at home. Instead of opening router ports (and worrying about security), use Tunnel to expose services under a personal domain with Access protection.

config.yml — Home Lab
tunnel: homelab
credentials-file: /home/pi/.cloudflared/uuid.json

ingress:
  - hostname: home.mydomain.com
    service: http://192.168.1.100:8123   # Home Assistant
  - hostname: cloud.mydomain.com
    service: http://192.168.1.100:8080   # Nextcloud
  - hostname: grafana.mydomain.com
    service: http://192.168.1.100:3000   # Grafana
  - service: http_status:404

5.2. Development — Sharing localhost with the team

Building a new feature and want a PM or designer to test on their phone without deploying?

Terminal — Quick tunnel (temporary)
# Create an ad-hoc tunnel — random URL, gone when the terminal closes
cloudflared tunnel --url http://localhost:5173

# Output: https://random-words.trycloudflare.com
Terminal — Named tunnel (persistent)
# Or use a named tunnel for a fixed URL
# dev.mydomain.com always points to your machine
cloudflared tunnel route dns dev-laptop dev.mydomain.com
cloudflared tunnel run dev-laptop

5.3. SSH/RDP without a VPN

Cloudflare can render SSH and RDP right in the browser — users don't need a client installed. Pair it with Access so only authorized people can connect.

graph LR
    A["Admin"] -->|"Open browser"| B["ssh.example.com"]
    B --> C["Cloudflare Access
Email OTP authentication"] C --> D["Browser-rendered SSH"] D -->|"Via Tunnel"| E["SSH server
port 22"] style A fill:#e94560,stroke:#fff,color:#fff style B fill:#2c3e50,stroke:#fff,color:#fff style C fill:#ff9800,stroke:#fff,color:#fff style D fill:#4CAF50,stroke:#fff,color:#fff style E fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50

SSH through the browser — no VPN, no SSH client required

5.4. Multi-service on a single server

A single tunnel can expose multiple services under different hostnames — no need for extra tunnels or servers:

graph TB
    subgraph Cloudflare["Cloudflare Edge"]
        direction LR
        D1["app.example.com"]
        D2["api.example.com"]
        D3["admin.example.com"]
    end
    subgraph Server["Internal server"]
        direction LR
        T["cloudflared"]
        S1["Vue App :5173"]
        S2[".NET API :5000"]
        S3["Admin Panel :8080"]
    end
    D1 --> T
    D2 --> T
    D3 --> T
    T --> S1
    T --> S2
    T --> S3
    style Cloudflare fill:#f8f9fa,stroke:#2c3e50
    style Server fill:#f0fff0,stroke:#4CAF50
    style T fill:#e94560,stroke:#fff,color:#fff
    style S1 fill:#fff,stroke:#4CAF50,color:#2c3e50
    style S2 fill:#fff,stroke:#4CAF50,color:#2c3e50
    style S3 fill:#fff,stroke:#4CAF50,color:#2c3e50

One tunnel — many services under different hostnames

6. High Availability and Production Deployment

6.1. Replica connectors

Use the same tunnel token on multiple machines — Cloudflare auto-load-balances between connectors. If one machine dies, traffic fails over to the survivors with zero downtime.

docker-compose.yml — HA Tunnel
services:
  tunnel-1:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN}

  tunnel-2:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN}

  web-app:
    image: my-web-app:latest
    ports:
      - "3000:3000"

6.2. Infrastructure as Code with Terraform

As of 2026, Cloudflare's Terraform provider v5 is stable and fully supports managing tunnels via IaC:

main.tf
resource "cloudflare_zero_trust_tunnel_cloudflared" "homelab" {
  account_id = var.cloudflare_account_id
  name       = "homelab-tunnel"
  secret     = random_id.tunnel_secret.b64_std
}

resource "cloudflare_zero_trust_tunnel_cloudflared_config" "homelab" {
  account_id = var.cloudflare_account_id
  tunnel_id  = cloudflare_zero_trust_tunnel_cloudflared.homelab.id

  config {
    ingress_rule {
      hostname = "app.${var.domain}"
      service  = "http://localhost:3000"
    }
    ingress_rule {
      service = "http_status:404"
    }
  }
}

resource "cloudflare_record" "app" {
  zone_id = var.cloudflare_zone_id
  name    = "app"
  content = "${cloudflare_zero_trust_tunnel_cloudflared.homelab.id}.cfargotunnel.com"
  type    = "CNAME"
  proxied = true
}

7. Cloudflare Tunnel vs Other Solutions

CriterionCloudflare TunnelngrokTailscalefrp (self-hosted)
CostFree, unlimitedFree (1 agent, 1 domain). Pro $15/moFree (100 devices)Free (need a server)
Custom domain✅ FreePaid plans onlyVia internal MagicDNS✅ Self-configured
Automatic TLS✅ (internal)❌ Need Let's Encrypt
DDoS Protection✅ Built-inLimited
WAF✅ Free tier
Auth/SSO✅ Zero Trust AccessOAuth (paid)✅ Tailscale ACL
HA/Load Balance✅ Multi-connectorEnterprise plan only✅ Peer-to-peer❌ Manual
ModelEdge proxy (public)Edge proxy (public)Peer-to-peer (private)Reverse proxy
Best forPublic exposure + securityFast demos, webhooksPrivate team networksFull control

When NOT to use Cloudflare Tunnel

Peer-to-peer networking: if you just need team machines to talk privately, Tailscale or WireGuard fits better. Data residency: if compliance forbids data crossing third parties, use frp or a private VPN. Serving large static files: reach for Cloudflare R2 or Pages instead of tunneling to a file server.

8. Advanced Security

8.1. Layered defense

graph TB
    A["Request from Internet"] --> B["Layer 1: Cloudflare DDoS Protection"]
    B --> C["Layer 2: WAF Rules"]
    C --> D["Layer 3: Bot Management"]
    D --> E["Layer 4: Zero Trust Access
(Email OTP / SSO)"] E --> F["Layer 5: Access Policy
(Email domain, country, device)"] F --> G["Layer 6: QUIC-encrypted tunnel"] G --> H["Internal service"] style A fill:#e94560,stroke:#fff,color:#fff style B fill:#2c3e50,stroke:#fff,color:#fff style C fill:#2c3e50,stroke:#fff,color:#fff style D fill:#2c3e50,stroke:#fff,color:#fff style E fill:#ff9800,stroke:#fff,color:#fff style F fill:#ff9800,stroke:#fff,color:#fff style G fill:#4CAF50,stroke:#fff,color:#fff style H fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50

Six security layers from edge to origin — all free on the free tier

8.2. Fully locking down the firewall

Because cloudflared only opens outbound connections, you can completely lock down the server's firewall — blocking all inbound traffic. That turns the server from "scannable on the Internet" into invisible to any port scanner.

iptables — full inbound lockdown
# Allow outbound traffic only (needed by cloudflared)
# Block all inbound (except established connections)
sudo iptables -P INPUT DROP
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo iptables -A INPUT -i lo -j ACCEPT

# The server is now "invisible" — nmap won't see any open ports
# But cloudflared still works fine because it only makes outbound connections

8.3. Service Tokens for API-to-API

If an external service needs to call an API through the tunnel (not a user logging in), use a Service Token instead of an Access Policy:

# Create a service token in the Dashboard:
# Access → Service Auth → Create Service Token
# Save the CF-Access-Client-Id and CF-Access-Client-Secret

# Call the API with the service token:
curl -H "CF-Access-Client-Id: YOUR_CLIENT_ID" \
     -H "CF-Access-Client-Secret: YOUR_CLIENT_SECRET" \
     https://api.example.com/internal/endpoint

9. Monitoring and Troubleshooting

9.1. Checking tunnel status

# List tunnels
cloudflared tunnel list

# Show detailed connection info
cloudflared tunnel info my-homelab

# Metrics endpoint (Prometheus-compatible)
cloudflared tunnel --metrics localhost:2000 run my-homelab
# Open http://localhost:2000/metrics to view metrics

9.2. Diagnostic logs

# Enable debug logs
cloudflared tunnel --loglevel debug run my-homelab

# Since 2026, QUIC connection metrics are more detailed:
# - Active stream count
# - Average RTT to edge
# - Packet loss rate
# - Connection migration events

Dashboard monitoring

At one.dash.cloudflare.comNetworksTunnels, you can see real-time tunnel status (healthy/degraded/down), connector version, the edge location it's attached to, and traffic volume. No extra setup — it ships with the free tier.

10. Production Deployment Checklist

#ItemDetails
1Install cloudflared as a system servicesudo cloudflared service install — auto-restart on crash or reboot
2Use a Dashboard-managed tunnelCloud-side config, easy remote management, no SSH into the server to change it
3Enable Zero Trust AccessAt minimum email OTP for every UI-facing service
4Service Tokens for APIsUse CF-Access-Client-Id + CF-Access-Client-Secret for API-to-API calls
5Lock down the firewallBlock all inbound traffic — cloudflared only needs outbound
6HA with multiple connectorsRun the same token on 2+ machines to avoid a SPOF
7MonitoringEnable the metrics endpoint + watch Dashboard tunnel health
8Terraform (for multi-env)IaC for tunnels + Access policies, easy to replicate between staging/prod

Wrap-up

Cloudflare Tunnel + Zero Trust Access is one of the strongest free-tier solutions today for exposing internal services to the Internet. No static IP, no open ports, automatic TLS, built-in DDoS protection, and a Zero Trust authentication layer — all for $0. From personal home labs to production startups, it's a tool every developer and sysadmin should keep in their toolkit.

References: