Skip to content
$_ setuptracking
Recipes

Self-Hosted Server-Side Tracking 2026: SSGTM, Cloudflare Worker, Nginx Proxy Compared

13 min read
Dark self-hosted server room with cyan glow on rack columns

Server-side tracking sounded like a luxury three years ago. Now it’s table stakes: ad blockers eat 30-40% of client-side hits in technical audiences, browser ITP truncates first-party cookies to 7 days, and the iOS 17.4 Private Relay quietly drops referrer headers on Safari. If you’re still firing everything from the page, you’re flying with one engine.

This guide walks through three production patterns for moving your tracking server-side without handing it to Google Cloud’s sGTM container or paying SaaS like Stape and Addingwell. All three are self-hosted, run for under €15/month, and we’ve deployed each of them.

What “server-side tracking” actually means

The phrase has been diluted. Three different things get called “server-side tracking” in marketing copy:

  1. Server-side tagging — the browser sends one beacon to your endpoint, and your server fans out to vendor APIs (Plausible, Meta CAPI, GA4 Measurement Protocol, etc). The browser never talks to vendor domains.
  2. Reverse proxy — the browser sends to yourdomain.com/p.js, your nginx/caddy/CF Worker rewrites the request and forwards to plausible.io/api/event. The vendor still receives the hit, just from your IP instead of the user’s.
  3. First-party server log analytics — your web server’s access log gets piped into a tool like GoAccess or Plausible’s log import. No JavaScript at all.

They’re different beasts with different trade-offs. We cover the first two (server-side tagging and reverse proxy) plus a third hybrid pattern. Pattern 3 is the most popular — sGTM containerised on Hetzner — and the most expensive in TCO terms.

Why move server-side at all

Five concrete reasons, in rough order of impact:

  • Ad blocker bypass. Most blocklists (EasyList, uBlock Origin’s defaults) block known vendor domains. A first-party endpoint on your own domain bypasses 90%+ of generic blockers. The dedicated anti-tracking lists still catch you eventually, but the floor lifts by 25-40%.
  • First-party cookie longevity. Safari’s ITP truncates cookies set via document.cookie from JavaScript to 7 days. Cookies set via Set-Cookie HTTP headers from your server are not truncated. Server-side tagging is the only way to get persistent first-party identifiers in Safari.
  • Privacy compliance. Third-party domains never see the user’s IP, browser headers, or referrer unless you choose to forward them. Strips most of what makes vendor scripts “third-party data processors” under GDPR Art 28.
  • Performance. One beacon out vs. five vendor scripts loaded. Less main-thread blocking, lower TBT, fewer CSP exceptions.
  • Reliability. When a vendor’s CDN has an incident, your tracking keeps queuing on your server. Retries become possible. Failure modes become legible.

If even two of those apply to you, the engineering cost is worth it.

Three patterns compared

Pattern Cost/month Latency added Maintenance Best for
SSGTM container on Hetzner (Pattern 1) €8-16 ~30-80ms Moderate — Docker, Google’s container updates Teams already using GTM client-side; many vendors to fan out to
Cloudflare Worker (Pattern 2) €0-5 ~10-25ms (edge) Low — just JS, no infra Plausible/Matomo/Umami stacks with 1-3 vendors
Nginx reverse proxy (Pattern 3) €0 (on existing infra) ~5-15ms Very low — one config block Single vendor (e.g. Plausible-only), ad blocker bypass focus

Pattern 1: SSGTM container on Hetzner CX22

Google’s server-side Tag Manager is a Docker container you can self-host. It receives hits on a “server container” endpoint, runs vendor templates (GA4, Meta CAPI, Google Ads, etc), and fans out. The architecture is identical to Google Cloud’s managed offering — you save the ~€85/month App Engine bill.

Spec: Hetzner CX22 (2 vCPU, 4 GB RAM, €4.51/mo), Ubuntu 24.04 LTS, Docker. Add a domain (we’ll use sgtm.example.com) with Cloudflare in front for TLS and DDoS shield.

The two binaries Google ships are gcr.io/cloud-tagging-10302018/gtm-cloud-image:stable (the main container) and a “preview server” container for testing tag changes. Production needs both; the preview container is split out for security — you don’t want preview-mode requests hitting your real container.

# /opt/sgtm/docker-compose.yml
version: '3.8'
services:
  primary:
    image: gcr.io/cloud-tagging-10302018/gtm-cloud-image:stable
    restart: always
    environment:
      CONTAINER_CONFIG: "<your container config string from Google Tag Manager UI>"
      PREVIEW_SERVER_URL: "https://preview.sgtm.example.com"
    ports: [ "8080:8080" ]
  preview:
    image: gcr.io/cloud-tagging-10302018/gtm-cloud-image:stable
    restart: always
    environment:
      CONTAINER_CONFIG: "<same config string>"
      RUN_AS_PREVIEW_SERVER: "true"
    ports: [ "8081:8080" ]

