GTM 18 min read

Google Tag Gateway on Shared Hosting: PHP Proxy Implementation Guide

Most Google Tag Gateway guides assume Cloudflare, Akamai, or a VPS. This step-by-step tutorial implements a working Tag Gateway using two PHP files and .htaccess — no cloud infrastructure required. Works on any Apache shared hosting including IONOS, Bluehost, SiteGround, and cPanel hosts.

A
Aumlytics Team
·

Google’s Tag Gateway documentation and every major tutorial assumes you have access to Cloudflare Workers, Akamai EdgeGrid, or at minimum a VPS with nginx. If you’re on shared hosting — IONOS, Bluehost, SiteGround, DreamHost, or any cPanel-based host — those guides stop being useful at the infrastructure step.

This guide implements a fully functional Google Tag Gateway using two PHP reverse proxy scripts and an .htaccess file. No cloud subscription. No server access. No Node.js runtime. If your host has PHP (they all do) and cURL enabled (almost all do), this works.

I implemented this on an IONOS shared hosting account running an Astro static site. The approach is applicable to any Apache-based shared host.


What We’re Building

The Google Tag Gateway proxies two types of requests through your domain:

  1. The GTM/gtag.js loader — your site loads https://yourdomain.com/gtag/js?id=G-XXXXXX instead of https://www.googletagmanager.com/gtag/js?id=G-XXXXXX
  2. GA4 measurement data — events send to https://yourdomain.com/g/collect instead of https://www.google-analytics.com/g/collect

From the browser, all traffic stays on your domain. Ad blockers target known Google domains — your domain isn’t on any list.

Architecture on shared hosting:

Browser → yourdomain.com/gtag/js → gtag-proxy.php → googletagmanager.com
Browser → yourdomain.com/g/collect → collect-proxy.php → google-analytics.com

Apache mod_rewrite intercepts the incoming requests before they hit the filesystem and routes them to the PHP scripts. The PHP scripts use cURL to forward requests upstream and return the response.


Prerequisites

  • Apache shared hosting (IONOS, Bluehost, SiteGround, DreamHost, cPanel hosts)
  • PHP 7.4+ with cURL extension (check via phpinfo() — look for curl section showing enabled)
  • SFTP or cPanel File Manager access to upload files
  • A GTM container with GA4 Configuration tag already working normally
  • HTTPS on your domain (required — Google Tag Gateway only works over TLS)

To verify cURL is available, create a temporary phpinfo.php file in your webroot containing <?php phpinfo(); ?>, visit it, search for “curl” in the output. Delete the file after checking.


Step 1: Create the GTM/gtag.js Loader Proxy

Create a file named gtag-proxy.php in your webroot (the public-facing directory — commonly public_html/, www/, or the root your domain resolves to).

<?php
/**
 * Google Tag Gateway — GTM/gtag.js Loader Proxy
 * Serves the GTM container script from your own domain, bypassing ad blockers
 * that block requests to googletagmanager.com
 *
 * Proxied paths:
 *   /gtag/js?id=G-XXXXXXXX  → https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX
 *   /gtm.js?id=GTM-XXXXXXX  → https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX
 */

// Security: Only serve GET requests
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
    http_response_code(405);
    exit('Method Not Allowed');
}

// Determine the target path from the rewritten URL
// .htaccess passes ?_gtm_path=gtag/js or ?_gtm_path=gtm.js
$path = $_GET['_gtm_path'] ?? 'gtag/js';
$path = ltrim($path, '/');

// Whitelist allowed proxy paths (security: only proxy known Google tag paths)
$allowed_paths = ['gtag/js', 'gtm.js', 'gtag/destination'];
if (!in_array($path, $allowed_paths)) {
    http_response_code(403);
    exit('Forbidden');
}

// Strip our internal routing param, keep all other query params
unset($_GET['_gtm_path']);
$queryString = http_build_query($_GET);

