Skip to main content
Infrastructure

Cloudflare Tunnel Dual-Endpoint Pattern

Architecture pattern for exposing a backend service with both public (protected) and internal (unprotected) endpoints via Cloudflare Tunnel

March 12, 2026

Cloudflare Tunnel Dual-Endpoint Pattern

When deploying backend services via Cloudflare Tunnel, you often need two distinct access patterns:

  1. Public access with authentication protection (for users/external consumers)
  2. 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 cloudflared daemon 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:

  1. Go to Zero Trust → Access → Applications
  2. Create/edit application for myservice.example.com
  3. Set up authentication rules (require email, SSO, etc.)
  4. 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:

  1. Not Advertised

    • URL is only in code/documentation
    • Not discoverable via normal browsing
    • Not in public DNS (same CNAME, but purposefully named differently)
  2. Only Reachable via Tunnel

    • Direct IP access impossible (CNAME resolves to Cloudflare only)
    • Cannot be reached by scanning or network attacks from outside
  3. 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
  4. 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:

  1. Not being advertised (security by obscurity)
  2. Not being directly reachable (CNAME forces tunnel)
  3. Sitting behind CF Pages auth (another layer)
  4. 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

  1. Naming Convention: Use -internal or -backend suffix for internal endpoints
  2. Environment Variables: Always use env vars, never hardcode URLs
  3. Fallback: Provide localhost:port fallback for local development
  4. Documentation: List both endpoints clearly in your README
  5. Monitoring: Monitor both endpoints for latency, errors, and availability
  6. Testing: Test both paths (public + internal) in your CI/CD pipeline

Related Docs


Last Updated: 2026-03-12 Status: Recommended Pattern Implementation: Production (Ollama service)

cloudflaretunnelarchitecturebackendapi