The CONTAINER_CONFIG string comes from your GTM server container settings (Admin → Container Settings → manual setup → copy the config). It’s base64-encoded JSON containing your container ID, region preference, and routing config.

Read:  Cookieless Tracking 2026: 5 Self-Hosted Setups (No Consent Banner)

Front this with Caddy or nginx for TLS termination. Caddy is simpler:

# /etc/caddy/Caddyfile
sgtm.example.com {
  reverse_proxy localhost:8080
  encode gzip
}
preview.sgtm.example.com {
  reverse_proxy localhost:8081
  encode gzip
}

Real-world cost on Hetzner CX22 with ~500K events/month: €4.51 server + €0 Caddy + €0 Cloudflare (free tier handles the TLS + caching). For 5M events/month bump to CX32 (€8.21). For 50M, scale horizontally with a small NLB.

The container documentation is in Google’s own docs and the preview architecture is detailed at tag-platform/server-side/preview-mode. The catch they don’t put on the marketing page: Google’s free-of-charge model only covers self-hosted; if you use App Engine the bill compounds with traffic.

When SSGTM is the right pick

  • You already have a GTM client container with 5+ tags — the upgrade path is one button (publish to server container)
  • You need to fan out to multiple Google products (Ads, GA4, FCAPI, GMP) plus Meta and LinkedIn — SSGTM has battle-tested templates for all of them
  • Your team has GTM experience — you don’t want to retrain on a different abstraction

When to skip SSGTM

  • You don’t have GTM today and only need 1-2 vendors — the container overhead isn’t worth it
  • You’re committed to no-Google — SSGTM is still a Google product; you depend on Google publishing the container image
  • You need sub-15ms latency at the edge — Pattern 2 (CF Worker) is faster, period

Pattern 2: Cloudflare Worker as a custom dispatcher

The most flexible of the three. Write your own dispatcher in <200 lines of JavaScript, run it at Cloudflare’s edge in 300+ POPs, pay nothing if you’re under 100K requests/day on the free plan. For self-hosted analytics stacks (Plausible, Matomo, Umami), this pattern beats SSGTM on every dimension except vendor template count.

The flow: browser sends a single beacon to track.example.com/e. The Worker inspects the payload, enriches with request headers (IP from CF-Connecting-IP, UA, country from CF-IPCountry), and dispatches in parallel to Plausible, Meta CAPI, and Google Ads CAPI. Returns 204 in <25ms.

// worker.js — deploy with `wrangler deploy`
export default {
  async fetch(request, env) {
    if (request.method !== 'POST') return new Response('', { status: 405 });

    let payload;
    try {
      payload = await request.json();
    } catch {
      return new Response('bad json', { status: 400 });
    }

    const ip = request.headers.get('CF-Connecting-IP');
    const ua = request.headers.get('User-Agent') || '';
    const country = request.headers.get('CF-IPCountry') || '';

    const tasks = [];

    // Always fire Plausible (cookieless, consent-free)
    tasks.push(forwardPlausible(payload, ip, ua, env));

    // Conditional fan-out
    if (payload.consent?.advertising) {
      if (payload.event === 'purchase') {
        tasks.push(forwardGoogleAdsConversion(payload, ip, ua, env));
        tasks.push(forwardMetaCAPI(payload, ip, ua, env));
      }
    }

    // Don't await — respond to the browser immediately
    // Use waitUntil so the worker stays alive for the fan-out
    if (request.cf?.colo) {
      // Edge runtime: use ctx.waitUntil if available
    }
    await Promise.all(tasks);

    return new Response('', {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': payload.origin || '*',
        'Access-Control-Allow-Methods': 'POST, OPTIONS'
      }
    });
  }
};

async function forwardPlausible(payload, ip, ua, env) {
  return fetch('https://plausible.example.com/api/event', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'User-Agent': ua,
      'X-Forwarded-For': ip
    },
    body: JSON.stringify({
      name: payload.event,
      url: payload.url,
      domain: 'example.com',
      props: payload.props
    })
  });
}

async function forwardGoogleAdsConversion(payload, ip, ua, env) {
  return fetch(`https://googleads.googleapis.com/v17/customers/${env.GADS_CUSTOMER_ID}/conversions:upload`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${env.GADS_TOKEN}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      conversions: [{
        gclid: payload.gclid,
        conversionAction: env.GADS_CONVERSION_ACTION,
        conversionDateTime: new Date(payload.timestamp).toISOString(),
        conversionValue: payload.value,
        currencyCode: payload.currency || 'EUR'
      }]
    })
  });
}

