Cloudflare Tunnel + Zero Trust — Expose Internal Apps to the Internet Securely and for Free
Posted on: 4/18/2026 3:11:25 AM
Table of contents
- 1. The Problem: Exposing Internal Services to the Internet
- 2. Cloudflare Tunnel — Architecture and How It Works
- 3. Setting Up Cloudflare Tunnel from Scratch
- 4. Zero Trust Access — Protecting Services with Authentication
- 5. Real-world Use Cases
- 6. High Availability and Production Deployment
- 7. Cloudflare Tunnel vs Other Solutions
- 8. Advanced Security
- 9. Monitoring and Troubleshooting
- 10. Production Deployment Checklist
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.
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:
| Characteristic | HTTP/2 over TCP (old) | QUIC (new) |
|---|---|---|
| Head-of-Line Blocking | Affects all streams on the same connection | Streams are independent — no cross-impact |
| Connection setup | TCP handshake + TLS handshake (2-3 RTT) | 0-RTT or 1-RTT (TLS 1.3 built in) |
| Reconnecting after network loss | Must re-establish everything | Connection migration — preserves session |
| Multiplexing | Affected by TCP HoL blocking | True multiplexing at the transport layer |
| On flaky networks | Slow, frequent timeouts | Resilient, 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
brew install cloudflared
curl -L https://pkg.cloudflare.com/install.sh | sudo bash
sudo apt install cloudflared
winget install Cloudflare.cloudflared
docker run -d --name cf-tunnel \
--restart unless-stopped \
cloudflare/cloudflared:latest \
tunnel --no-autoupdate run --token YOUR_TUNNEL_TOKEN
3.2. Dashboard-managed Tunnel (Recommended)
This is the simplest approach — everything configured on the Cloudflare Dashboard:
Step 1: Sign in at one.dash.cloudflare.com → Networks → Tunnels → Create 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:
| Field | Example value | Description |
|---|---|---|
| Subdomain | app | The subdomain you want |
| Domain | example.com | A domain already on Cloudflare |
| Service Type | HTTP | The internal service's protocol |
| URL | localhost:3000 | The 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:
# 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
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.
# 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 Access → Applications → Add an application → pick Self-hosted
Step 2: Enter the application domain: app.example.com
Step 3: Create an Access Policy:
| Field | Value | Description |
|---|---|---|
| Policy name | Allow team members | Policy name |
| Action | Allow | Allow access if matched |
| Include — Emails | *@company.com | Allow the whole company email domain |
| Include — Email | partner@external.com | Or 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 Settings → Authentication.
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
| Provider | Setup | Best for |
|---|---|---|
| One-time PIN (Email OTP) | None — built in | Quick setup, personal use, demos |
| Google Workspace | OAuth Client ID + Secret | Companies on Google |
| GitHub | OAuth App | Dev teams, open source |
| Azure AD (Entra ID) | App Registration + Tenant ID | Microsoft 365 enterprises |
| Okta | OIDC/SAML | Enterprise SSO |
| SAML 2.0 Generic | Entity ID + SSO URL | Any 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.
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?
# Create an ad-hoc tunnel — random URL, gone when the terminal closes
cloudflared tunnel --url http://localhost:5173
# Output: https://random-words.trycloudflare.com
# 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.
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:
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
| Criterion | Cloudflare Tunnel | ngrok | Tailscale | frp (self-hosted) |
|---|---|---|---|---|
| Cost | Free, unlimited | Free (1 agent, 1 domain). Pro $15/mo | Free (100 devices) | Free (need a server) |
| Custom domain | ✅ Free | Paid plans only | Via internal MagicDNS | ✅ Self-configured |
| Automatic TLS | ✅ | ✅ | ✅ (internal) | ❌ Need Let's Encrypt |
| DDoS Protection | ✅ Built-in | Limited | ❌ | ❌ |
| WAF | ✅ Free tier | ❌ | ❌ | ❌ |
| Auth/SSO | ✅ Zero Trust Access | OAuth (paid) | ✅ Tailscale ACL | ❌ |
| HA/Load Balance | ✅ Multi-connector | Enterprise plan only | ✅ Peer-to-peer | ❌ Manual |
| Model | Edge proxy (public) | Edge proxy (public) | Peer-to-peer (private) | Reverse proxy |
| Best for | Public exposure + security | Fast demos, webhooks | Private team networks | Full 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.
# 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.com → Networks → Tunnels, 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
| # | Item | Details |
|---|---|---|
| 1 | Install cloudflared as a system service | sudo cloudflared service install — auto-restart on crash or reboot |
| 2 | Use a Dashboard-managed tunnel | Cloud-side config, easy remote management, no SSH into the server to change it |
| 3 | Enable Zero Trust Access | At minimum email OTP for every UI-facing service |
| 4 | Service Tokens for APIs | Use CF-Access-Client-Id + CF-Access-Client-Secret for API-to-API calls |
| 5 | Lock down the firewall | Block all inbound traffic — cloudflared only needs outbound |
| 6 | HA with multiple connectors | Run the same token on 2+ machines to avoid a SPOF |
| 7 | Monitoring | Enable the metrics endpoint + watch Dashboard tunnel health |
| 8 | Terraform (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:
Cloudflare Agent Cloud 2026 — Building AI Agents at the Edge with Workers, Durable Objects, and Project Think
Testing Strategy on .NET 10 — TestContainers, xUnit v3, and Mutation Testing for Production
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.