GCP migration analysis — near-zero cost alternatives
Decision document. Evaluates leaving GCP entirely to close the gap to $0/mo for Oter + Negodex together. This supersedes the “stay on GCP; no platform migration” stance in gcp-cost-cuts.md — that stance was correct for May 2026 waste-cutting; this doc reopens the question for a different goal (near-zero cost, ops burden accepted).
This is analysis, not a queued migration. Execution would be a separate plan.
Context
The May 2026 cost cuts (see gcp-billing.md) brought monthly GCP spend to ~$22–38 (Scenario A: April total was the whole billing account) or ~$57–67 (Scenario B: Negodex SQL was extra). Cloud SQL now dominates at ~$46–52/mo; every other SKU is effectively at free tier.
The question this doc answers: is it worth leaving GCP entirely for a near-$0 target? Chosen profile:
- Cost target: $0–5/mo (aggressive)
- Ops tolerance: willing to own a VPS
- Scope: Oter + Negodex together (they share
oter-db— splitting them recreates the ~$50/mo second-instance problem, so they migrate as one unit) - Firebase FCM: stays on the existing
oter-8c4e4project regardless — free tier, host-agnostic
Current state (verified 2026-07-07)
| Component | Today on GCP | Monthly |
|---|---|---|
Ktor server (oter) | Cloud Run, 1 vCPU / 512 MiB, scale-to-zero | ~$3–5 |
Next.js web (oter-web) | Cloud Run, scale-to-zero | ~$2–3 |
Internal docs (oter-docs-internal) | Cloud Run + Cloud IAP | ~$1 |
Nest API (Negodex api) | Cloud Run, scale-to-zero (project negodex) | ~$1–2 |
Postgres (oter-db) | Cloud SQL db-custom-1-3840, 10 GB, shared by both apps | ~$46–52 |
| Attachments | GCS bucket oter-attachments-prod (STANDARD → NEARLINE @ 90d → COLDLINE @ 365d) | ~$0–2 |
| Secrets | Secret Manager, mounted at deploy | ~$0 |
| CI/CD auth | Workload Identity Federation | ~$0 |
| Logging | 1-day retention + severity exclusions | ~$0 (free tier) |
| Total (Scenario A / B) | ~$22–38 / ~$57–67 |
Cloud SQL is ~80% of the bill. Every other lever is already pulled.
Recommendation
Move both apps to a single Hetzner ARM VPS (CAX11, ~$4/mo) in Ashburn US-East, running Docker Compose, with Cloudflare R2 for attachments and Cloudflare Access for the internal docs site.
Why this over the alternatives (details in Alternatives considered):
- Only option that plausibly lands under ~$6/mo total for both apps.
- Ashburn keeps LATAM latency in the same region class as today’s
us-east1(delta <20ms). - Zero reclamation risk (unlike Oracle Always Free).
- Runs on infrastructure we already understand: Docker Compose, Caddy, Postgres.
Target topology
Hetzner CAX11 (Ashburn) — 2 vCPU ARM, 4 GB RAM, 40 GB SSD, 20 TB egress
└── Docker Compose
├── caddy — TLS + reverse proxy (auto Let's Encrypt)
├── postgres:18 — one instance, two databases (oter_prod + myecommercedb), volume-mounted
├── oter-server — Ktor container from GHCR
├── oter-web — Next.js container from GHCR
├── negodex-api — Nest container from GHCR
├── oter-docs — nginx serving static docs, behind Cloudflare Access (free)
└── (optional) uptime-kuma for self-hosted monitoring
Cloudflare R2 — attachments bucket (S3 API, $0 egress, 10 GB free)
Cloudflare Pages — wasmJS + static docs (already free-tier friendly)
GitHub Container Registry — image storage
Firebase FCM — unchanged (still push notifications)Cost projection
| Line | Monthly |
|---|---|
| Hetzner CAX11 ARM | ~$4.00 |
| Hetzner Cloud backups (20% surcharge, optional) | ~$0.80 |
| Cloudflare R2 storage (~5 GB attachments) | ~$0.00 (10 GB free) |
| Cloudflare Access / Pages / DNS | ~$0.00 |
| GHCR (small private images) | ~$0.00 |
| Domain (already owned) | — |
| Total | ~$4.80/mo |
That’s a ~78–92% reduction vs current post-optimization spend, and ~95% vs the April 2026 baseline.
Alternatives considered
| Option | Cost | Why not |
|---|---|---|
| Oracle Cloud Always Free (Ampere A1: 4 vCPU, 24 GB RAM) | $0/mo | Highest saving, but Oracle has a well-documented history of reclaiming idle Always-Free ARM instances with 24h notice. Fine for a hobby app; not acceptable when Negodex runs on it. Reasonable fallback if Hetzner ever raises prices meaningfully. |
| Contabo VPS S (4 vCPU, 8 GB) | ~$5/mo | Cheaper per core than Hetzner but Germany-only (higher LATAM latency), history of shared-CPU noisy-neighbor issues, weaker network reputation. |
| Fly.io (Machines + Managed Postgres) | ~$8–12/mo | Preserves scale-to-zero and matches Cloud Run architecturally. Managed Postgres tier is still $19/mo for anything non-trivial, and reliability track record (2022–2023 outages) is weaker than a dedicated VPS. Saves less than Hetzner. |
| Neon / Supabase for Postgres only, keep Cloud Run | ~$10–25/mo | Lowest-effort option (~1 day of work): pg_dump → new host, update JDBC URL, done. At $19/mo Neon Launch or $25/mo Supabase Pro it isn’t “near-zero” — it fits the “best cost/effort ratio” profile that was explicitly deprioritized here. |
| AWS Lightsail / DigitalOcean Droplet | ~$5–7/mo | Comparable to Hetzner on price but worse per resource unit. No decisive advantage. |
What actually breaks (change surface)
The migration doc for future-us should enumerate these; not implementation steps, just the surface area.
- Postgres connectivity.
oter-dbconnection string switches from the Cloud SQL socket factory to a standard JDBC URL.server/src/main/kotlin/com/esteban/ruano/utils/Config.ktalready handles both — it has a/cloudsql/socket-factory branch (line ~102) and a standard-JDBC branch. This is essentially an env-var change (DB_HOST/DB_URL). Same for Negodex Nest API. - Attachments.
server/src/main/kotlin/com/esteban/ruano/service/GcsFileStorageService.ktbecomes an S3-client implementation pointed at R2. R2 exposes an S3-compatible API, sosoftware.amazon.awssdk:s3with a custom endpoint works. Lifecycle rules (STANDARD → NEARLINE → COLDLINE) become R2 lifecycle rules (fewer tiers; at single-digit GB the difference is <$1/mo). - Secrets. Secret Manager mounts (
--set-secretsinpublish-server-release.yml) become.envfiles on the VM, owned by root, read bydocker compose. Rotate via SSH +docker compose up -d. Trade-off: no audit trail, no per-service scoping. - CI/CD. GitHub Actions currently uses WIF →
gcloud run deploy. New flow: build container → push to GHCR → SSH to the VM →docker compose pull && docker compose up -d. Requires an SSH deploy key stored as a repo secret. Three workflows to rewrite:publish-server-release.yml,publish-web-release.yml,publish-docs-release.yml. - Internal docs auth. Cloud IAP → Cloudflare Access (free tier: up to 50 users, Google/GitHub SSO). Nearly at-parity feature-wise.
- Firebase / FCM. No code change;
NotificationService.ktalready reads credentials from a file path. Credentials JSON just moves from Secret Manager to a mounted file. - Backups. Cloud SQL’s 7-day retained backups + 7-day PITR become
pg_dumpallcron → R2 (encrypted). Hetzner snapshots (~$0.40/mo) cover the VM. No PITR. Accept RPO of ~24h. A restore drill should be documented and run once before cutover. - Observability. Cloud Logging →
docker compose logs+ optional Loki Cloud free tier (already wired inlogback.xml). Uptime monitoring via Uptime Kuma on the same VM or Better Stack free tier. - DNS + TLS. Move DNS to Cloudflare (or keep current), point A records at the VM, Caddy handles Let’s Encrypt automatically.
- Dependency cleanup.
server/build.gradle.ktscan dropcom.google.cloud.sql:postgres-socket-factoryandcom.google.cloud:google-cloud-storage. Keepfirebase-admin.
Risk register
| Risk | Mitigation |
|---|---|
| Single point of failure — VM reboot ≈ 1 min downtime for both apps | Acceptable for personal life-OS + small ecommerce. Document a maintenance-window policy. Consider a $4/mo standby that pulls the same images if downtime becomes user-visible. |
| No managed PITR on Postgres | Nightly encrypted pg_dumpall to R2 + a weekly restore drill. RPO 24h, RTO ~15 min. |
LATAM latency slightly worse from Ashburn vs us-east1 | Same region class (US East), delta typically <20ms. Nürnberg is a fallback with different tradeoffs. |
| Ops burden creep — OS patches, Postgres major upgrades | Budget one Sunday per quarter for apt upgrade + docker compose pull. Pin Postgres major, upgrade deliberately. Caddy handles TLS renewal. |
| Losing WIF audit trail / IAM granularity | Accept it. Rotate SSH keys yearly, restrict management surfaces behind Cloudflare Access. |
| Cloudflare R2 free-tier changes | Falls back to $0.015/GB storage + $0 egress — still cheap. |
| Attachments bucket lifecycle (NEARLINE/COLDLINE savings) doesn’t map exactly | R2 has fewer tiers; at current scale <$1/mo difference. |
Trigger conditions for revisiting
Migrate now if:
- Current spend consistently exceeds ~$30/mo (Scenario B is live) and no further GCP-side cut is available.
- We’re comfortable owning weekly ops for both apps.
Defer migration if:
- Scenario A is confirmed by billing export and monthly spend stays under ~$25.
- Traffic grows enough that a $4 VPS won’t fit — at that point the analysis changes (managed Postgres becomes worth its price).
- We plan to add more apps to the shared Cloud SQL instance (amortizes the fixed cost further and pushes the crossover further out).
Revisit within a year regardless if:
- Hetzner raises prices >20%.
- Oracle Always Free ARM reclamation policy changes materially (would make it a genuine alternative).
- We stop needing Negodex — the shared-SQL argument collapses and Neon Launch ($19) becomes the obvious answer for Oter alone.
Related
- gcp-billing.md — baseline, cuts applied, forecast
- gcp-cost-cuts.md — May 2026 runbook (stay-on-GCP stance; superseded by this doc for the near-zero goal)
- hetzner-migration-runbook.md — the follow-up runbook that executed this analysis