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:

ApproachProblem
Single public endpoint + AccessBackend calls get redirected to login page
Service tokens with credentialsAdds credential rotation, storage, complexity
IP allowlistingDoesn’t work with Cloudflare (everything uses CF IPs)
Separate infrastructureDoubles 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

ThreatRiskMitigation
Someone discovers the URLLowNot advertised; only in private repos/docs
Someone tries to access it directlyNoneCNAME forces tunnel routing; can’t bypass
Tunnel is compromisedHighBut affects both endpoints equally
Origin service is compromisedHighBut affects both endpoints equally
CF Pages code leaks the URLLowURL is just a hostname, not a secret

Benefits vs. Service Tokens

AspectDual EndpointsService Tokens
Credential managementNoneRequires token rotation
Code complexitySimple fetch()Needs auth headers
API scope requirementsNoneNeeds special scopes
PerformanceFastSlower (auth validation)
DebuggingEasyComplex
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


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

cloudflaretunnelarchitecturebackendapi