8 min read
Zero to Cloud Run in a Day — Then the Bill, and the API That Said No

Post #1 ended with a teaser: the same Azure telemetry platform, ported to Google Cloud in a day. This is that post. The repo is here, and the live demo is here.

Act 1: The one-day port

The Azure original — App Service API, Azure Functions for ingestion, Azure SQL Serverless, all Terraform, all GitHub Actions — was done. I’d never touched GCP. I picked it up in one long day, and the port crossed languages as well as clouds: .NET to Go, Azure to GCP.

The concept mapping was the easy part, and it’s where AI earned its keep. I didn’t need GCP depth — I needed a translation table:

AzureGCP
App Service APICloud Run (Go single binary, serves API + SPA)
Azure Functions timersCloud Scheduler (OIDC) → Cloud Run /ingest endpoints
Azure SQL ServerlessFirestore (native mode)
Terraform + GitHub ActionsTerraform + GitHub Actions (Workload Identity Federation, keyless)

AI wrote the first-draft Terraform. AI mapped the Azure concepts to GCP services. AI pulled the doc pages I needed. What AI didn’t do was decide whether the architecture was correct — whether scale-to-zero was the right default, whether the VPC egress design would actually work, whether the Firestore data model matched the access patterns. That’s platform judgment, and it’s the part that steers. The division of labor is the same one I described in post #1: AI for velocity, judgment for direction.

By end of day, the Go binary served the React SPA and the REST API from a single Cloud Run container, Cloud Scheduler hit /ingest/metro and /ingest/flight on a cron, and Firestore held the vehicle documents. It compiled, it deployed, the dashboard rendered. Done.

Then it sat. (The public repo won’t show you this gap — the history was squashed at publication, so the port, the parking, and the hunt you’re about to read all landed as one initial commit. The timeline here is my account, not git’s.)

Act 2: The OpenSky egress hunt

I came back to revive it as a live portfolio piece. Fired it up. The Metro feed — live Austin bus positions — populated fine. The flight layer was dead. Zero aircraft on the map.

The symptom: dial tcp api.opensky-network.org:443: i/o timeout. Every flight fetch from Cloud Run timed out.

Here’s where I made the reasonable-sounding wrong turn. I architected the solution before I finished diagnosing the problem. The obvious explanation was egress IP reputation — OpenSky doesn’t love cloud IPs, so give it a clean one. I stood up the full stack: a Serverless VPC Access connector (min 2 instances, 24/7), Cloud NAT, and a reserved static IP. All egress routed through it.

The flight feed stayed dead.

Now the actual diagnosis — variable isolation, one thing at a time:

  1. Region. I’d deployed to europe-west3 (Frankfurt) for OpenSky proximity — their API servers are in Switzerland. Maybe it was a region-specific block. Result: same timeout. The README records the conclusion: “region and static IP do not bypass it.”

  2. Static IP. The NAT IP was reserved and stable. If OpenSky was blocking a dynamic range, a static IP should clear it. Result: same timeout. The dedicated static NAT IP changed nothing.

  3. OAuth mode. Maybe it was rate-limiting on anonymous calls. I switched to OAuth2-authenticated mode (OPENSKY_CLIENT_ID / OPENSKY_CLIENT_SECRET, 4000 credits/day). Result: same timeout. Authentication didn’t matter.

  4. Control feed. The Capital Metro GTFS-RT feed — data.texas.gov — was working perfectly from the same NAT IP, same Cloud Run service, same egress path. If the networking stack were broken, Metro would fail too. It didn’t.

  5. Residential IP. I hit the same OpenSky endpoint from a residential box. HTTP 200, instantly. Aircraft data, no timeout.

That’s the proof. Same endpoint, same authentication, same network path — the only variable that mattered was whether the source IP was a cloud datacenter range. OpenSky silently drops TCP connections from GCP egress IPs. Not a rate-limit, not an outage, not a misconfiguration. IP-level blocking of cloud datacenter ranges, by design. The VPC/NAT stack I’d built was ~$45/mo — the cost commit message says exactly that: ~$45/mo for zero benefit — solving a problem that didn’t have a GCP-side solution.

