Using Cloudflare Workers to tunnel matching traffic into your local
Why Would You Ever Do This?
You probably wouldn’t ever. I needed it for a SAML integration I was working on. The IdP was locked to the production URL, and every small tweak meant a full deploy cycle just to see if it worked. I wanted to iterate fast — and for some edge cases with Xdebug — without touching production. So I built a small Cloudflare Worker that quietly routes my browser session to my local DDEV environment while everyone else keeps hitting the real server.
How It Works
This only works if your domain is already proxied through Cloudflare (the orange cloud ☁️ in your DNS settings). When that’s the case, every request passes through Cloudflare’s edge before reaching your origin, which means you can intercept and reroute it with a Worker.
The Worker adds a simple opt-in toggle: you visit your production URL with ?cf_local_debug=1, and the Worker checks that the request comes from your IP. If it does, it sets a short-lived cookie and redirects you back. From that point on, as long as that cookie is present and the request comes from your IP, the Worker transparently proxies all traffic to your local DDEV tunnel instead of the normal origin. Everyone else continues hitting production as if nothing happened.

Here’s the full flow:
- Your domain is proxied through Cloudflare and the Worker is attached to its routes (
myapp.example.com/*). - You run
ddev share --provider=cloudflared(or any other share provider) to expose your local environment via a Cloudflare Tunnel URL (e.g.https://foo-bar.trycloudflare.com). - You set that URL as the
DEBUG_ORIGINsecret on the Worker, along with your current public IP asDEBUG_IP. - You visit
https://myapp.example.com/?cf_local_debug=1. - The Worker sets the debug cookie and redirects you to the clean URL.
- Subsequent requests from your browser go to your local DDEV — Xdebug, local database, local code and all.
- When you’re done, visit
?cf_local_debug=0to clear the cookie, or let it expire on its own.
Setting Up the Worker
You’ll need a Cloudflare account and the Wrangler CLI. Create a new folder for the worker and add these three files:
package.json
{
"name": "cloudflare-worker-ddev",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"deploy": "wrangler deploy"
},
"devDependencies": {
"wrangler": "^4.6.0"
}
}
wrangler.jsonc
{
"name": "cloudflare-worker-ddev",
"main": "src/index.js",
"compatibility_date": "2026-04-07",
"workers_dev": false,
}
Routes are intentionally kept out of wrangler.jsonc to avoid hardcoding your domain in source control — you’ll add those in the dashboard in a moment.
src/index.js
function parseCookies(cookieHeader) {
const cookies = {}
if (!cookieHeader) return cookies
for (const part of cookieHeader.split(";")) {
const index = part.indexOf("=")
if (index === -1) continue
const key = part.slice(0, index).trim()
const value = part.slice(index + 1).trim()
cookies[key] = value
}
return cookies
}
function buildDebugCookie(name, value, maxAge) {
return [
`${name}=${value}`,
"Path=/",
`Max-Age=${maxAge}`,
"HttpOnly",
"Secure",
"SameSite=None",
].join("; ")
}
function buildRedirectResponse(url, cookieName, cookieValue, maxAge) {
return new Response(null, {
status: 302,
headers: {
Location: url.toString(),
"Set-Cookie": buildDebugCookie(cookieName, cookieValue, maxAge),
"Cache-Control": "private, no-store",
},
})
}
function getConfig(env) {
const debugOrigin = env.DEBUG_ORIGIN
const debugIp = env.DEBUG_IP
const debugCookie = env.DEBUG_COOKIE?.trim() || "cf_local_debug"
const debugMaxAge = env.DEBUG_MAX_AGE ? Number(env.DEBUG_MAX_AGE) : 3600
return { debugOrigin, debugIp, debugCookie, debugMaxAge }
}
export default {
async fetch(request, env) {
const requestUrl = new URL(request.url)
// Images always go to the real origin — no need to proxy assets
if (
/\.(png|jpe?g|gif|svg|webp|ico|bmp|tiff|avif)$/i.test(requestUrl.pathname)
) {
return fetch(request)
}
const config = getConfig(env)
const clientIp = request.headers.get("CF-Connecting-IP") || ""
const cookies = parseCookies(request.headers.get("Cookie"))
const hasDebugCookie = cookies[config.debugCookie] === "1"
const enableRequested =
requestUrl.searchParams.get("cf_local_debug") === "1"
const disableRequested =
requestUrl.searchParams.get("cf_local_debug") === "0"
const ipMatches = clientIp === config.debugIp
if (enableRequested && ipMatches) {
const cleanUrl = new URL(requestUrl)
cleanUrl.searchParams.delete("cf_local_debug")
return buildRedirectResponse(
cleanUrl,
config.debugCookie,
"1",
config.debugMaxAge
)
}
if (disableRequested) {
const cleanUrl = new URL(requestUrl)
cleanUrl.searchParams.delete("cf_local_debug")
return buildRedirectResponse(cleanUrl, config.debugCookie, "0", 0)
}
// Not in debug mode — pass through to the real origin
if (!(hasDebugCookie && ipMatches)) return fetch(request)
// Proxy to the local tunnel
const debugOrigin = new URL(config.debugOrigin)
const targetUrl = new URL(requestUrl)
targetUrl.protocol = debugOrigin.protocol
targetUrl.hostname = debugOrigin.hostname
targetUrl.port = debugOrigin.port
targetUrl.searchParams.delete("cf_local_debug")
const headers = new Headers(request.headers)
headers.set("Host", debugOrigin.hostname)
headers.set("x-debug-via", "cloudflare-worker")
headers.set("x-original-host", requestUrl.hostname)
headers.set("x-forwarded-proto", "https")
headers.set("x-forwarded-for", clientIp)
// x-forwarded-host is intentionally omitted — some tunnel providers (ngrok, Cloudflare Tunnel)
// drop connections if it doesn't match the SNI hostname
const proxiedRequest = new Request(targetUrl.toString(), {
method: request.method,
headers,
body: request.body,
redirect: "manual",
})
const upstream = await fetch(proxiedRequest)
const response = new Response(upstream.body, upstream)
response.headers.set("Cache-Control", "private, no-store")
return response
},
}
A few design decisions worth noting:
- Image passthrough — image requests skip the worker entirely to avoid unnecessary tunnel traffic for static assets.
- IP lock — the debug cookie can only be set from
DEBUG_IP, so no other visitor can accidentally activate it. x-forwarded-hostis omitted — Cloudflare Tunnel (and ngrok) may drop connections when this header doesn’t match the tunnel’s SNI hostname.redirect: "manual"— redirects from the tunnel are forwarded as-is rather than followed internally, which matters for things like SAML assertion callbacks.
Deploy
npm install
npm run deploy
Set secrets
Secrets are set via the CLI and stored encrypted by Cloudflare — nothing sensitive ever lives in source control. The two required ones are the tunnel URL (more on that in the next step) and your public IP:
# The local tunnel URL from ddev share
npx wrangler secret put DEBUG_ORIGIN
# Pipe your current public IP directly — no copy-pasting
curl -s https://api.ipify.org | npx wrangler secret put DEBUG_IP
Two optional secrets have sensible defaults:
# Cookie name — defaults to cf_local_debug
npx wrangler secret put DEBUG_COOKIE
# Cookie lifetime in seconds — defaults to 3600 (1 hour)
npx wrangler secret put DEBUG_MAX_AGE
Attach the route
In the Cloudflare dashboard, go to Workers & Pages → your Worker → Settings → Domains & Routes and add myapp.example.com/*.
Important: This only works if your DNS record is set to Proxied (orange cloud) in Cloudflare. If it’s DNS-only, the Worker never sees the traffic.
Starting the Tunnel
Each time you want to debug, start your DDEV project and expose it:
ddev start
ddev share --provider=cloudflared
ddev share will print a public URL like https://care-assessment-divine-forestry.trycloudflare.com. Copy it and update the Worker secret:
npx wrangler secret put DEBUG_ORIGIN
# paste the tunnel URL when prompted
Note: The tunnel URL is ephemeral — it changes every time you run
ddev share. Remember to updateDEBUG_ORIGINeach session.
Activating Debug Mode
With the Worker deployed, the route configured, and the tunnel running, visit your production URL with the toggle parameter:
https://myapp.example.com/?cf_local_debug=1
The Worker checks your IP against DEBUG_IP, sets the cookie, and redirects you to the clean URL. Your browser is now transparently receiving responses from your local DDEV environment.
Debugging with Xdebug
Since traffic is now being served from your local DDEV, Xdebug works exactly as it normally would:
ddev xdebug on
Set a breakpoint in your IDE, trigger the request (or let the IdP do it for you), and the debugger will pause execution right where you need it — even though the URL in the browser says myapp.example.com.
Wrapping Up
This setup scratches a very specific itch. No staging deploys, no IdP reconfiguration, no “works on my machine” guesswork. Just your real production URL, your local DDEV environment, and Xdebug ready to catch whatever comes in.
The code is intentionally simple and easy to adapt to different workflows, cookie strategies, or tunnel providers.
The full worker source is at hanoii/cloudflare-worker-ddev.