// Build target URL
$targetUrl = 'https://www.googletagmanager.com/' . $path;
if (!empty($queryString)) {
    $targetUrl .= '?' . $queryString;
}

// Fetch from Google using cURL
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => $targetUrl,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_MAXREDIRS      => 3,
    CURLOPT_TIMEOUT        => 10,
    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_USERAGENT      => 'GoogleTagGatewayProxy/1.0',
    CURLOPT_HTTPHEADER     => [
        'Accept: */*',
        'Accept-Encoding: gzip, deflate, br',
    ],
    CURLOPT_ENCODING       => '', // Auto-decode gzip
    CURLOPT_HEADER         => true, // Include headers in response
]);

$raw = curl_exec($ch);
$info = curl_getinfo($ch);
$errno = curl_errno($ch);
curl_close($ch);

// Handle cURL errors
if ($errno || $raw === false) {
    http_response_code(502);
    error_log("GTM proxy cURL error {$errno} fetching: {$targetUrl}");
    exit('Bad Gateway');
}

// Separate headers from body
$headerSize = $info['header_size'];
$body = substr($raw, $headerSize);

// Set appropriate response headers
http_response_code($info['http_code']);
header('Content-Type: text/javascript; charset=UTF-8');

// Cache GTM loader script aggressively (it changes infrequently)
header('Cache-Control: public, max-age=3600, s-maxage=3600');
header('X-Proxied-By: TagGateway/1.0');

// Output the script
echo $body;

Key design decisions:

  • Path whitelist — only proxies gtag/js, gtm.js, and gtag/destination. Any other path returns 403. This prevents the script from being abused as an open proxy.
  • Internal _gtm_path param — the .htaccess injects this param to tell the script which upstream path to request. It’s stripped before the upstream request.
  • CURLOPT_HEADER => true + header size calculation — separates response headers from body, so we only echo the script content (not upstream headers).
  • 1-hour cache — GTM container scripts update infrequently; caching reduces latency and server load.

Step 2: Create the GA4 Measurement Proxy

Create a second file named collect-proxy.php in the same webroot directory.

<?php
/**
 * Google Tag Gateway — GA4 Measurement Protocol Proxy
 * Forwards GA4 event data from your domain to Google Analytics,
 * bypassing ad blockers that block google-analytics.com
 *
 * Handles:
 *   POST/GET /g/collect  → https://www.google-analytics.com/g/collect
 *   POST/GET /g/collect/ → same
 */

// Allow GET and POST only
$method = $_SERVER['REQUEST_METHOD'];
if (!in_array($method, ['GET', 'POST'])) {
    http_response_code(405);
    exit('Method Not Allowed');
}

// Target: GA4 measurement endpoint
$targetUrl = 'https://www.google-analytics.com/g/collect';

// Forward the original query string (contains measurement_id, client_id, etc.)
$queryString = $_SERVER['QUERY_STRING'] ?? '';
if (!empty($queryString)) {
    $targetUrl .= '?' . $queryString;
}

// Get the POST body (GA4 event data)
$body = '';
if ($method === 'POST') {
    $body = file_get_contents('php://input');
}

// Real client IP — forward to Google so geo/IP data is preserved
$clientIp = $_SERVER['HTTP_X_FORWARDED_FOR']
    ?? $_SERVER['HTTP_X_REAL_IP']
    ?? $_SERVER['REMOTE_ADDR']
    ?? '';

// Forward a safe subset of request headers
$forwardHeaders = [
    'Content-Type: application/x-www-form-urlencoded',
    'X-Forwarded-For: ' . $clientIp,
];

// Forward User-Agent so device detection works correctly in GA4
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
    $forwardHeaders[] = 'User-Agent: ' . $_SERVER['HTTP_USER_AGENT'];
}

// Send to Google Analytics
$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => $targetUrl,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => false,
    CURLOPT_TIMEOUT        => 8,
    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_HTTPHEADER     => $forwardHeaders,
    CURLOPT_HEADER         => false,
]);

