Blog
analytics

First-Party Analytics Proxy with Cloudflare Workers (PostHog)

How I set up a first-party proxy for PostHog analytics using Cloudflare Workers to capture 30-40% more data, while respecting GDPR and EU data residency.

Mladen Ruzicic
Mladen Ruzicic
5 min

Ad blockers block analytics. That’s their job. But it means I was missing 30-40% of my traffic data.

I set up a first-party proxy that routes PostHog through my own domain. Ad blockers don’t block first-party requests.

The problem

PostHog’s default setup loads from us.i.posthog.com. Ad blockers have this domain in their blocklists:

||posthog.com^
||i.posthog.com^
||us.i.posthog.com^

Users with ad blockers (30-40% of tech-savvy audiences) send no analytics. I had no idea which fonts they searched for or which pages they visited.

The solution

Route PostHog through a subdomain I control:

e.fontalternatives.com → eu.i.posthog.com

The e is short for “events”. It looks like a first-party endpoint. Ad blockers don’t block it because it’s not on their lists.

EU data residency

PostHog offers EU hosting at eu.i.posthog.com. Since FontAlternatives serves a global audience and I’m EU-based, I use the EU endpoint for GDPR compliance.

The proxy preserves this: requests go through my domain but ultimately hit PostHog’s EU servers.

The Worker

The proxy is a simple Cloudflare Worker:

export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    // Rewrite to PostHog EU endpoint
    const posthogUrl = new URL(url.pathname + url.search, 'https://eu.i.posthog.com');

    // Clone request with new URL
    const posthogRequest = new Request(posthogUrl.toString(), {
      method: request.method,
      headers: request.headers,
      body: request.body,
    });

    // Forward to PostHog
    const response = await fetch(posthogRequest);

    // Clone response with CORS headers
    const newHeaders = new Headers(response.headers);
    newHeaders.set('Access-Control-Allow-Origin', 'https://fontalternatives.com');
    newHeaders.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    newHeaders.set('Access-Control-Allow-Headers', 'Content-Type');

    return new Response(response.body, {
      status: response.status,
      headers: newHeaders,
    });
  },
};

It’s a transparent proxy. Receives request, forwards to PostHog, returns response.

Wrangler configuration

Separate config for the proxy worker:

{
  "name": "fontalternatives-posthog",
  "main": "src/workers/posthog-proxy.ts",
  "compatibility_date": "2024-01-01",
  "routes": [
    {
      "pattern": "e.fontalternatives.com/*",
      "custom_domain": true
    }
  ]
}

Deploy with:

wrangler deploy --config wrangler.posthog.jsonc

Client configuration

Update the PostHog client to use the proxy:

import posthog from 'posthog-js';

posthog.init('phc_your_project_key', {
  api_host: 'https://e.fontalternatives.com',
  ui_host: 'https://eu.posthog.com', // Dashboard still uses direct URL
});

The api_host is the proxy. The ui_host is where the PostHog dashboard lives (for session recordings, etc.).

Handling CORS

Browsers send preflight OPTIONS requests for cross-origin POST. The Worker handles these:

async fetch(request: Request): Promise<Response> {
  // Handle preflight
  if (request.method === 'OPTIONS') {
    return new Response(null, {
      headers: {
        'Access-Control-Allow-Origin': 'https://fontalternatives.com',
        'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type',
        'Access-Control-Max-Age': '86400',
      },
    });
  }

  // ... rest of proxy logic
}

The Access-Control-Max-Age caches preflight responses for 24 hours, reducing request overhead.

What gets proxied

PostHog sends several types of requests:

EndpointPurpose
/captureEvent tracking
/decideFeature flags
/engageUser properties
/batchBatched events
/s/Session recordings

The proxy forwards all of them transparently.

Privacy considerations

This proxy doesn’t change what data PostHog collects. It only changes the transport path.

I still:

  • Don’t collect PII without consent
  • Respect Do Not Track headers
  • Provide opt-out mechanisms
  • Comply with GDPR (EU data residency)

The proxy helps me understand aggregate behavior, not track individuals.

The impact

Before and after comparison over 30 days:

MetricBefore proxyAfter proxyChange
Page views12,40018,200+47%
Unique users4,1005,800+41%
Search events2,8004,100+46%

The “missing” traffic was real users with ad blockers. Now I see them.

Deployment

I deploy the proxy separately from the main site:

# Main site
npm run deploy

# PostHog proxy
npm run deploy:posthog

Different workers, different domains, independent deployment.

DNS setup

Cloudflare DNS for the proxy subdomain:

e.fontalternatives.com  CNAME  fontalternatives-posthog.ruzicic.workers.dev

The CNAME points to the Worker. Cloudflare handles SSL automatically.

Tradeoffs

What I gained:

  • 30-40% more analytics data
  • Complete picture of user behavior
  • Better font demand signals. This proxy powers the demand tracking system described in The Content Flywheel.

What I lost:

  • Extra Worker to maintain
  • Potential for proxy to go down (PostHog would too)
  • Slight latency increase (extra hop)

Ethical considerations: Some argue that bypassing ad blockers disrespects user choice. I disagree for analytics specifically:

  • Analytics don’t show ads
  • Analytics help me improve the site
  • Users blocking analytics often do so accidentally (browser extensions block everything)
  • I don’t track individuals, only aggregate patterns

For ad-supported sites showing actual ads, the ethics are different.

Monitoring

I monitor the proxy with PostHog itself (ironic, I know):

// In the Worker
const start = Date.now();
const response = await fetch(posthogRequest);
const duration = Date.now() - start;

// Log to PostHog (through production, not proxy)
if (duration > 1000) {
  console.log(`Slow PostHog proxy: ${duration}ms`);
}

If the proxy slows down or errors, I see it in PostHog’s server-side logs.

Code organization

The proxy lives in the main repo:

src/
  workers/
    posthog-proxy.ts    # The proxy Worker
wrangler.posthog.jsonc  # Proxy-specific config

Same codebase, different deployment target.

Alternative approaches

Other ways to proxy analytics:

Reverse proxy (nginx):

location /e/ {
  proxy_pass https://eu.i.posthog.com/;
}

Works but requires a server. I don’t have one.

Edge functions (Vercel/Netlify): Similar to Workers but platform-specific. I’m already on Cloudflare.

PostHog’s reverse proxy feature: PostHog offers guidance on this. My approach follows their recommendations.

The result

FontAlternatives now captures analytics from all users, not just those without ad blockers. The font demand signals are more accurate, and I can make better decisions about which fonts to add.

Total cost: $0 (Cloudflare Workers free tier).

Explore on FontAlternatives

#analytics#posthog#cloudflare-workers#privacy

More from the blog