Two implementation notes that bite people in production:

  • Plausible requires X-Forwarded-For when called from a server. Without it, every event gets attributed to your Worker’s IP and Plausible’s anti-spam quickly flags you. The header is documented in Plausible’s Events API docs but the explanation is buried — it’s required because Plausible uses the IP for SipHash session identification, not for storage.
  • Meta CAPI deduplication. If you’re sending both client-side Pixel and server-side CAPI for the same event, generate an event_id on the client, pass it through the Worker, and include it in both calls. Otherwise Meta double-counts and your ROAS reporting goes weird.
Read:  UTM Parameters Done Right: Campaign Tracking Without the Mess

Cost: Cloudflare Workers free tier is 100K req/day. Paid plan is $5/mo for 10M req. For most sites in the 100K-1M monthly visitor range, the free tier is enough. Even if you scale to 10M monthly events, you’re at $5/mo for the dispatching layer plus your existing analytics infrastructure.

Pattern 3: Nginx reverse proxy for single-vendor setups

The simplest pattern. You’re not running multiple vendors and don’t need server-side enrichment — you just want the browser to talk to track.example.com instead of plausible.io. Add a 30-line nginx config block and you’re done.

This is Plausible’s recommended setup for ad blocker bypass. The Plausible docs cover the basics; we add the bits that matter for production: TLS, real-IP forwarding, error logging, and a CSP-friendly path layout.

# /etc/nginx/sites-available/example.com.conf
server {
    listen 443 ssl http2;
    server_name example.com www.example.com;
    # ... your existing TLS, root, etc

    # Plausible proxy: /js/script.js serves the tracker
    location = /js/script.js {
        proxy_pass https://plausible.example.com/js/script.js;
        proxy_set_header Host plausible.example.com;
        proxy_ssl_server_name on;

        # Cache the script aggressively — it changes rarely
        proxy_cache_valid 200 6h;
        add_header Cache-Control "public, max-age=21600, immutable";
    }

    # The hit endpoint
    location = /api/event {
        proxy_pass https://plausible.example.com/api/event;
        proxy_set_header Host plausible.example.com;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_ssl_server_name on;

        # Don't cache hits
        proxy_no_cache 1;
        proxy_cache_bypass 1;
    }
}

Then in your HTML, swap the standard Plausible snippet for the proxied one:

<!-- Standard -->
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>

<!-- Proxied -->
<script defer data-domain="example.com" data-api="https://example.com/api/event" src="https://example.com/js/script.js"></script>

The same pattern works for Matomo, Umami, and PostHog with a one-line endpoint change. For Matomo, replace matomo.js and matomo.php. For Umami, replace script.js and api/send. For PostHog, replace the static and decide endpoints.

What this pattern doesn’t do

Reverse proxy is the simplest pattern, and that’s because it’s the dumbest one. Specifically: there’s no consent gating, no enrichment, no fan-out to multiple vendors, no anti-fraud filtering, no retry on vendor downtime. The user’s browser still talks (indirectly) to the vendor — just via your domain. If the vendor goes down, your tracking is down.

For most self-hosted analytics setups, that’s fine. Plausible.io has a 99.99% uptime SLA and the Plausible script is a single tiny JS file. If you’re self-hosting Plausible too (recommended — see our Plausible on Hetzner guide), the dependency chain is even shorter.

Cost over 3 years — real numbers

Setup Year 1 Year 2 Year 3 3-yr total
Google Cloud sGTM (App Engine) €1020 €1140 €1260 €3420
Stape Cloud (mid plan) €360 €420 €480 €1260
Pattern 1 (SSGTM on Hetzner CX22) €54 €98 €98 €250
Pattern 2 (Cloudflare Worker, paid plan) €60 €60 €60 €180
Pattern 3 (nginx on existing server) €0 €0 €0 €0

Numbers assume ~500K events/month year 1, growing to 1M by year 3. Hetzner pricing includes upgrade from CX22 to CX32 in year 2. App Engine and Stape both scale on event volume.

The latency question

Server-side tracking always adds latency — how much depends on where your dispatcher runs relative to the user. Three rough ballparks measured on real Hetzner FSN1 + CF Workers (London POP) deployments:

  • Cloudflare Worker (edge): 8-15ms p50, 25-40ms p95 (user → CF edge → your origin)
  • SSGTM on Hetzner FSN1: 30-80ms p50 from EU users, 200-300ms p95 from US users
  • Nginx reverse proxy on your existing origin: 5-15ms added (just one extra hop on your own server)

For an analytics beacon firing in sendBeacon(), this doesn’t matter — the call is async and doesn’t block the page. For a Google Ads CAPI call that needs to fire before redirecting to checkout, every millisecond matters. Pattern 2 (Workers) wins on that axis.

