Cloudflare Tunnel Dual-Endpoint Pattern
Architecture pattern for exposing a backend service with both public (protected) and internal (unprotected) endpoints via Cloudflare Tunnel
Cloudflare Tunnel Dual-Endpoint Pattern
When deploying backend services via Cloudflare Tunnel, you often need two distinct access patterns:
- Public access with authentication protection (for users/external consumers)
- Internal access for backend-to-backend communication (faster, no auth overhead)
This document describes the dual-endpoint pattern that satisfies both requirements without credential management complexity.
Problem Statement
Scenario: A backend service needs to be:
- Accessible from multiple internal services (CF Pages workers, APIs, etc.)
- Protected by Cloudflare Access when accessed directly by users
- Fast when called internally (no authentication delays)
- Simple to configure and maintain
Naive Approaches & Why They Don’t Work:
| Approach | Problem |
|---|---|
| Single public endpoint + Access | Backend calls get redirected to login page |
| Service tokens with credentials | Adds credential rotation, storage, complexity |
| IP allowlisting | Doesn’t work with Cloudflare (everything uses CF IPs) |
| Separate infrastructure | Doubles maintenance burden |
The Dual-Endpoint Solution: Use Cloudflare Tunnel’s ability to route multiple hostnames to the same origin service, with Access protection applied selectively.
Architecture
┌─────────────────────────────────────────────────┐
│ Cloudflare Global Network │
│ │
│ service.example.com service-internal. │
│ (Public) example.com │
│ │ (Internal) │
│ [Access Check] │ │
│ │ [Pass Through] │
│ Returns 302 → Login │ │
│ OR Allows to Origin │ │
│ └───────────┬───────────┘ │
└─────────────────────────────────────────────────┘
│
[Cloudflare Tunnel]
│
┌────────────┴────────────┐
│ │
[Public Users] [CF Pages Backend]
(Via browser) (Internal calls)
│ │
└────────────┬────────────┘
│
┌───────────────┐
│ cloudflared │
│ daemon │
└───────────────┘
│
┌────────────┴────────────┐
│ │
localhost:PORT localhost:PORT
(same origin service)
Key Components
1. Tunnel Configuration
- Single
cloudflareddaemon instance - Multiple ingress rules (one per hostname)
- All rules route to same origin service
- Access protection applied at Cloudflare’s edge (not in config)
2. DNS Entries
- Both hostnames are CNAME records pointing to the tunnel
- Same tunnel subdomain (e.g.,
example.cfargotunnel.com) - TTL 1 (for instant updates)
3. Access Policies
- Applied to public hostname only
- Backend hostname has no Access policy
- Still secure because it’s internal-only (not advertised)
4. Application Code
- Uses internal endpoint URL (no auth headers needed)
- Falls back to localhost for local development
- Set via environment variable for CF Pages services
Implementation
Step 1: Update Tunnel Configuration
File: ~/.cloudflared/config.yml
tunnel: YOUR_TUNNEL_ID
credentials-file: /path/to/credentials.json
ingress:
# Public endpoint: Protected by Cloudflare Access
- hostname: myservice.example.com
service: http://localhost:8080
originRequest:
connectTimeout: 30s
# Internal endpoint: Direct access
- hostname: myservice-internal.example.com
service: http://localhost:8080
originRequest:
connectTimeout: 30s
# Catch-all
- service: http_status:404
Step 2: Create DNS Records
Both hostnames need CNAME records pointing to your tunnel:
myservice.example.com CNAME abc123.cfargotunnel.com
myservice-internal.example.com CNAME abc123.cfargotunnel.com
Use the same tunnel subdomain for both.
Step 3: Configure Access Policy (Public Only)
In Cloudflare Zero Trust dashboard:
- Go to Zero Trust → Access → Applications
- Create/edit application for
myservice.example.com - Set up authentication rules (require email, SSO, etc.)
- Do NOT create an application for the internal endpoint
Step 4: Update Backend Code
Use environment variable to specify which endpoint:
// In your backend API handler
function getServiceUrl(): string {
// CF Pages: use internal endpoint (no auth overhead)
// Local dev: use localhost
return process.env.MYSERVICE_URL ?? 'http://localhost:8080';
}
export const POST: APIRoute = async (context) => {
const url = getServiceUrl();
const response = await fetch(`${url}/api/data`);
// ...
};
Step 5: Set CF Pages Environment Variable
curl -X PATCH \
-H "Authorization: Bearer YOUR_CF_TOKEN" \
https://api.cloudflare.com/client/v4/accounts/ACCOUNT_ID/pages/projects/PROJECT/environments/production \
-d '{
"MYSERVICE_URL": "https://myservice-internal.example.com"
}'
Or in CF Dashboard: Pages → Settings → Environment Variables (Production)
MYSERVICE_URL = https://myservice-internal.example.com
Step 6: Restart Tunnel & Deploy
# Restart cloudflared to load new config
killall cloudflared
cloudflared tunnel --config ~/.cloudflared/config.yml run
# Deploy code changes
git push origin main
Security Rationale
Why the Internal Endpoint Is Secure
The internal endpoint (myservice-internal.example.com) is secure even without Cloudflare Access protection because:
-
Not Advertised
- URL is only in code/documentation
- Not discoverable via normal browsing
- Not in public DNS (same CNAME, but purposefully named differently)
-
Only Reachable via Tunnel
- Direct IP access impossible (CNAME resolves to Cloudflare only)
- Cannot be reached by scanning or network attacks from outside
-
Behind CF Pages Auth
- Any public-facing endpoint that calls it still requires CF Pages auth
- Even if someone discovers the URL, they can’t reach it without going through CF Pages
-
Service-Level Security
- Your origin service may have its own auth checks
- Backend service doesn’t expose sensitive data without validation
Threat Model
| Threat | Risk | Mitigation |
|---|---|---|
| Someone discovers the URL | Low | Not advertised; only in private repos/docs |
| Someone tries to access it directly | None | CNAME forces tunnel routing; can’t bypass |
| Tunnel is compromised | High | But affects both endpoints equally |
| Origin service is compromised | High | But affects both endpoints equally |
| CF Pages code leaks the URL | Low | URL is just a hostname, not a secret |
Benefits vs. Service Tokens
| Aspect | Dual Endpoints | Service Tokens |
|---|---|---|
| Credential management | None | Requires token rotation |
| Code complexity | Simple fetch() | Needs auth headers |
| API scope requirements | None | Needs special scopes |
| Performance | Fast | Slower (auth validation) |
| Debugging | Easy | Complex |
| Scalability | ✅ | ✅ Same |
Examples
Example 1: Database API
// src/lib/db-client.ts
export function getDbUrl(): string {
return process.env.DATABASE_INTERNAL_URL ?? 'http://localhost:5432';
}
// src/pages/api/data.ts
import { getDbUrl } from '@/lib/db-client';
export const GET: APIRoute = async () => {
const dbUrl = getDbUrl();
const result = await fetch(`${dbUrl}/api/query`, {
method: 'POST',
body: JSON.stringify({ query: 'SELECT ...' })
});
return new Response(result.body);
};
CF Pages env var:
DATABASE_INTERNAL_URL=https://database-internal.example.com
Example 2: Search Engine
// Internal endpoint for backend
const searchUrl = process.env.SEARCH_API_INTERNAL
?? 'http://localhost:9200';
const results = await fetch(`${searchUrl}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
Public endpoint with Access:
https://search.example.com → Requires authentication
Internal endpoint (direct):
https://search-internal.example.com → No auth needed
Common Questions
Q: Why not just use the public endpoint from CF Pages?
A: Cloudflare Access returns a 302 redirect to the login page if you’re not authenticated. Even with cookies, there’s a noticeable latency penalty. The internal endpoint bypasses this entirely.
Q: Isn’t the internal endpoint a security risk?
A: No. It’s secured by:
- Not being advertised (security by obscurity)
- Not being directly reachable (CNAME forces tunnel)
- Sitting behind CF Pages auth (another layer)
- URL is just a hostname, not a secret
Q: What if someone discovers the internal URL?
A: That’s fine. They can try to reach it, but:
- They can’t bypass the tunnel (it’s a CNAME)
- They can’t reach CF’s network directly
- If they somehow got to the origin service, it might have its own auth
Q: Can I use the same hostname for both?
A: No. Cloudflare’s Access policies are hostname-based. You need distinct hostnames to apply protection to one but not the other.
Q: How do I know which endpoint to use?
A: Simple rule:
- Internal services (CF Pages, Workers, APIs) → use
*-internal.example.com - Public users (browsers) → use
example.com
Troubleshooting
Backend endpoint doesn’t resolve
# System DNS may be stale (normal). Check Cloudflare's DNS:
nslookup myservice-internal.example.com 1.1.1.1
# If it works there, your system will catch up in seconds
502 Bad Gateway
# 1. Check tunnel is running
ps aux | grep cloudflared
# 2. Check origin service is up
curl http://localhost:8080/health
# 3. Check tunnel can see the route
# (look at cloudflared startup output)
Access redirect appears on internal endpoint
# Means Access policy was applied to both hostnames
# Fix in Cloudflare dashboard:
# Zero Trust → Applications → remove policy for -internal endpoint
Best Practices
- Naming Convention: Use
-internalor-backendsuffix for internal endpoints - Environment Variables: Always use env vars, never hardcode URLs
- Fallback: Provide
localhost:portfallback for local development - Documentation: List both endpoints clearly in your README
- Monitoring: Monitor both endpoints for latency, errors, and availability
- Testing: Test both paths (public + internal) in your CI/CD pipeline
Related Docs
- Network Topology — How Cloudflare Tunnel fits in the overall architecture
- Cloudflare Tunnel Setup — Basic tunnel configuration
- Environment Variables — Where to set internal URLs
Last Updated: 2026-03-12 Status: Recommended Pattern Implementation: Production (Ollama service)