Why we migrated at all
I spent the second half of 2025 on contract with a US retail and delivery startup, moving their entire production stack from AWS to Google Cloud. That was roughly 25 services on a Python and Go codebase. The trigger wasn’t a technical purity argument. It came down to pricing commitments, a data/ML roadmap that leaned on GCP, and a platform that had grown organically enough that a re-platform was cheaper than a slow refactor in place. One constraint shaped every decision: this is a live delivery business, so a maintenance-window cutover was off the table. It had to be incremental and effectively zero-downtime.
Mapping the stack, service by service
The first month was inventory and mapping, not Terraform. AWS and GCP are close enough to feel interchangeable and different enough to hurt you if you assume that. The mapping we landed on: containers moved to GKE. Managed relational databases went to Cloud SQL for PostgreSQL, where PostGIS was a hard requirement, because this is a delivery business and geospatial queries are load-bearing. Redis moved to Memorystore, event-driven glue to Cloud Functions, and the north-south edge to API Gateway plus Google Cloud load balancers. Stripe billing and Firebase stayed where they were and got treated as external dependencies, which conveniently meant they didn’t move on the critical path.
I split the ~25 services into waves by blast radius. Stateless services with no persistent storage went first. They’re the cheap, reversible experiments that shake out DNS, IAM, egress, and image-registry issues before anything irreversible happens. Stateful services and anything sitting in the request-critical path went last, once the platform underneath them was boring and well-understood. Everything was described in Terraform, with GitLab CI running plan/apply per environment, so GCP infrastructure came up as reviewed code rather than console clicks.
Seeding, then replicating: the data problem
Data was the part that actually kept me up. Moving stateless containers is a solved problem. Moving a live PostgreSQL database that’s taking writes every second, without losing a row, is where migrations go to die.
The approach was two-phase. First, backup-and-restore seeding: take a consistent snapshot of the source database and restore it into Cloud SQL to establish a baseline. That gets you a target that’s correct as of a point in time but immediately stale, since the source keeps moving. The second phase was ongoing DMS replication from AWS into GCP, streaming the delta continuously so the target tracked the source and replication lag stayed low and observable. The seed gets you the bulk of the data cheaply and quickly. Replication closes the gap and, crucially, keeps it closed until you’re ready to cut over.
The property this buys you is that the actual cutover becomes small. Instead of “copy a database during an outage,” the cutover is “stop writes on the old side, let replication drain the last few seconds, point the app at the new side.” A short, well-understood operation instead of a multi-hour gamble. I treated replication lag as a first-class signal. You do not cut over on faith. You cut over when lag is near zero and the target reconciles.
Cutover sequencing and rollback thinking
Per service, the cutover was scripted and deliberately dull: verify replication is caught up, drain and quiesce writes on the AWS side, confirm the GCP target has settled, flip the config or DNS/routing so traffic lands on GCP, then watch. Doing this per wave rather than big-bang meant a bad cutover affected one slice of the system instead of all of it, and the earlier stateless waves had already de-risked the mechanics.
Rollback was a design input, not an afterthought. For stateless services, rollback is trivial. You point routing back at AWS, which is still running. Stateful is the honest hard part. The moment GCP takes writes, AWS is no longer authoritative, and “roll back” stops meaning “flip a switch.” So the plan leaned on keeping the old environment warm and running through each wave, keeping the cutover window short enough that the blast radius of a bad flip was tiny, and defining explicit go/no-go checks (lag, error rates, health) before quiescing writes rather than discovering problems after. The uncomfortable truth I keep relearning: for stateful cutovers, your real safety net is a fast, confident forward fix and rigorous pre-flight checks, not a fantasy of instant reversal once writes have moved.
Takeaways
- Re-platforming is a mapping problem before it’s a Terraform problem. The month spent on AWS-to-GCP service equivalence and hard requirements (PostGIS, Redis semantics) saved far more than it cost.
- Seed then replicate. Backup-and-restore for the bulk, continuous DMS replication for the delta. That combination is what turns a multi-hour outage into a few-second cutover.
- Wave by blast radius. Stateless and reversible first to shake out the platform; stateful and request-critical last, once everything under them is boring.
- Treat replication lag as a go/no-go signal. Cut over on measured lag, never on faith.
- Rollback for stateful services is mostly a myth, so plan around it. Keep the old side warm, keep windows short, front-load the checks, and be ready to fix forward.
- Keep external dependencies external. Leaving Stripe and Firebase off the critical path removed whole categories of migration risk for free.