Every time you tweak a server-side GTM client template, you push to a staging container, wait 30-60 seconds for the preview to reconnect, fire a test event, realise you fumbled a templateDataStorage key, and repeat. Do that fifteen times in an afternoon and you’ve lost an hour to nothing but round-trips. What if you could run the entire sGTM container on your laptop, hot-reload templates, and inspect every request and response header without ever pushing to a Cloud Run revision?
You can. Google added official SGTM_MODE=preview and SGTM_MODE=serving support to the sGTM Docker image, and Simo Ahava flagged localhost as a first-class dev target. The problem is that every tutorial stops at “run the container and open port 8080.” That’s not a workflow. This post is the full loop we use at Aumlytics when building custom clients for clients: Docker Compose, tunnelling live traffic from a production site, template hot-reload, and a clean promotion path into staging and prod.
Why bother running sGTM locally
If your only use of server-side GTM is proxying GA4 hits through a custom subdomain, you probably don’t need this. Push to Cloud Run, tag your events, move on.
But the moment you start writing custom client templates — for a Shopify webhook, a CRM ingest endpoint, a first-party cookie service, or an AI agent callback — the standard workflow falls apart. Here’s the honest cost comparison from a two-week custom client build we ran last quarter:
| Workflow step | Cloud Run staging loop | Localhost loop |
|---|---|---|
| Template code change → visible in preview | 45-90 seconds | 1-2 seconds |
| Cost per dev day (staging container + preview) | ~$3-5 in Cloud Run minutes | $0 |
| Inspecting raw request headers hitting the client | Requires log-based Cloud Logging queries | docker logs -f or Wireshark |
| Testing malformed payloads safely | Risk of polluting staging analytics | Fully sandboxed |
| Rollback on a broken client | Redeploy previous revision | Ctrl+C |
The template iteration speed is the real win. When you’re writing a client that has to runContainer() correctly against a specific request signature, you want the sub-second feedback loop. The cost savings are a nice bonus, especially if you have a team of four analytics engineers each burning their own preview container.
There’s also a safety argument. Custom clients receive raw HTTP requests. If your client mishandles a header parsing routine and starts throwing exceptions in production, you’ve broken data collection for everything routed through that container. Testing the failure modes locally, with adversarial payloads you’d never fire at staging, is worth the setup effort alone.
Docker Compose setup: tagging + preview servers
Google’s docs give you a single docker run command. That’s fine for a five-minute demo. For actual development you want Compose so you can bring the preview server, tagging server, and any dependent mocks up together.
Here’s the docker-compose.yml we use as a starting point:
version: "3.9"
services:
sgtm-preview:
image: gcr.io/cloud-tagging-10302018/gtm-cloud-image:stable
container_name: sgtm-preview
environment:
- RUN_AS_PREVIEW_SERVER=true
- CONTAINER_CONFIG=${CONTAINER_CONFIG}
- PORT=8081
ports:
- "8081:8081"
restart: unless-stopped
sgtm-tagging:
image: gcr.io/cloud-tagging-10302018/gtm-cloud-image:stable
container_name: sgtm-tagging
depends_on:
- sgtm-preview
environment:
- CONTAINER_CONFIG=${CONTAINER_CONFIG}
- PREVIEW_SERVER_URL=http://sgtm-preview:8081
- PORT=8080
ports:
- "8080:8080"
restart: unless-stopped
Two things most guides get wrong here.
First, they run only the tagging server and skip the preview server entirely. That works if you never want to use the visual debugger — but if you’re building custom clients, the preview UI is where you inspect the request → client → tag flow. Run both.
Second, the PREVIEW_SERVER_URL value has to be reachable from inside the tagging container. http://localhost:8081 will fail because that resolves to the tagging container itself. Use the service name (sgtm-preview) so Compose’s internal DNS handles it.
Put your container config string in a .env file next to the compose file:
CONTAINER_CONFIG=aWQ9R1RNLVhYWFhYWFgmZW52PTEmYXV0aD1zb21ldG9rZW4=
Grab that string from your sGTM container’s setup screen (“Manually provision tagging server” → copy the config). Then:
docker compose up -d
docker compose logs -f sgtm-tagging
You should see Server is running and listening on port 8080 within a few seconds. Hit http://localhost:8080/healthz and you’ll get an ok. Open the preview server UI by copying the preview URL from your GTM workspace and swapping the domain for localhost:8081.
If the preview UI shows “Waiting for the tagging server to be provisioned” indefinitely, 90% of the time your CONTAINER_CONFIG differs between the two services. Double-check the env file was picked up on both.
Tunnelling live site traffic into localhost
The tagging server is running. Now you need real requests hitting it. Firing curl commands works for smoke tests, but if you’re debugging a client that parses Shopify checkout webhook payloads or Amazon Marketing Stream events, you need actual traffic shape.
Two tunnel options work well:
ngrok is the fastest to set up. ngrok http 8080 gives you an https://xxxx.ngrok-free.app URL. Point your test site’s transport_url or your webhook source at that URL, and every request hits your local container.
Cloudflare Tunnel is what we run for anything longer-lived. It’s free, gives you a stable hostname, and lets you attach the tunnel to a subdomain you own (e.g. sgtm-dev.aumlytics.com), which matters because a lot of sGTM behaviour is domain-sensitive — cookies especially.
The Cloudflare setup, in short:
cloudflared tunnel login
cloudflared tunnel create sgtm-local
cloudflared tunnel route dns sgtm-local sgtm-dev.yourdomain.com
cloudflared tunnel run --url http://localhost:8080 sgtm-local
Now update your web GTM container’s Google Tag server_container_url field to point at https://sgtm-dev.yourdomain.com. Every GA4 event fired on your dev site now routes through your laptop.
A gotcha most people miss: when tunnelling, the Host header your local container sees will be the tunnel hostname, not localhost. This matters because sGTM’s built-in clients (GA4, Google Ads) validate the request against the server URL configured in the container. If they don’t match, requests get rejected as claim-invalid. Set the container’s server URL in the workspace UI to match your tunnel hostname exactly, then re-copy your CONTAINER_CONFIG and restart the stack.
You’ll also want to set the X-Forwarded-For and X-Forwarded-Proto headers correctly. Cloudflare Tunnel handles this. ngrok does too. If you roll your own tunnel via SSH, don’t — the number of times we’ve seen client IP detection break because someone reverse-tunnelled through a bare TCP forward is embarrassing.
Building and testing a custom client with hot-reload
Here’s where local dev pays off. Say you’re building a client that receives Shopify checkout webhooks, verifies the HMAC, and rewrites the payload into a GA4-compatible event structure. The template code sits in Client.tpl in your GTM workspace UI, but iterating there is slow.
The workflow we use:
- Draft the sandboxed JS in your editor of choice (VS Code with the sGTM template syntax highlighter helps).
- Paste into the template’s Code tab in your GTM workspace.
- Save the template, save the workspace (no publish needed).
- Fire a test request at
http://localhost:8080/your-endpoint. - Watch the preview UI at
http://localhost:8081for the client match, request data, and anylogToConsoleoutput.
The workspace save → container image refresh takes 5-10 seconds locally, versus the 45-90 seconds of pushing to Cloud Run. The stable image polls the workspace state and re-loads the container config without a restart.
For the request itself, here’s a minimal test harness we drop into every custom client project:
// scripts/test-client.js
const crypto = require('crypto');
const fetch = require('node-fetch');
const SHOPIFY_SECRET = process.env.SHOPIFY_WEBHOOK_SECRET;
const SGTM_URL = 'http://localhost:8080/shopify/checkout';
const payload = JSON.stringify({
id: 4567890123,
email: 'test@example.com',
total_price: '129.00',
currency: 'GBP',
line_items: [{ product_id: 111, quantity: 2, price: '64.50' }]
});
const hmac = crypto
.createHmac('sha256', SHOPIFY_SECRET)
.update(payload, 'utf8')
.digest('base64');
(async () => {
const res = await fetch(SGTM_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Hmac-Sha256': hmac,
'X-Shopify-Topic': 'checkouts/create',
'X-Shopify-Shop-Domain': 'aumlytics-test.myshopify.com'
},
body: payload
});
console.log('Status:', res.status);
console.log('Body:', await res.text());
})();
Run it with node scripts/test-client.js and you get a signed, realistic Shopify webhook hitting your local container. Change the client template, save, re-run the script, watch the preview UI. Total feedback loop: about 8 seconds.
For inspecting what the client actually sees, logToConsole in the sandboxed JS surfaces in the preview UI’s client-specific “Console” tab. But you can also docker logs -f sgtm-tagging for the raw stdout stream, which is faster when you’re scanning for exceptions.
If you’re building the receiving end of an AI agent workflow, you’ll want to log the full request body once, then move to structured assertions. Don’t leave logToConsole on request payloads in production — you’ll leak PII into Cloud Logging.
Promoting from local to staging to production
The point of local dev is confidence, not permanence. Here’s the promotion path we use.
Step 1: Version the workspace. Once your local testing passes, create a container version in the sGTM UI (not a publish — just a version). Tag it with a semver-ish label like client-shopify-webhook-v0.3.
Step 2: Deploy that version to a Cloud Run staging service. We keep two separate Cloud Run services per client: sgtm-staging and sgtm-prod. Staging pulls the latest version, prod pulls only versions marked “live.” A small Cloud Build trigger handles this:
# cloudbuild.yaml (staging)
steps:
- name: 'gcr.io/cloud-builders/gcloud'
args:
- run
- deploy
- sgtm-staging
- --image=gcr.io/cloud-tagging-10302018/gtm-cloud-image:stable
- --region=europe-west2
- --update-env-vars=CONTAINER_CONFIG=${_STAGING_CONFIG}
- --allow-unauthenticated
substitutions:
_STAGING_CONFIG: 'YOUR_STAGING_CONTAINER_CONFIG'
Step 3: Replay traffic against staging. The same scripts/test-client.js from earlier, with SGTM_URL swapped to your staging Cloud Run URL. If it passes locally and passes staging with real DNS and TLS in play, you’re 95% of the way there.
Step 4: Publish the container version to prod. In sGTM, promote the version to live, and your prod Cloud Run picks it up on the next container refresh (usually within 60 seconds).
This is the same discipline we apply to GA4 tag deployments and to any GTM change management process — locally verified, staging-validated, then promoted. The container config strings are the artefact that flows through the pipeline; the workspace itself is source-of-truth.
Common mistakes and troubleshooting
We’ve broken every one of these at least once, so learn from our scars.
CORS errors from the browser. If your web container fires GA4 events at https://sgtm-dev.yourdomain.com but the browser blocks the request, check that your tagging server URL in the sGTM workspace matches the tunnel hostname exactly. Also verify the response includes Access-Control-Allow-Origin — if you’ve written a custom client that returns raw JSON, you have to set CORS headers yourself using setResponseHeader.
Cookies not being set. First-party cookies only work if the sGTM domain is a subdomain of the site domain. sgtm-dev.aumlytics.com receiving traffic from www.aumlytics.com works. sgtm-dev.ngrok-free.app receiving traffic from www.aumlytics.com does not — the cookie will be set on the ngrok domain, which is useless. Use Cloudflare Tunnel with a subdomain of your actual site for any cookie-related debugging.
Preview server shows requests but no clients match. Nine times out of ten this is a claim priority issue. If a built-in client (like the GA4 client) has higher priority and claims the request before your custom client sees it, you’ll see the request in the preview but your client’s claimRequest() never runs. Bump your client’s priority above the built-ins, or narrow the built-in’s request path.
“Container config invalid” on startup. The CONTAINER_CONFIG string expires or gets rotated when you regenerate credentials in the workspace UI. If your Docker container refuses to start after you’ve been away for a few weeks, re-copy the config string.
Preview UI reachable but empty. You copied the preview URL from GTM but didn’t swap the domain. The URL looks like https://your-sgtm-domain.com/gtm/preview?... — replace only the host with localhost:8081, keep the entire query string. Half the “why doesn’t preview work” tickets we get on client Slacks are this exact mistake.
Requests hitting the tagging server but not the tunnel. On macOS, Docker Desktop occasionally binds to the wrong network interface. If curl localhost:8080/healthz works from your machine but Cloudflare Tunnel can’t reach it, restart Docker Desktop. It’s a stupid answer but it’s the right one.
Sandboxed JS require() failing silently. The sGTM sandbox only exposes specific APIs. If you require('sendHttpRequest') and it comes back undefined, you didn’t add the permission in the template’s Permissions tab. This behaves the same locally and in Cloud Run, so it’s easy to catch early — as long as you’re actually reading the preview UI’s error output.
Key Takeaways
- Localhost sGTM is production-supported and drops your custom client iteration time from ~60 seconds per change to under 5 seconds, with zero Cloud Run cost during development.
- Run both the tagging server (port 8080) and the preview server (port 8081) via Docker Compose — most guides skip the preview server, which is the piece you actually need for debugging.
- Use Cloudflare Tunnel with a real subdomain of your site (not ngrok’s free hostnames) whenever you’re debugging cookie behaviour or CORS, because first-party cookie semantics depend on domain alignment.
- Build a lightweight test harness in Node or Python that fires signed, realistic payloads at your local container — this is what unlocks fast iteration on custom clients for Shopify, Amazon SP-API, or AI agent callbacks.
- Promote through container versions, not through copy-pasted template code: version locally, deploy to Cloud Run staging via Cloud Build, then publish live once staging replay tests pass.
- Every failure mode you’ll hit in prod (CORS, cookies, claim priority, permission scopes) will surface identically in the local stack — which is the entire point of the workflow.
Share this article