The fix: stop trying to fetch from GCP. An off-cloud flight pusher runs on a residential host, fetches OpenSky from an unblocked IP, and POSTs the parsed aircraft to an authenticated endpoint (POST /ingest/flight/push, guarded by INGEST_PUSH_TOKEN, compared in constant time). Cloud Run writes them to Firestore. The dashboard renders flights normally.

The box is outbound-only — no inbound ports, nothing to firewall. It holds zero GCP credentials. All Firestore and IAM access stays on Cloud Run. The only secrets on the box are the OpenSky OAuth creds and the shared bearer token. The payload is public flight telemetry, so a strong random secret over HTTPS is an appropriate guard.

Act 3: The cost pass

The initial architecture ran roughly $45–60/mo. The deployed architecture costs ~$0–2/mo. Torn down, it’s under $0.10. The cut happened in a single commit — e82fa2f — and it looked like this:

ChangeWhat it killed
Removed VPC connector + Cloud NAT + static IP~$45/mo line item (always-on connector, min 2 instances)
Cloud Run min_instance_count = 0, max_instance_count = 2Idle compute charge + runaway-bill ceiling
cpu_idle = trueCPU billing while idle
Firestore 24h TTL (expire_at = ingested_at + 24h)Unbounded storage growth on an append-only collection
Dropped flight-ingester scheduler jobInvocations hitting a blocked endpoint for nothing
Metro polling 1m → 2mHalved Cloud Run invocations + Firestore writes

The max-instance cap is the one I’d highlight. Scale-to-zero handles the idle cost. The max-instance cap handles the failure mode nobody talks about: a traffic spike or a crash loop spinning up instances on a portfolio demo with no budget alert. Two instances maximum. The bill cannot run away.

The slow leak I found afterward

The architecture was cheap. The Artifact Registry was not. Every CI deploy pushed a new timestamp-tagged image, and nothing was cleaning up the old ones. Storage accumulates. It’s a slow leak — not the kind of thing that shows up in the first month, but the kind that shows up in month three when you’re paying for forty images you don’t need.

I closed it in the same place the admin permission already lives: infra/bootstrap-cicd.sh. The script now creates the Artifact Registry repository with a two-policy cleanup rule — keep the 5 most recent image versions, delete everything older than 1 day that isn’t protected. I previewed the policy with a dry run before arming it — an operational step, not a committed one — because a deletion policy is exactly the kind of thing you don’t trust on faith. AR evaluates the policies on a recurring basis, so changes take about a day to take effect.

Finding the next problem is table stakes. Closing it before publishing is the point.

What I’d tell you to steal

  1. Architect after you diagnose, not before. I built a $45/mo VPC/NAT stack to solve a problem that didn’t have a GCP-side solution. Five minutes of variable isolation would have told me that before I wrote a line of Terraform. The reasonable-sounding fix is the most expensive mistake you can make.

  2. Isolate one variable at a time, and keep a control. Region, static IP, auth mode, control feed, residential IP — five tests, one variable each. The CapMetro feed working from the same NAT IP was the control that proved the networking stack was fine. Without a control, you’re guessing.

  3. Cap your max instances, not just your min. Scale-to-zero is the cost story. The max-instance cap is the insurance policy. A portfolio demo with no budget alert and no max cap is a crash loop away from a surprise bill.

  4. When a provider blocks your cloud, don’t fight it — route around it. The off-cloud pusher is outbound-only, holds no cloud credentials, and costs nothing to run. It’s less architecturally pure than a VPC egress setup, and it’s the correct answer.

  5. Hunt for the slow leak before you publish. The AR image accumulation wasn’t in the cost commit because I hadn’t found it yet. The cleanup policy is. Finding the next problem is table stakes; closing it before you write the post is the point.