if ($method === 'POST') {
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}

$response = curl_exec($ch);
$info = curl_getinfo($ch);
$errno = curl_errno($ch);
curl_close($ch);

// Handle cURL errors silently — never block the user's page for analytics
if ($errno || $response === false) {
    error_log("GA4 collect proxy cURL error {$errno} for URL: {$targetUrl}");
    // Return 204 anyway — analytics failures should be invisible to users
    http_response_code(204);
    exit;
}

// GA4 normally returns 204 No Content — mirror that
http_response_code(204);

// No caching for measurement data
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Access-Control-Allow-Origin: *');

Key design decisions:

  • Forwards X-Forwarded-For — without this, all GA4 events appear to come from your server’s IP. Forwarding the real client IP preserves geo data, device data, and IP-based deduplication in GA4.
  • Forwards User-Agent — GA4 uses the User-Agent for device categorisation (mobile/desktop/tablet). Without forwarding it, all traffic appears as the server’s agent string.
  • Silent failure — returns 204 even on cURL errors. Analytics proxy failures should never cause errors on your users’ pages.
  • No response body — GA4’s actual endpoint returns 204 No Content. We mirror that exactly.

Step 3: Configure Apache mod_rewrite

This is the routing layer. The .htaccess file must intercept requests for /gtag/*, /gtm.js, and /g/collect before any other rules run, and route them to the PHP scripts.

Critical rule: The proxy rules must have no RewriteCond guards — they must fire unconditionally. If you add RewriteCond %{REQUEST_FILENAME} !-f before the proxy rules (which is common for static site setups), Apache will check whether /gtag/js exists as a file, it won’t, the condition passes, but the proxy rule below it might not handle it correctly depending on your rule order. Keep proxy rules clean and first.

Add the following to your .htaccess in the webroot. If you already have a .htaccess, merge carefully — the proxy rules must come before any static site clean-URL rules:

Options -Indexes
DirectoryIndex index.html index.php

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteBase /

    # ── Google Tag Gateway Proxy ─────────────────────────────────
    # Neutral paths — avoids path-pattern ad blocker lists
    # Routes BEFORE clean-URL rules (no file-exist conditions)

    # GTM loader  →  gtag-proxy.php
    RewriteRule ^a/js$ /gtag-proxy.php?_gtm_path=tmj.js [L,QSA]

    # gtag/js direct loader  →  gtag-proxy.php
    RewriteRule ^a/gtag/(.+)$ /gtag-proxy.php?_gtm_path=gtag/$1 [L,QSA]

    # GA4 measurement endpoint  →  collect-proxy.php
    RewriteRule ^a/g/collect/?$ /collect-proxy.php [L,QSA]

    # ── Your existing clean-URL rules below this line ─────────────
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule ^(.+)$ /$1.html [L,QSA]

</IfModule>

Why /a/js instead of /gtm.js? See the Path Obfuscation section below — this is a critical step that defeats aggressive ad blockers.

If you’re on WordPress, add the proxy rules inside the same <IfModule mod_rewrite.c> block, before the WordPress rules:

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteBase /

    # Google Tag Gateway — add these BEFORE WordPress rules
    RewriteRule ^a/js$ /gtag-proxy.php?_gtm_path=gtm.js [L,QSA]
    RewriteRule ^a/gtag/(.+)$ /gtag-proxy.php?_gtm_path=gtag/$1 [L,QSA]
    RewriteRule ^a/g/collect/?$ /collect-proxy.php [L,QSA]

    # BEGIN WordPress
    RewriteRule ^index\.php$ - [L]
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule . /index.php [L]
    # END WordPress
</IfModule>

Important .htaccess gotcha: If you’re writing the .htaccess content using any scripting language (Python, Node.js) and uploading it programmatically, be careful with string escaping. The $1 backreferences in the RewriteRule lines can be misinterpreted as variable references in Python f-strings or shell scripts. Always write the file content literally — use raw strings in Python (r"""...""") or write to a local file first, then upload with sftp.put() rather than writing inline.


Step 4: Upload the Files

Upload all three files to your webroot via SFTP or cPanel File Manager:

Local fileUpload destination
gtag-proxy.php/your-webroot/gtag-proxy.php
collect-proxy.php/your-webroot/collect-proxy.php
.htaccess/your-webroot/.htaccess

On IONOS, the webroot for a domain typically lives at clickandbuilds/YourSiteName/ or htdocs/. In cPanel, it’s usually public_html/.

File permissions: PHP files should be 644, .htaccess should be 644. Avoid setting PHP files to 755 or 777 on shared hosting.


Step 5: Verify the Proxy Endpoints

Before configuring GTM, confirm the proxy is actually running. Test each endpoint manually:

Test 1: gtag loader proxy

Visit in your browser: https://yourdomain.com/gtag/js?id=G-TEST123

You should see JavaScript content (even if it’s a 404 from Google for the fake ID, the proxy should return it). Check the response headers in DevTools (Network tab) — look for:

X-Proxied-By: TagGateway/1.0
Content-Type: text/javascript; charset=UTF-8

Test 2: collect proxy

Open DevTools → Console and run:

fetch('https://yourdomain.com/g/collect?v=2&tid=G-TEST&cid=test', {method:'POST'})
  .then(r => console.log(r.status)) // should log 204

Test 3: Astro/existing site still works

Navigate to your homepage and a few pages. If the proxy rules accidentally catch clean-URL requests, pages will 500. If everything looks normal, the rule ordering is correct.

Python test script (if you prefer CLI verification):

import urllib.request

base = 'https://yourdomain.com'
tests = [
    (f'{base}/gtag/js?id=G-TEST123', 'gtag proxy'),
    (f'{base}/g/collect?v=2&tid=G-TEST&cid=test', 'collect proxy'),
    (f'{base}/', 'homepage'),
]

for url, label in tests:
    try:
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=10) as r:
            proxied = r.headers.get('X-Proxied-By', 'not proxied')
            print(f'[{r.status}] {label}{proxied}')
    except Exception as e:
        print(f'[ERR] {label}{e}')

Expected output:

[200] gtag proxy — TagGateway/1.0
[204] collect proxy — not proxied
[200] homepage — not proxied

(The collect proxy doesn’t return X-Proxied-By — that’s fine, it returns 204.)


Step 6: Create the GA4 Tag in GTM with transport_url

This is where you tell GA4 to send all measurement hits through your proxy. The key is the transport_url configuration parameter — it overrides the default google-analytics.com endpoint at the tag level.

Note on GTM’s current UI: GTM now uses a “Google Tag” tag type for GA4 setup (replacing the older “GA4 Configuration” tag). The steps below reflect the current interface.

Create the tag

  1. Go to tagmanager.google.com and open your container
  2. Click Tags → New
  3. Click Tag Configuration — a panel slides in showing tag types
  4. Under Google Analytics, select Google Tag

You’ll see two options:

Tag typePurpose
Google TagLoads the GA4 library and configures your property — use this
Google Analytics: GA4 EventSends custom events — you’ll add these later

Configure the Google Tag

Fill in the tag settings:

Tag ID: Enter your GA4 Measurement ID — G-XXXXXXXXXX (Found in GA4 → Admin → Data Streams → click your web stream → Measurement ID)

Configuration settings → Add row:

Configuration ParameterValue
transport_urlhttps://yourdomain.com/a

The transport_url parameter is the entire Tag Gateway configuration. When set, GA4 appends /g/collect to this value and sends all measurement requests there — so https://yourdomain.com/a becomes https://yourdomain.com/a/g/collect. Your collect-proxy.php handles the forwarding transparently.

Why /a as the base? Setting transport_url to https://yourdomain.com/a means GA4 sends hits to /a/g/collect — a path that appears on no ad block filter list. If you set it to https://yourdomain.com, GA4 sends to /g/collect which some aggressive blockers do target. The /a/ prefix defeats path-pattern blocking for the collect endpoint too.

Triggering: Click the Triggering area and select Initialization - All Pages

Why Initialization - All Pages? This special trigger fires before the standard “All Pages” trigger, ensuring the GA4 library is configured before any event tags run. This prevents race conditions where event tags fire before GA4 has initialised.

Tag name: GA4 Configuration tag (with Tag Gateway)

Click Save.

Your completed tag should look like this:

Tag Type:    Google Tag
Tag ID:      G-XXXXXXXXXX
Configuration settings:
  transport_url → https://yourdomain.com
Trigger:     Initialization - All Pages

Update your GTM snippet to use the proxy loader

The transport_url routes measurement data through your proxy. You also need the GTM loader script itself to come from your domain (so it bypasses ad blockers too).

In your site’s <head>, use this modified GTM snippet — note the j.src URL points to your domain:

<!-- GTM snippet loading via Tag Gateway proxy -->
<script>
(function(w,d,s,l,i){
  w[l]=w[l]||[];
  w[l].push({'gtm.start': new Date().getTime(), event:'gtm.js'});
  var f=d.getElementsByTagName(s)[0],
      j=d.createElement(s),
      dl=l!='dataLayer'?'&l='+l:'';
  j.async=true;
  j.src='https://yourdomain.com/a/js?id='+i+dl; // ← neutral path via proxy
  f.parentNode.insertBefore(j,f)
})(window,document,'script','dataLayer','GTM-XXXXXXX');
</script>

Also add the GTM noscript fallback at the very top of <body>:

<noscript>
  <iframe src="https://yourdomain.com/a/js?id=GTM-XXXXXXX"
    height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>

Replace GTM-XXXXXXX with your Container ID (visible in GTM → Admin → Container Settings).


Step 7: Publish the GTM Version

  1. In GTM, click Submit (top right button)
  2. Add a version name: GA4 Tag Gateway — initial setup
  3. Click Publish

GTM changes are not applied to your live site until published. Once published, your container loads from your domain and all GA4 measurement traffic routes through your proxy.


Step 8: Verify End-to-End in GA4 DebugView

  1. Add ?gtm_debug=x to your site URL (or enable debug mode via Tag Assistant Chrome extension)
  2. Open GA4 → Admin → DebugView
  3. Navigate through a few pages on your site
  4. Watch events appear in DebugView in real time

While doing this, open Chrome DevTools → Network tab and filter for requests to your domain containing collect:

  • You should see POST https://yourdomain.com/g/collect (not google-analytics.com)
  • Status should be 204

If you see requests going to google-analytics.com, the GTM Container Settings update hasn’t applied yet — make sure you published the new GTM version and hard-refreshed the page (Ctrl+Shift+R / Cmd+Shift+R).


Troubleshooting

500 Internal Server Error on all pages

Your .htaccess has a syntax error, or the proxy rules are interfering with your site’s existing rules. Common causes:

  • Two separate <IfModule mod_rewrite.c> blocks in the same file (merge them into one)
  • RewriteBase set to something other than / when your site is in a subdirectory

Proxy endpoints return 404

The PHP files aren’t in the right location, or mod_rewrite isn’t enabled on your host. Check:

  • gtag-proxy.php and collect-proxy.php are in the same directory as .htaccess
  • Your host has AllowOverride All set (call support if you’re unsure — most shared hosts enable this)

X-Proxied-By header missing

The request isn’t reaching gtag-proxy.php. Visit https://yourdomain.com/gtag-proxy.php?_gtm_path=gtag/js&id=G-TEST directly (bypassing mod_rewrite). If this returns JS content, the PHP is fine but the rewrite rule isn’t triggering. Re-check the .htaccess rule order.

GA4 DebugView shows no events

  • Confirm the new GTM version was published after saving Container Settings
  • Check Network tab — are requests going to your domain or still to google-analytics.com?
  • Clear cookies and try again in an incognito window (old sessions may still have the old GTM container cached)

Geography data showing server location in GA4

The X-Forwarded-For header forwarding is working if you see your actual location. If GA4 shows your server’s data centre location (typically a US city for IONOS US servers), check that collect-proxy.php is correctly reading $_SERVER['REMOTE_ADDR'] and sending it as X-Forwarded-For. Some hosting providers may filter custom headers — you can test by temporarily logging the value to your error log.

cURL extension not available

If your PHP phpinfo() doesn’t show cURL, contact your host to enable it. On cPanel, it’s often under Select PHP VersionExtensions → enable curl. IONOS, Bluehost, and SiteGround all have cURL enabled by default.


Defeating Path-Pattern Blockers {#defeating-path-pattern-blockers}

Domain-based blocking is the most common ad blocker behaviour — requests to googletagmanager.com and google-analytics.com are blocked. Your PHP proxy solves this by serving everything from your own domain.

But there’s a second class of blocker: path-pattern lists. Extensions like uBlock Origin allow users to subscribe to filter lists (EasyPrivacy, Peter Lowe’s list) that block specific URL patterns regardless of domain. Common patterns on these lists:

  • Any URL ending in gtm.js
  • Any URL matching /g/collect
  • Any URL matching gtag/js

If a user has an aggressive filter list enabled, your proxy calls to yourdomain.com/gtm.js and yourdomain.com/g/collect will still be blocked — even though they’re on your own domain. This is exactly what you’ll see in Chrome DevTools when a request shows status (blocked) with “Provisional headers are shown.”

The fix: use paths that appear on no filter list.

Instead of conventional paths, route your proxy through a neutral prefix — we use /a/:

Blocked pathNeutral replacement
/gtm.js/a/js
/g/collect/a/g/collect (via transport_url: https://yourdomain.com/a)
/gtag/js/a/gtag/js

The .htaccess rules and GTM snippet in this guide already use these neutral paths. The path-pattern blocker has no pattern to match against — the proxy is invisible.

What percentage of users does this affect?

In practice: small but real. Browser telemetry studies estimate 5–15% of users with ad blockers use aggressive filter lists that include path-pattern rules. For a developer or marketing-professional audience, this can be higher. Using neutral paths adds zero complexity to the implementation and recovers this segment.

How to diagnose path-pattern blocking in DevTools:

Open Chrome DevTools → Network tab. If a request to your proxy URL shows:

  • Status: (blocked) or (canceled)
  • “Provisional headers are shown” warning
  • Zero response time

…the request was killed by a browser extension before it left the browser. Disabling the ad blocker and reloading will immediately show the request succeeding. Switching to neutral proxy paths resolves it for all users.


Frequently Asked Questions

Does this work on any shared hosting?

Yes — Apache with mod_rewrite and PHP with cURL. Both are standard on virtually all shared hosts (IONOS, Bluehost, SiteGround, DreamHost, cPanel-based hosts). You can verify cURL is available by creating a temporary phpinfo.php file and searching for the curl section.

Will it bypass all ad blockers?

It bypasses the large majority — those blocking by domain (the default behaviour of most blockers). Some aggressive blockers use path-pattern lists that target filenames like gtm.js regardless of domain. Using neutral proxy paths as shown in this guide defeats those too. The only remaining edge case is DNS-level blocking (Pi-hole, NextDNS), which can’t be bypassed from a browser context — though those users represent a very small fraction of typical audiences.

Does the proxy affect Core Web Vitals?

Minimally. The GTM loader script is cached for 1 hour — repeat visitors get it from browser cache with no proxy round-trip. GA4 measurement pings are async and fire after the page is interactive, so they never block rendering or count against LCP, CLS, or INP. The added latency (typically 20–80ms for the first GTM load) is invisible to users.

Is this GDPR compliant?

The proxy is infrastructure — it’s neutral on compliance. GDPR compliance depends on your consent implementation. Pair this proxy with Google Consent Mode v2 (defaulting analytics_storage to denied for EEA visitors) and a cookie consent banner. The proxy then ensures consented users’ data is captured even if they have an ad blocker — which is both accurate and legal.

What happens if the proxy script errors?

Both scripts are designed for silent failure. collect-proxy.php returns HTTP 204 even on cURL errors, so GA4 never throws a JavaScript error on your page. gtag-proxy.php returns 502 on failure — GTM won’t load for that page view, but user experience is completely unaffected. Analytics gaps from infrastructure issues are always preferable to user-facing errors.

Do I need to change anything in GA4?

No. The proxy forwards requests with the original client IP (via X-Forwarded-For) and User-Agent, so geo data, device categorisation, and session identification all work normally in GA4. Your property, data streams, and reports are unchanged.


Does It Actually Recover Blocked GA4 Traffic?

Yes — but the impact varies by audience. Technical audiences (developers, SaaS users, marketing professionals) tend to run ad blockers at significantly higher rates (30–50% reported in some studies) compared to general consumer traffic (5–15%).

For a typical B2B or developer-focused site, implementing a Tag Gateway can recover 20–35% of previously invisible sessions. For a general consumer e-commerce site, the impact is smaller but still meaningful for high-value attribution.

What the proxy doesn’t solve:

  • Users with JavaScript entirely disabled (very rare, ~0.2% of users)
  • Users in regions where your domain itself is blocked
  • Consent mode — users who haven’t consented won’t have GA4 fire regardless of routing (as it should be)

The privacy angle: The Tag Gateway doesn’t bypass consent or track users who’ve opted out. It ensures that users who haven’t blocked tracking and haven’t withdrawn consent actually have their analytics captured accurately, rather than losing data to indiscriminate domain-level blocking.


Performance Considerations

Proxy latency: Each proxied request adds a network round-trip through your server. For the gtag.js loader (cached for 1 hour after first request), the impact is minimal. For g/collect measurement pings (async, non-blocking), users never experience any delay — these fire after the page is loaded and interactive.

Server load: The collect proxy handles lightweight POST requests with small payloads (~2–4KB). On a site with 10,000 monthly pageviews, this adds roughly 10,000 POST requests per month to your server — negligible for any shared hosting plan.

Timeout handling: Both scripts use conservative timeouts (10 seconds for the loader, 8 seconds for collect). If Google’s servers are slow or unreachable, the scripts fail silently rather than hanging the user’s page load.


Security Notes

  • The gtag-proxy.php whitelist ensures only known Google Tag Manager paths can be proxied. An attacker can’t use this as a generic web proxy.
  • collect-proxy.php only forwards to a hardcoded Google Analytics URL — it cannot be redirected to arbitrary endpoints.
  • Both scripts validate the HTTP method (GET-only for the loader, GET/POST for collect).
  • No sensitive data from your server environment is exposed or forwarded upstream.

Complete File Reference

Files to upload to your webroot:

FileSizePurpose
gtag-proxy.php~50 linesProxies GTM/gtag.js loader script
collect-proxy.php~45 linesProxies GA4 measurement data
.htaccessModifiedRoutes requests to PHP scripts

GTM changes:

SettingLocationValue
transport_url parameterGTM → Google Tag → Configuration settingshttps://yourdomain.com/a
GTM snippet srcYour site’s HTML <head>https://yourdomain.com/a/js?id=GTM-XXXXXXX
GTM noscript srcYour site’s <body> opening taghttps://yourdomain.com/a/js?id=GTM-XXXXXXX

No GA4 property changes required — all routing changes happen at the GTM and server level.


If you want us to implement the Google Tag Gateway on your site or audit your existing GA4 setup, book a free consultation.

#gtm#google-tag-gateway#php#shared-hosting#ga4#ad-blockers#htaccess#ionos#first-party

Want This Implemented Correctly?

Let our team apply these concepts to your specific setup — with QA validation and 30 days of support.