Cloudflare Workers Proxy Setup
Use Cloudflare Workers to proxy Rybbit tracking at the edge
Cloudflare Workers provide a serverless way to proxy Rybbit tracking at the edge, close to your users worldwide. This delivers excellent performance with minimal configuration.
Two settings make or break a first-party proxy:
- Enable First-Party Proxy in your Rybbit Site Settings → Privacy & Security, so the visitor IP your proxy forwards in
X-Forwarded-Foris trusted directly. - Forward all of the browser's request headers, not a hand-picked few. Rybbit's bot detection expects the headers every real browser sends (
Accept,Accept-Language,Accept-Encoding,Sec-Fetch-*) — proxies that forward onlyUser-Agentget their events accepted with200 OKbut classified as bot traffic. UseX-Forwarded-Forfor the visitor IP;X-Real-IPdoes not survive Rybbit Cloud's edge.
Overview
Cloudflare Workers run on Cloudflare's global network, intercepting and proxying requests before they reach your origin server. This makes them perfect for analytics proxying with built-in caching and ultra-low latency.
What you'll achieve:
- Edge-based proxying in 300+ cities worldwide
- Automatic caching with Cloudflare's Cache API
- Sub-10ms latency for most requests
- Zero server infrastructure needed
Prerequisites
- Cloudflare account with your domain
- Workers enabled on your account (free tier available)
- Your Rybbit instance URL:
- Cloud hosted:
https://app.rybbit.io - Self-hosted: Your instance URL
- Cloud hosted:
- Your Rybbit site ID
Implementation
Create Worker
Log in to Cloudflare Dashboard and create a new Worker:
- Go to Workers & Pages → Create application → Create Worker
- Name it
rybbit-proxy(or your preferred name) - Click Deploy to create the worker
Update Worker Code
Replace the default code with the proxy implementation. The examples use the ES module syntax (export default { fetch }), which current Workers use; environment variables and secrets arrive as the env argument of fetch.
// Cloudflare Worker for Rybbit Analytics Proxy
// Proxies every /analytics/* route (script.js, replay.js, track, identify,
// site/tracking-config/:siteId, site/:siteId/feature-flags/evaluate,
// session-replay/record/:siteId) to Rybbit's /api/*.
const RYBBIT_HOST = 'https://app.rybbit.io';
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Only proxy /analytics/* paths
if (!url.pathname.startsWith('/analytics/')) {
// Pass through to origin
return fetch(request);
}
try {
return await proxyToRybbit(request, url);
} catch (error) {
console.error('Proxy error:', error);
return new Response('Analytics proxy error', { status: 500 });
}
},
};
async function proxyToRybbit(request, url) {
// Map paths: /analytics/* → /api/*
let rybbitPath = url.pathname.replace('/analytics/', '/api/');
// Build Rybbit URL
const rybbitUrl = new URL(rybbitPath, RYBBIT_HOST);
rybbitUrl.search = url.search; // Preserve query params
// Get client IP
const clientIp = request.headers.get('CF-Connecting-IP') ||
request.headers.get('X-Forwarded-For') ||
'0.0.0.0';
// Build request headers
const proxyHeaders = new Headers(request.headers);
proxyHeaders.set('X-Real-IP', clientIp);
proxyHeaders.set('X-Forwarded-For', clientIp);
proxyHeaders.delete('CF-Connecting-IP'); // Remove Cloudflare internal header
// Build proxy request
const proxyRequest = new Request(rybbitUrl, {
method: request.method,
headers: proxyHeaders,
body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined,
});
// Check cache for GET requests
if (request.method === 'GET') {
const cache = caches.default;
let response = await cache.match(proxyRequest);
if (response) {
// Return cached response
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: {
...Object.fromEntries(response.headers),
'X-Cache-Status': 'HIT',
},
});
}
}
// Fetch from Rybbit
const response = await fetch(proxyRequest);
// Cache scripts for 1 hour
if (request.method === 'GET' && url.pathname.match(/\.(js)$/)) {
const cacheResponse = new Response(response.body, response);
cacheResponse.headers.set('Cache-Control', 'public, max-age=3600');
cacheResponse.headers.set('X-Cache-Status', 'MISS');
// Clone and cache
const cache = caches.default;
await cache.put(proxyRequest, cacheResponse.clone());
return cacheResponse;
}
// Return non-cacheable response
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}// Minimal Cloudflare Worker for Rybbit
const RYBBIT_HOST = 'https://app.rybbit.io';
export default {
async fetch(request) {
const url = new URL(request.url);
// Only proxy /analytics/script.js and /analytics/track
if (url.pathname === '/analytics/script.js' || url.pathname === '/analytics/track') {
const rybbitPath = url.pathname.replace('/analytics/', '/api/');
const rybbitUrl = `${RYBBIT_HOST}${rybbitPath}${url.search}`;
const clientIp = request.headers.get('CF-Connecting-IP') || '0.0.0.0';
const proxyHeaders = new Headers(request.headers);
proxyHeaders.set('X-Real-IP', clientIp);
proxyHeaders.set('X-Forwarded-For', clientIp);
return fetch(rybbitUrl, {
method: request.method,
headers: proxyHeaders,
body: request.method === 'POST' ? request.body : undefined,
});
}
// Pass through to origin
return fetch(request);
},
};A minimal setup proxies only script.js and /track. The script then can't fetch your site's tracking config, so it ignores every setting on the site's Tracking tab and runs on built-in defaults:
- Pageviews, SPA navigation, query strings and outbound links are tracked.
- Web vitals, error tracking, session replay, button clicks, copy and form tracking stay off.
- Feature flags return their fallback.
identify()andsetTraits()can't reach/identify, so traits aren't stored and earlier anonymous activity isn't linked to the user.
Using Workers secrets for configuration:
export default {
async fetch(request, env) {
const RYBBIT_HOST = env.RYBBIT_HOST || 'https://app.rybbit.io';
const url = new URL(request.url);
if (!url.pathname.startsWith('/analytics/')) {
return fetch(request);
}
// Map paths: /analytics/* → /api/*
const rybbitUrl = new URL(url.pathname.replace('/analytics/', '/api/'), RYBBIT_HOST);
rybbitUrl.search = url.search; // Preserve query params (e.g. ?siteId=)
const clientIp = request.headers.get('CF-Connecting-IP') || '0.0.0.0';
const proxyHeaders = new Headers(request.headers);
proxyHeaders.set('X-Real-IP', clientIp);
proxyHeaders.set('X-Forwarded-For', clientIp);
return fetch(rybbitUrl.toString(), {
method: request.method,
headers: proxyHeaders,
body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined,
});
},
};Set environment variable in Cloudflare Dashboard:
Workers & Pages → Your Worker → Settings → Variables and Secrets → Add RYBBIT_HOST. With Wrangler, set it under vars in your Wrangler config instead.
The full and environment-variable versions forward every /analytics/* path, so feature flag evaluation (/analytics/site/:siteId/feature-flags/evaluate) and /identify are covered. If you narrow the paths, keep the feature flag route: without it, rybbit.flag() silently returns your fallback. Web vitals collection is bundled into script.js; replay.js is only loaded when session replay is enabled for the site.
Deploy Worker
Click Save and Deploy to publish your worker.
Configure Worker Route
Set up a route to trigger the worker:
- Go to Workers & Pages → Your Worker → Triggers
- Click Add route
- Configure:
- Route:
yourdomain.com/analytics/* - Zone: Select your domain
- Route:
- Click Save
The route pattern yourdomain.com/analytics/* ensures the worker only runs for analytics requests, not your entire site.
Update Your Tracking Script
Add the script to your website:
<script src="/analytics/script.js?siteId=YOUR_SITE_ID" defer></script>Older snippets that pass the ID as data-site-id still work.
Keep the proxied file named script.js. The script cuts its own URL at /script.js to find where to send events, so a renamed file (for example /analytics/a.js) sends every request to the wrong place.
Verify the Setup
- Visit your site with Developer Tools open
- Check Network tab:
- Script loads from
/analytics/script.js - Check response headers for
CF-Cache-Status(HIT/MISS)
- Script loads from
- Verify in Rybbit dashboard: Data should appear
How It Works
Cloudflare Workers intercept requests at the edge:
- Request to
yourdomain.com/analytics/script.jshits Cloudflare edge - Worker checks cache for GET requests
- If not cached, proxies to
app.rybbit.io/api/script.js - Response is cached at the edge location
- Subsequent requests serve from edge cache (ultra-fast)
All tracking data flows through Cloudflare's network while preserving client IP information.
Performance Benefits
Global Edge Network
- 300+ locations worldwide: Workers run in Cloudflare data centers globally
- Sub-10ms cold start: Faster than traditional serverless
- Automatic scaling: Handles traffic spikes without configuration
Built-in Caching
- Cache API: Leverages Cloudflare's massive edge cache
- Intelligent caching: Scripts cached at edge, tracking requests passed through
- Instant cache purge: Clear cache via Cloudflare API if needed
Zero Infrastructure
- No servers to manage: Fully serverless
- Pay-per-request: Free tier includes 100,000 requests/day
- Automatic HTTPS: Cloudflare handles SSL
Advanced Configuration
Cache Control
Customize caching behavior:
// Cache scripts for different durations
if (url.pathname === '/analytics/script.js') {
cacheResponse.headers.set('Cache-Control', 'public, max-age=3600'); // 1 hour
} else if (url.pathname === '/analytics/replay.js') {
cacheResponse.headers.set('Cache-Control', 'public, max-age=7200'); // 2 hours
}Rate Limiting
Add basic rate limiting:
// Simple in-memory rate limiting (per edge location)
const rateLimitMap = new Map();
async function checkRateLimit(ip) {
const key = `ratelimit:${ip}`;
const current = rateLimitMap.get(key) || { count: 0, resetAt: Date.now() + 60000 };
if (Date.now() > current.resetAt) {
current.count = 0;
current.resetAt = Date.now() + 60000;
}
current.count++;
rateLimitMap.set(key, current);
return current.count > 100; // 100 requests per minute
}Logging and Analytics
Log proxy usage by wrapping proxyToRybbit and calling the wrapper from fetch:
async function proxyWithLogging(request, url) {
const startTime = Date.now();
const response = await proxyToRybbit(request, url);
// Shows up in Workers Logs / `wrangler tail`
console.log({
path: url.pathname,
status: response.status,
duration: Date.now() - startTime,
cached: response.headers.get('X-Cache-Status') === 'HIT',
});
return response;
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (!url.pathname.startsWith('/analytics/')) {
return fetch(request);
}
try {
return await proxyWithLogging(request, url);
} catch (error) {
console.error('Proxy error:', error);
return new Response('Analytics proxy error', { status: 500 });
}
},
};Cost Considerations
Cloudflare Workers pricing (as of 2024):
- Free tier: 100,000 requests/day
- Paid plan: $5/month for 10 million requests + $0.50 per million after
For typical traffic:
- 100,000 sessions/month ≈ 200,000-500,000 requests (well within free tier)
- Even with session replay: Usually under 1 million requests/month
Most sites stay within the free tier. Bandwidth is unlimited on all Cloudflare plans.
Troubleshooting
Worker not triggering
Problem: Requests bypass worker.
Solution:
- Verify route is configured:
yourdomain.com/analytics/* - Check zone matches your domain
- Ensure orange-cloud (proxy) is enabled on DNS record
Cache not working
Problem: X-Cache-Status always shows MISS.
Solution:
- Verify cache key includes important parts:
const cacheKey = new Request(rybbitUrl, request); - Ensure
Cache-Controlheaders are set - Check method is GET (POST requests aren't cached)
Incorrect geolocation
Problem: All visitors show Cloudflare IPs.
Solution:
Use CF-Connecting-IP header:
const clientIp = request.headers.get('CF-Connecting-IP');
proxyHeaders.set('X-Forwarded-For', clientIp);Also enable First-Party Proxy in your site settings so Rybbit trusts that header.
Deployment errors
Problem: Worker fails to deploy.
Solution:
- Check syntax in editor (no console.log in production)
- Verify all async functions use await
- Test locally with Wrangler:
npm install -g wrangler wrangler dev
Local Development
Use Wrangler for local testing:
# Install Wrangler
npm install -g wrangler
# Login to Cloudflare
wrangler login
# Create worker
wrangler init rybbit-proxy
# Dev server
wrangler dev
# Deploy
wrangler deployRelated Resources
- Tracking Script Documentation - Script configuration
- Generic Proxy Guide - Core concepts
- Cloudflare Workers Docs - Official docs
- Workers Cache API - Caching documentation