Run server-side Google Tag Manager (sGTM) on the Cloudflare edge for $0β$5/month β with first-party cookies that survive Safari ITP, baked in.
Server-side Google Tag Manager is the modern, privacy-respecting way to collect analytics β but Google's reference deployment runs on Google Cloud App Engine / Cloud Run, which means:
- πΈ You pay for always-on instances. A "small" sGTM setup is typically 3+ App Engine instances, landing most teams at $40β$120+/month before a single extra visitor shows up. Scale it for real traffic and the bill climbs fast.
- π One region, added latency. Your tagging server lives in a single GCP region. A shopper in Tokyo hitting a server in
us-central1eats a round-trip they didn't need. - π§ It's infrastructure you now babysit β instances, scaling config, SSL, uptime.
EdgeGTM flips the model. It's a tiny Cloudflare Worker that acts as the first-party front door to your sGTM container and runs in 330+ cities on Cloudflare's edge network:
| Classic GCP sGTM | EdgeGTM on Cloudflare | |
|---|---|---|
| Monthly cost | ~$40β$120+ (always-on instances) | $0 (Free: 100k req/day) β $5 (Paid: 10M req/mo) |
| Latency | Single region | Anycast edge, 330+ cities |
| Cold starts | Possible | None (V8 isolates, ~0ms) |
| Scaling | You configure instances | Automatic, to millions of req |
| ITP cookie fix | DIY | Built in |
| Ops burden | Instances, SSL, scaling | One wrangler deploy |
π‘ You can even point EdgeGTM at a free sGTM preview/tagging server or your own backend that speaks the sGTM protocol, and let the edge handle delivery, TLS, and cookie hardening.
Safari's Intelligent Tracking Prevention (ITP) caps any cookie written by client-side JavaScript (document.cookie) to 7 days β and as little as 24 hours for visitors arriving with tracking parameters. Firefox and Brave do similar things. The damage:
- π Returning customers are miscounted as brand-new visitors.
- π Attribution windows collapse, so your ad spend looks worse than it is.
- π§© Cross-session journeys (browse today β buy in 10 days) break.
ITP leaves exactly one door open: cookies set by a real first-party server via the Set-Cookie HTTP header are honored for their full lifetime. EdgeGTM runs on your own subdomain (e.g. sgtm.example.com) and intercepts every Set-Cookie coming back from the container, rewriting it into a hardened first-party cookie:
Set-Cookie: FPID=xxxx; Domain=.example.com; Path=/; Max-Age=34560000;
Expires=...; SameSite=Lax; Secure; HttpOnly
- πͺ
Domain=.example.comβ genuine first party, shared across subdomains - β³
Max-Ageextended to 400 days β no more 7-day amnesia - π
SameSite=Lax; Secure; HttpOnlyβ safe defaults, invisible to XSS/document.cookie
The identity cookies it extends include FPID, FPLC, _ga, _ga_*, _gcl*, and friends.
flowchart LR
subgraph Browser["π§ Visitor's Browser"]
A["gtag.js / GTM<br/>fires /g/collect"]
end
subgraph Edge["β‘ Cloudflare Edge (your subdomain)"]
W["EdgeGTM Worker<br/>sgtm.example.com"]
end
subgraph Origin["βοΈ Server-side GTM Container"]
G["sGTM tagging server<br/>(Cloud Run / preview / backend)"]
end
subgraph Vendors["π Destinations"]
V1["GA4"]
V2["Google Ads"]
V3["Meta / TikTok / ..."]
end
A -- "1 first-party request<br/>(cookies attached)" --> W
W -- "2 sanitized proxy<br/>+ X-Forwarded-For<br/>+ preview header" --> G
G -- "3 response<br/>+ Set-Cookie (3rd-party-ish)" --> W
W -- "4 rewritten Set-Cookie<br/>(1st-party, 400d, Lax/Secure/HttpOnly)" --> A
G -.-> V1
G -.-> V2
G -.-> V3
- The browser fires its measurement request to your subdomain β a first-party call.
- EdgeGTM strips hop-by-hop and Cloudflare-internal headers, forwards the original client IP and the GTM preview header, and proxies to your container.
- The container processes the hit and returns its
Set-Cookieheaders. - EdgeGTM rewrites those cookies into durable first-party cookies and streams the response back to the browser.
# 1. Clone & install
git clone https://github.com/NagaYu/edge-gtm.git
cd edge-gtm
npm install
# 2. Point it at your sGTM container + your domain
# Edit wrangler.toml:
# GTM_CONTAINER_URL = "https://your-sgtm-container.run.app"
# COOKIE_DOMAIN = ".yourdomain.com"
# 3. Ship it to the edge
npm run deployThat's it. Your Worker is live at https://edge-gtm.<your-subdomain>.workers.dev.
Map a subdomain of your site to the Worker so cookies are first-party. In wrangler.toml:
[[routes]]
pattern = "sgtm.yourdomain.com/*"
zone_name = "yourdomain.com"Then set your GTM/gtag server_container_url (or transport_url) to https://sgtm.yourdomain.com. Redeploy with npm run deploy.
All settings live in wrangler.toml under [vars] (and [env.production.vars]). Secrets should use wrangler secret put <NAME> instead.
| Variable | Required | Default | Description |
|---|---|---|---|
GTM_CONTAINER_URL |
β | β | Upstream sGTM origin (scheme + host, no trailing slash/path). |
COOKIE_DOMAIN |
β¬ | (none) | Registrable domain for first-party cookies, e.g. .example.com. |
COOKIE_MAX_AGE_DAYS |
β¬ | 400 |
Lifetime applied to identity cookies (FPID/FPLC/_ga/β¦). |
GTM_PREVIEW_HEADER |
β¬ | (empty) | Value for X-Gtm-Server-Preview to keep Preview/Debug working. |
EXTRA_PROXY_PATHS |
β¬ | (empty) | Comma-separated extra path prefixes to proxy. |
FORWARD_CLIENT_IP |
β¬ | true |
Forward the real client IP via X-Forwarded-For for geo/IP enrichment. |
LOG_LEVEL |
β¬ | info |
debug | info | warn | error. |
Out of the box EdgeGTM proxies the standard sGTM endpoints, including:
/g/collect /j/collect /r/collect /collect
/gtag/js /gtag/destination /gtm.js
/td /ccm/collect /pagead/ /dc/ /healthz
Need more? Add them to EXTRA_PROXY_PATHS. Anything not on the allowlist returns 404, so the Worker can never be abused as an open proxy.
npm run dev # wrangler dev β local edge runtime at http://localhost:8787
npm test # vitest β cookie rewriting + proxy behavior (39 tests)
npm run test:watch # vitest in watch mode
npm run typecheck # strict TypeScript, no emit
npm run tail # live-stream structured production logsHit a local endpoint to sanity-check the proxy:
curl -i "http://localhost:8787/g/collect?v=2&tid=G-XXXX&en=page_view"You should see the upstream status echoed, an x-edge-gtm: 1 header, and any cookies rewritten to SameSite=Lax; Secure; HttpOnly.
A built-in liveness probe is always available (never proxied):
curl -s http://localhost:8787/__edgegtm/health
# {"status":"ok","upstream":"your-sgtm-container.run.app"}edge-gtm/
βββ src/
β βββ index.ts # Entry: config parse, path allowlist, proxy, error handling
β βββ cookie.ts # Set-Cookie parsing + first-party / ITP cookie rewriting
β βββ types.ts # Env, RuntimeConfig, header denylists, endpoint allowlist
βββ test/
β βββ cookie.test.ts # Cookie parsing/splitting/rewriting (22 tests)
β βββ proxy.test.ts # Config parsing + full fetch-handler proxy (17 tests)
βββ .github/workflows/
β βββ ci.yml # Typecheck β test β dry-run build on push/PR
βββ wrangler.toml # Worker name, compatibility, env vars, routes
βββ tsconfig.json # Strict TS config targeting the Workers runtime
βββ vitest.config.ts # Test runner config
βββ .dev.vars.example # Local secrets/vars template (copy to .dev.vars)
βββ package.json # Dev deps + dev/test/build/deploy scripts
βββ LICENSE # MIT
βββ README.md
Design principles
- π Web Standards only β
Request/Response/Headers/fetch. No Node-isms in the hot path. - π‘οΈ Defensive by default β strict header denylists (hop-by-hop + Cloudflare-internal stripped), explicit method gate, allowlisted endpoints, and upstream failures mapped to clean
502s instead of leaking stack traces. - π Type-safe β
strictTypeScript withnoUncheckedIndexedAccessandexactOptionalPropertyTypes; noany. - πͺ Spec-correct cookies β a comma-safe
Set-Cookiesplitter (soExpires=Wed, 09 Jun β¦never breaks),getSetCookie()when available, and__Host-prefix handling.
Does this replace my sGTM container? No β it's the first-party edge front door to it. Your container still does the tagging; EdgeGTM makes delivery fast, cheap, and ITP-resistant.
Will it work with GA4 / Google Ads / Meta CAPI tags? Yes. EdgeGTM is transport-layer; it forwards whatever your container speaks and only rewrites cookies on the way back.
Is forwarding the client IP a privacy problem?
Set FORWARD_CLIENT_IP = "false" to strip it. By default it's forwarded so server-side geo/IP enrichment stays accurate β your call.
Can I keep using GTM Preview/Debug mode?
Yes β set GTM_PREVIEW_HEADER to the value from your container's Preview screen.
Issues and PRs are very welcome. Please run npm run typecheck before opening a PR.
MIT Β© EdgeGTM contributors.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Built for marketers and engineers who refuse to pay $100/month to count a click. β‘