The SaaS I Replaced in an Afternoon
In one afternoon with Claude Code I moved server-side GTM off Stape and onto my own VPS, found why Meta counted almost twice what GA4 did, and took over the pager. What an engineer can bring in-house now, and what it costs.
Stape cut me off. I didn’t upgrade.
On the morning of September 30, every GA4 hit from this site came back as a 404 from Stape. The free plan had run out. From 10 p.m. the night before until 5 p.m. that day, GA4 recorded two events. My Lead event only travels through the server, so any lead in that window was invisible to GA4 and to Meta.
I was angry. Then I opened Claude Code, and by 5 p.m. the hits were flowing through a server I control. It took about an afternoon of my time, spread across a normal workday. That afternoon is the interesting part. It shows what one engineer can take on in 2026, and what it costs.
September 30, in numbers
- 19 h
- with GA4 darktwo events from 10 p.m. to 5 p.m.
- ~1
- afternoon of my timespread across a workday
- 70
- testsplus a lab running Google's real image offline
- 0
- new subscriptionsthe VPS was already paid for
I wasn’t locked out. I outgrew the free plan.
Stape’s free plan gives you 10,000 requests a month. When they run out, the container stops serving traffic until the next billing period. That rule is in their docs, and it is fair.
I was at 13,355. About 8,700 of those requests I couldn’t explain, most likely bots. My own notes from two days earlier had the line “upgrade Stape before paid traffic.” I read it and moved on.
The cheapest paid plan costs $17 a month on a yearly bill. For a blog this size, that is the rational call, and for most of my career it would have been mine. Replacing Stape meant learning how Google’s server container reads an IP, how GTM’s sandboxed template runtime behaves, and how Meta matches a server event to a person. Weekends, for a blog that is only starting.
Stape did nothing wrong. The math changed under me.
Server-side GTM is a mailroom you can run yourself
Without it, every tracker on your page talks to its owner straight from the visitor’s browser. The Meta pixel sends to Meta, the Google tag sends to Google, and you see none of it.
Server-side GTM, Google’s take on server-side tagging, puts a mailroom in the middle. The browser sends one letter to your server. The server decides who gets a copy (GA4, Meta’s Conversions API, your own BigQuery), what gets added (city and country, a hashed email), and what gets stripped before anything leaves. Google ships that mailroom as a Docker image. Stape rents you a running copy, plus paid add-ons it calls power-ups.
Meta is the exception. Its pixel keeps its own line from the browser, so Meta hears about each visit twice: once from the pixel, once from your mailroom. It is supposed to keep only one copy. That detail decides a section further down.
The analogy breaks in one place, and it matters later: the return address. Meta reads it, because the visitor’s IP is one of the signals it uses to match an event to a person.
When I opened the hood, almost nothing was Stape. My server container ran unchanged on any copy of Google’s image. Even Stape’s own Meta tag, a public gallery template, ran fine without Stape’s hosting. The Stape-specific parts were two headers in my Cloudflare Worker, how the cookie domain got resolved, and one power-up that gave the container a BigQuery credential. The migration was pointing the Worker somewhere else.
The power-ups were the other half. Stape lists 23 of them, and the useful ones sit on paid plans. Nine now have an equivalent of my own, five of them from plans I never paid for. A few I had built before. Most came that afternoon.
Stape's power-ups, and what replaced them
| Power-up | Stape plan | Mine | Net |
|---|---|---|---|
| Hosting | 10k free, then paid | A bento stack on the VPS I already run | no quota |
| AI Traffic Detection | Pro | Detected on the site, sent to GA4, Meta, BigQuery and the lead form | wider |
| Ad Blocker Info | Pro | One check per session: did the pixel load? | same |
| Cookie Keeper | Pro | Renews cookies from the server, can't restore a deleted one | weaker |
| Bot Detection | Pro | User agent and navigator.webdriver, no bot database | weaker |
| Dedicated IP | Enterprise | The VPS's own fixed IP | same |
| Click ID Restorer | Free | Restores gclid and msclkid when Safari strips them | same |
| Custom Loader | Free | Google tag gateway through the Worker | same |
| GEO Headers | Free | Cloudflare geo and the real IP, sent by the Worker | same |
| Google Service Account | Free | One environment variable in the stack | same |
The whole architecture fits on one screen
The site already sent its hits to felipefontoura.com/io, a Cloudflare Worker on the same origin. Same origin means the cookies come back in a response from the site itself, so Safari doesn’t cap them at 7 days, as it does with cookies written by JavaScript or set by a server at a different IP. That Worker used to forward to Stape. Now it forwards to my VPS.
One hit, from browser to three destinations
Input
A visitor's browser sends a GA4 hit to felipefontoura.com/io
- WORKERSame-origin proxy
Lets through GA4 hits and the debug preview, nothing else. Adds Cloudflare's geo, the visitor's real IP in a Forwarded header, and a secret key.
- TRAEFIKGate on the VPS
Routes to the container only when the key is present. Everything else gets a 404, including every scanner looking for /.env.
- SGTMGoogle's own image
The same server container I had on Stape. It forwards to GA4, to Meta's Conversions API and to a BigQuery table I own.
Output
One hit in, three destinations out, on infrastructure I already paid for
The stack lives in bento, the open-source installer that turns my VPS into a Docker Swarm with Traefik. It already runs my agents and my automations. The sGTM stack is one more service on it, merged that afternoon with docs good enough for someone who never saw my setup.
One IP address could have sunk the whole move
This is where the mailroom analogy breaks. A paper envelope keeps its return address all the way. On the internet, every proxy that passes a request along puts its own address on it. Every visitor to my blog would look like they lived inside a Cloudflare data center, and Meta would match almost nobody.
The usual fix is a note saying who really sent it: the X-Forwarded-For header. Traefik, the front desk on my VPS, crosses that note out and writes in whoever handed it the request, which is the Worker. I could tell Traefik to trust Cloudflare’s addresses, but that trusts anyone running on Cloudflare, not just my Worker. Traefik leaves the standard Forwarded header alone. So the Worker writes the visitor’s IP there, over anything the browser sent, and the secret key is its badge. No badge, no entry.
That was the theory. Google’s docs said the container reads the IP from Forwarded, and a local Traefik test showed the header survived. I still didn’t ship on that. Claude built a lab: Google’s real image running in a container with no route to the internet, fed with fake hits through the same path. The visitor’s IP arrived in the Meta payload, in IPv4 and in IPv6. A forged X-Forwarded-For was ignored. That was the biggest risk of the migration, and it closed before anything touched production.
A day later, Meta’s Events Manager showed what a PageView carried.
What reached Meta with a PageView, October 1
| Parameter | Events carrying it |
|---|---|
| IP address and user agent | 100% |
| City, state and country | 100% |
| ZIP code | 88% |
| Email, hashed | 32% |
Once someone fills the form, the browser keeps a SHA-256 hash of their email, phone and name, never the plain text, and every event after that carries it, a plain PageView included.
The agent gave me hands. The calls were still mine.
I have been building production software for twenty-five years, and server-side tracking was never my specialty. That afternoon Claude Code read three repositories and two fresh GTM exports, wrote the Swarm stack, rewired the Worker, wrote four custom GTM templates and a harness that runs their real code against my container’s configuration. It opened the pull requests, in English, on a public repo, and fixed a CI that had been red since July on the way.
That is the power I didn’t have before: range. Before, the edge of what I could build in an afternoon was the edge of what I already knew. Now the edge is what I can verify.
Verifying is where my part of the afternoon went.
What the agent did
- 01Diffed my live GTM containers against the template repo
- 02Wrote the stack, the Worker change and four GTM templates
- 03Built 70 tests and the offline lab
- 04Ran the production review I asked for and fixed what it found
- 05Opened the open-source pull requests, in English
What I decided
- 01Don't rebuild Stape. Fix what its absence broke
- 02Meta, GA4 and BigQuery must count the same thing
- 03Bots stay out of every destination, BigQuery included
- 04Keep the no-reload navigation. Tracking adapts to the site
- 05Review it like it goes to production, without overengineering
- 06Grow the tests where a silent error costs money
The agent wanted to keep bot traffic in BigQuery with an is_bot flag, so I could see how much noise got cut. I said no. I have no interest in counting bots anywhere, so the column went too. When I asked whether the site had lost its no-reload navigation, the answer was no, and I had it show me the diff anyway.
AI doesn’t replace those who think. It multiplies those who know how to build. I repeat that line so often it became a yardstick, and that afternoon was a clean measurement.
The real bug was in my numbers, not in Stape
Losing the quota forced an audit I should have done earlier. Over the day and a half before the outage, Meta received about 249 PageViews. GA4 counted about 139. Same site, same visitors, almost double. You can’t buy ads on numbers that disagree like that.
First, how Meta avoids counting a page twice. It hears about every page from the pixel and from my server, which builds Meta’s copy out of the GA4 hit. It keeps one only when both carry the same event ID. Think of two copies of a receipt: same number, one sale. Different numbers or no number, and Meta can’t tell they are the same sale.
Five things made the two disagree. I can’t rank them from the data I had. I can point at each one in code.
Five reasons Meta and GA4 disagreed
- 1
Noise mapped to PageView
The server translated GA4 event names into Meta events, with PageView as the fallback. Every event without a row in the table, user_engagement and scroll included, reached Meta as one more PageView. I had removed that fallback two days earlier, on the evening of September 28. It was still inside the window.
user_engagement → PageView scroll → PageView
- 2
Soft navigation
Astro swaps pages without a reload, so GTM only runs on the first one. After that, each tool tracked the swaps on its own. The pixel sent its copy with no receipt number. The server's copy, built from GA4's history tracking, kept page one's number, so Meta threw it away as a repeat. Every page after the first reached Meta without the server's data.
pixel: PageView, no event_id server: PageView, event_id of page 1
- 3
Bots filtered in one place
The bot filter only guarded the Conversions API. A bot that runs JavaScript still fired the pixel and GA4, and only GA4 drops known bots on its own.
- 4
The pixel's automatic extras
A setting called Meta-enabled Conversions API was on without anyone ticking it, because the template treats a missing field as yes. Automatic events too. Sources that GA4 and BigQuery never see.
- 5
Two clocks
GA4 reports in São Paulo time. Meta's API answers in UTC. Comparing "the same day" starts three hours off.
The fix was one rule applied everywhere. Every page swap now pushes a single event that fires the pixel and the GA4 tag together, with one receipt number. Bots don’t get GTM loaded at all, and the server repeats the check for anything that skips the page. The pixel’s automatic behavior is off, and only events with a row in the table reach Meta. A test fails if the bot pattern on the site and on the server ever drift apart.
In-house means the pager is mine
Everything that went wrong that afternoon was mine to catch. No support ticket, no status page. Some of it the tests caught, some the review caught, and one thing reached production data before anyone caught it.
What broke on the way, and who caught it
| What happened | Caught by | Fix |
|---|---|---|
| The first lab run sent 3 fake page views to the real Meta pixel and GA4 | too late | The container resolves DNS on its own and ignored the hosts file. The lab now has no route out and checks that before it accepts a hit. |
| Hits with no user agent slipped past the bot filter | tests | GTM turns an empty variable into the text "undefined". The check now handles it. |
| A crafted utm_source could carry HTML into a cookie, the lead form and my email tool | review | Only hostname characters pass now. The malicious case is a test. |
| Any website could switch off my tracking for its own visitors | review | My opt-out link worked as an embedded image. The Worker now accepts real navigations only. |
| A missing secret would have sent every hit to a silent 404 | review | The Worker now answers 500 with a message saying which secret is missing. |
| Purchases sent by webhook would have been classified as bots | review | The bot check now runs on browser hits only. |
The agent found the last four in a review. It found them because I asked for one, framed as “what we’d do before production, without overengineering.” That request is the twenty-five years. The review is the agent.
The rest of the bill is ongoing. If the VPS goes down, GA4 and Meta go down with it. I chose not to build a fallback at this volume, and that is a choice I now own. Stape checks every request against private and public bot databases, and I can’t replicate a private database. The image only runs on amd64. Updates, logs and key rotation are mine. None of that is expensive. All of it is mine.
Bring it in when the layer is thin and you can check the work
The agent didn’t make every SaaS worth replacing. It moved the line for one kind: a thin layer over something open, where I can catch the agent when it’s wrong. I didn’t know this domain well. I could build a lab for it, and that was enough.
Rent it or run it
Run it
Server-side GTM, for me. Google ships the image, I already run the VPS, and I can catch the agent when it's wrong.
Rent it and sleep
The value is the operation: an SLA, compliance, data pooled across thousands of customers. Understanding it doesn't let you copy it.
Rent it while you learn
The agent will write it either way. You won't know which bugs went out with it.
Rent it
Nothing to gain and nothing to judge. Pay the invoice.
The agent moved the line in the left column. The right column still belongs to the vendors. Even Stape had a piece there, its bot database, and I gave it up on purpose.
Before you cancel your next subscription, check what I had going for me.
What made this one worth bringing in
- Required:The core is open and someone else maintains itGoogle publishes the server container image. I run it, I don't write it.
- Required:The infrastructure already existsSwarm, Traefik, TLS and DNS were already running for other stacks. The new cost was one service.
- Required:You can test without touching productionTests for the logic, an offline lab for the real image. The one leak happened before the lab was isolated, which is the point.
- Required:The failure mode is yours to absorbA dark afternoon on my blog costs me data. On a client's checkout it would cost them money. Different call.
- Required:You get something the SaaS wasn't giving youMatching counts, my own BigQuery table, and Pro-plan power-ups without the Pro plan.
- Anti-pattern:Cancelling only to save the subscription feeSeventeen dollars a month is cheaper than one bad afternoon. Do it for control or capability, not for the invoice.
The free plan didn’t kill a SaaS. It killed a default.
For most of my career, “build or buy” for a layer like this had a default answer: buy, because building meant weeks outside my specialty. That default was really a statement about my range. The afternoon of September 30 moved it.
What I have now that I didn’t have before is an engineer’s range that grows with what I can check, not with what I already know. What I still have is the pager. The question that decides whether you should bring something in-house is no longer “can I build it?” It is “if the agent gets it wrong, will I notice?”
Self-hosting server-side GTM, quick answers
What happens when you go over Stape's free plan?
The free plan includes 10,000 requests a month. According to Stape's docs, when the quota runs out the container stops serving traffic until the next billing period, and the quota doesn't roll over. Paid plans start at $17 a month on a yearly bill for up to 500,000 requests.
Is there a free alternative to Stape?
Yes, if you can run a server. Stape hosts Google's own server container image, and Google publishes that image for anyone to run. On a VPS you already pay for, there's no new subscription.
Most power-ups can be rebuilt. I rebuilt nine, five of them from Stape's paid plans. What you can't rebuild is the managed part: Stape's bot database and someone else watching the uptime. The real price of the free option is operating it yourself.
Can you self-host server-side Google Tag Manager?
Yes. Google publishes the server container as a Docker image. You run two services from it, one for tagging and one for the debug preview, and point them at the config string from a manually provisioned server container in GTM.
I run it as a stack in bento, my open-source VPS installer, behind Traefik.
Do I need a proxy in front of a self-hosted sGTM?
No, a subdomain pointed straight at the container works. I use a same-origin proxy anyway: cookies come back from the site's own origin, scanners never reach the container, and only requests carrying the proxy's key get through. A subdomain pointing at another server loses the cookie part, because Safari also caps cookies set from a different IP.
If you do use a proxy, send the visitor's IP in a header your reverse proxy won't overwrite. Mine was Forwarded, not X-Forwarded-For.
Is self-hosting sGTM free?
There's no new subscription if you already run a server. The two services need a few hundred megabytes of RAM, and BigQuery streaming inserts cost cents at blog volume.
The real cost is operations: updates, monitoring, and every outage being yours.
Why do Meta and GA4 report different page view counts?
On my site, five things: unmapped events reaching Meta as extra PageViews, pages swapped without a reload that each tool tracked on its own with event IDs that couldn't pair, bots filtered in one place only, the pixel's automatic events, and reports in different time zones.
The newsletter
Don’t Code, Specify. A weekly dispatch from where AI agents meet real production. No hype, just what shipped and what broke.
Subscribe on Substack (opens in a new tab)