What you lose going server-side

Two things vendors get from client-side that they don’t get server-side, and you need to compensate:

  1. Browser fingerprinting for fraud detection. Meta Pixel and Google Ads use device fingerprints (canvas, WebGL, audio) for fraud signals. Server-side hits don’t carry those. For most B2B and SaaS conversion tracking, this doesn’t matter. For high-value e-commerce with fraud risk, you may need both pixel and CAPI in parallel (deduped via event_id).
  2. Cross-domain attribution via third-party cookies. Some attribution windows depend on cookies set by vendor domains. With server-side, those cookies move to your domain and the attribution model shifts. Usually for the better — first-party cookies last longer — but vendor dashboards may show “anonymous” buckets where they used to show “social-organic”.
Read:  Matomo Ecommerce Tracking 2026: WooCommerce + Shopify Self-Hosted

When NOT to go server-side

  • You only have GA4 and one Meta pixel. GA4’s client-side gtag.js is small, fast, and has 95% of what server-side gives you. The two pixels combined add ~30KB — not worth the engineering effort.
  • You don’t have someone on-call for your tracking infrastructure. Server-side tagging makes tracking part of your application stack. When the Worker fails or the SSGTM container OOMs, your tracking is silently broken until someone notices the data gap. Have a Sentry/Healthchecks-style monitor in place before deploying.
  • You’re already on a SaaS analytics tool that doesn’t expose a server-side API. Some legacy analytics vendors only have client-side SDKs. Server-side wrapping them is a hack that breaks on every vendor update.

FAQ

Can I run all three patterns at the same time?

Yes — and it’s a sensible production setup. Pattern 3 (nginx reverse proxy) handles your Plausible script delivery (ad blocker bypass). Pattern 2 (Worker) handles conversion forwarding to ad platforms (latency-sensitive). Pattern 1 (SSGTM) handles complex multi-tag scenarios where Google’s vendor templates save engineering time. The three are layers, not alternatives.

Does server-side tracking break my GDPR position?

Generally no — it strengthens it. With server-side, third-party domains never see the user directly. Your server becomes the data controller for the initial hit, and the vendor becomes a downstream processor (Art 28). This is cleaner than client-side where the user’s browser is making direct calls to vendor domains. You still need a DPA with each vendor and you still need to respect consent for ad-personalization purposes.

What’s the difference between server-side tagging and server-side tracking?

Server-side tagging is the broader concept: any architecture where a server-side component sits between the browser and vendor APIs. Server-side tracking is more specific: the beacon-to-server-to-vendors flow. They’re often used interchangeably but tagging implies the orchestration layer (SSGTM, Stape, custom dispatcher) and tracking implies the data flow.

Can I use sGTM without Google’s container image?

Not really. The “GTM” in sGTM is Google’s tag templates — that’s the whole product. If you want a non-Google equivalent, look at Pattern 2 (custom Worker), or use Snowplow’s collector + enricher pipeline for a more open-source-native approach. The trade-off is you write your own vendor integrations instead of using Google’s templates.

How does Cloudflare Zaraz fit in?

Zaraz is Cloudflare’s managed alternative to SSGTM. It runs in the Worker runtime (so very fast) but is a black box — you configure tags through Zaraz’s UI rather than writing Worker code. For teams who want the SSGTM model without operating Docker, Zaraz is a strong option. The catch: vendor-template count is much smaller than SSGTM’s, and your dispatching logic lives in Cloudflare’s UI rather than your repo. Compare to Pattern 2 if you prefer code-in-Git.

Do I need Consent Mode v2 with server-side tracking?

If you’re firing any Google product (Ads, GA4, Tag Manager) through your server-side flow, yes — the consent state needs to travel with the hit. See our guide on Consent Mode v2 for self-hosted stacks for the patterns that combine consent gating with server-side dispatch.

Pick a starting point

  • You have GTM client-side today and need to move 5+ tags server-side: Pattern 1 (SSGTM container on Hetzner). 1-day setup, familiar abstraction, €4.51/mo.
  • You have Plausible/Matomo/Umami and need to add ad-platform conversion forwarding: Pattern 2 (Cloudflare Worker). 200 lines of code, edge latency, free tier covers most sites.
  • You have Plausible only and want to bypass ad blockers: Pattern 3 (nginx reverse proxy). 30-line config block, zero new infrastructure.

For the Plausible install that pairs with patterns 2 and 3, see Plausible on Hetzner. For the Matomo install, see Matomo on Hetzner. For the broader tag manager landscape, see our roundup of self-hosted tag manager alternatives.


Found this useful?

Try the Stack Picker to get a personal recommendation, or browse the install recipe library.