Four Phases in Two Days (From a Ski Lodge)
GCP Cloud Run, offline SwiftData, analytics batching, Workload Identity Federation, all shipped between ski runs in Andorra. Plus a hot take on GCP and the Game Center debugging saga that cost three commits.
The ski trip to Andorra continued. The backend foundation from the previous post was solid. What I needed next was velocity: getting the server into production and the iOS client hooked up to it. Two days, four architectural phases, six pull requests, one Game Center debugging saga that cost me three commits to track down. This is the post about how fast things can move when the tool, the stack, and the mountain air all line up.
The GCP hot take
First: why GCP?
I picked Google Cloud Platform over AWS or any of the smaller PaaS options (Railway, Fly, Render). Two reasons, one subjective and one pragmatic.
The subjective reason: GCP is the Windows 10 to AWS's Windows XP.
I know that's going to offend half of you. Hear me out. AWS is the mature incumbent. It's enormous. It has a service for everything. It's also a museum of decisions made at different times by different teams with different ideas about how things should work. The IAM model predates the service mesh model which predates the serverless model, and each of them carries baggage from the era it was designed in. You can feel the archaeology when you use it.
GCP is newer. The console is more coherent. The CLI (gcloud) is excellent, and critically, Claude knows it fluently. When I want to deploy a Cloud Run service, I describe what I want in English and Claude writes the gcloud run deploy command correctly on the first try, including the flags I didn't know I needed. Try doing that for AWS and you get confident wrong answers because AWS has so many ways to deploy a service that the model gets confused about which one you mean.
There's also one thing GCP has that I genuinely did not expect: the Gemini-based AI helpers in the Cloud Console actually work. Not in the way most cloud providers' AI helpers work (which is: they don't). I mean actually helpful. Click on a failing Cloud Run revision and the helper tells you, in English, what's wrong and how to fix it, and the answer is correct. This is a very low bar and AWS still trips on it. GCP clears it.
The pragmatic reason: Smartbull (my day job) is migrating from AWS to GCP. The migration is slow and deliberate. They're moving cautiously, de-risking as they go. Building Geo Climber on GCP gives me a no-risk personal environment to push the stack as hard as possible and learn its quirks. Every gotcha I hit on Geo Climber is a gotcha I don't hit at work. Every idiomatic pattern I learn is a pattern I can bring back to the day-job migration. The side project is a training ground for the main gig.
If your day job is migrating to a new cloud, build your side project on it. You get double value: a working side project plus free training for your main gig.
Four phases
On day 11 (2026-03-27) and day 12 (2026-03-28), the following shipped:
Deploy server to GCP Cloud Run (#12) Add Phase 2: catalog manager, progression store, balance display (#13) Add Phase 3: offline persistence with SwiftData + reward estimation (#14) Add Phase 4: analytics event batching (#15) Add auto-deploy pipeline with Workload Identity Federation (#16) Add integration tests with real Postgres in CI (AYA-111) (#17)
Six PRs across two days.
Phase 1 (PR #12): deploy to Cloud Run. Get the Fastify server running in production. Cloud Run for the service, Cloud SQL for Postgres, Artifact Registry for the Docker images. Deployment took two attempts. The first try was missing some service account permissions and failed mid-deploy. The second try used async Cloud Build with polling and worked.
Phase 2 (PR #13): catalog and progression on iOS. The iOS client can now call the server for catalog data (item definitions, reward formulas) and progression state (best run, total coins, unlocks). The balance display on the start screen shows real data from the server. First end-to-end "iOS calls server, server returns data, iOS renders it" feature.
Phase 3 (PR #14): offline persistence with SwiftData. SwiftData is Apple's newer Core Data replacement, released in iOS 17, and I had never used it. On any normal day that would be a "stop everything and research" moment. On day 11 it wasn't even a speedbump. Being in my web dev comfort zone for the server work, Claude knowing SwiftData well, and the "on a roll" state from the previous two days meant Phase 3 just... shipped. Ten files, offline queue for runs, offline reward estimation so the player sees an approximate coin reward even when offline. Done in half an afternoon session. "Not even a bumper" is the phrase I actually said out loud.
Phase 4 (PR #15): analytics event batching. The iOS client collects analytics events locally and batches them to the server. Avoids one HTTP call per event, which would murder the battery and the backend. Four files, straightforward pattern, done.
Plus auto-deploy (PR #16). Merging to master now automatically triggers a Cloud Build that rebuilds the Docker image, runs the Prisma migrations against production Cloud SQL, and updates the Cloud Run service. Using Workload Identity Federation (WIF), which means GitHub Actions can authenticate to GCP without storing long-lived service account keys. More on WIF below.
Plus integration tests (PR #17). Real Postgres in CI, running the full test suite against a throwaway database per run. Catches schema mismatches, constraint violations, query errors, transaction behavior. All the things mocks miss.
Six PRs in two days. Four architectural phases. The server went from "scaffold on my laptop" on day 10 to "in production with auto-deploy and integration tests" on day 12. I was coding from a hotel room in the mountains. The ski lodge worked.
Workload Identity Federation (Claude suggested it, and I was too impressed)
PR #16 is a good example of a pattern that keeps showing up in this series.
When I told Claude "set up auto-deploy from GitHub Actions to Cloud Run," Claude suggested Workload Identity Federation. I had never heard of WIF. Claude explained: instead of storing a long-lived service account key as a GitHub secret (which is the old way and has serious security downsides), you configure GCP to trust GitHub's OIDC tokens and exchange them for short-lived credentials at deploy time. No stored keys. Rotated automatically. Best practice.
I was impressed. This was the kind of architectural sophistication I'd expect from a senior DevOps engineer, not from an AI that had known my project for two days. I implemented it as suggested, deployed it, and it worked.
Here's the thing I want to flag. A few days later (outside the scope of this post, in the GCP hardening PR that came later in the series) I came back to this deployment and realized the initial setup was missing other security concerns that a real senior DevOps engineer would have flagged. The backend was publicly exposed with no VPC. The service account permissions were broader than necessary. The Cloud SQL instance was accessible from the public internet with just password auth. Things that WIF alone doesn't protect you from.
Claude didn't proactively tell me about those. It suggested WIF, I implemented WIF, WIF is good. But WIF plus public exposure, broad SA permissions, and password-auth Postgres is also bad, and Claude hadn't flagged that combination.
I said something like this in the Metal post and I'll keep saying it: the failure mode isn't "Claude makes an obviously wrong suggestion." The failure mode is "Claude makes a good but incomplete suggestion, you're impressed by how good it is, you stop looking for the incompleteness, and then you ship a half-finished architecture that looks senior."
The mental model I've landed on: Claude is like a very capable team of juniors and mid-level devs. Capable. Fast. Often surprising. Sometimes confidently wrong. Needs oversight the way you'd give oversight to a team of six mids: checking each other's work, asking "did we cover X?" before a deploy, cycling back a day later to stress-test the decisions that seemed too easy.
If you remember one thing from this post, remember this. It's the thing I wish I'd internalized on day 11 instead of day eighteen.
The Game Center debugging saga
Day 12 had a different kind of story. Game Center authentication.
I'd added Game Center signature verification to the server earlier in the day. The iOS client uses the Game Center SDK to get a signed identity token, sends it to the server, and the server verifies the signature against Apple's public key. This is the standard pattern for using Game Center for server-side identity.
It didn't work. Every auth attempt from the iOS client failed with a signature verification error on the server.
I burned three commits on this. The git log tells the story in timestamp order:
11:32d83e4b0Add Game Center signature verification (production only). Introduces the bug.11:5597c02f8Add warn logging for Game Center auth failures. Starts diagnosing.12:048fd2779Debug: log full GC auth input on failure. Gives up on privacy-friendly logging, dumps everything.12:45354f0eaFix Game Center auth: use teamPlayerID, not gamePlayerID. Found it.
One hour, thirteen minutes from introducing the bug to fixing it. The bug was this: Apple's Game Center has two different player identifier fields, gamePlayerID and teamPlayerID. They look nearly identical in the Game Center SDK. gamePlayerID is per-game and mostly deprecated for server-side use. teamPlayerID is per-team (per publisher identity) and is the correct one for cross-game player identity and signature verification.
Apple's documentation on this is unclear, in the way Apple's documentation is often unclear. There's a sentence somewhere that distinguishes the two, but it's buried and easy to miss. I picked the wrong one.
The fix was a two-character change. Finding it took an hour because the failure mode was a cryptographic signature mismatch, not an obvious field error. The signature couldn't verify because the player ID going into the verification was the wrong one, but "signature doesn't verify" is a red herring that looks like a key problem or a format problem.
The meta-lesson: when your auth is failing and you don't know why, crank up logging verbosity before anything else. The Debug: log full GC auth input on failure commit was the turning point. Once I was looking at the actual bytes going into verification and the actual bytes Apple was signing, the mismatch was obvious. Before that, I was guessing at key formats and signature algorithms.
Privacy-friendly logging is a good default. Don't die on that hill when you're stuck. Add the noisy logging, find the bug, then remove the noisy logging. The risk of having sensitive data in a log for 30 minutes during a debugging session is much lower than the risk of not finding the bug at all.
The ski trip in numbers
This post is my chance to celebrate the ski trip context because it really was the vibe of the week.
From 2026-03-23 to 2026-03-28, I shipped:
- The TestFlight beta (covered in post 5)
- The backend scaffold (post 6)
- The CLAUDE.md consolidation
- Six backend PRs (today's post, PRs #12-#17)
- ~25 issue closures in Linear
- Five ski runs a day on average
Working hours: evenings until midnight, afternoons during ski breaks, never during the actual skiing. Probably 6 hours of coding per day plus 5 hours on the mountain plus some sleeping in between. And it was the most productive week of the project.
The mountains were in Andorra. Empty runs because it was off-peak. Good prices because it was off-peak. Dinner in the village every night. Me and the laptop and the project and a view. If this is what indie dev life looks like, I understand why people chase it.
Andorra was amazing. I'm going back, and next time I'm doing it on purpose as a working trip.
This is post 7 of 18 in a series about building Geo Climber with Claude Code. Four architectural phases in two days, all shipped between ski runs. Andorra was amazing. Join the Discord and download Geo Climber on the App Store.