Skip to content

How to Migrate from GCP to AWS: GKE to EKS Step by Step

A GCP to AWS migration guide from a real GKE to EKS move: rehearse the data sync, re-encrypt Vault data, redirect old URLs and decommission without leftovers.

21 min read

A GCP to AWS migration usually starts life as a mapping exercise. Someone lists the running services, finds the AWS counterpart of each, writes Terraform for the target, and the plan reads well: GKE becomes EKS, Cloud SQL becomes RDS, Cloud Storage becomes S3. For stateless apps that picture holds. You build the image, deploy it on the new cluster, point traffic at it, and retire the old copy.

The stateful apps break it. Their databases keep taking writes while you copy them, and data encrypted by a key service in the old cloud has to be re-encrypted before the new deployment can read it. Add changing hostnames and "zero downtime" becomes a promise nobody measured, with a bill on cutover night and another weeks later, when the old project still charges for resources nobody listed.

I recently led a move of five applications from several GKE clusters onto existing EKS clusters: a headless CMS, a React single-page app, a Django and Celery service, a FastAPI auth broker and a small Go service.

Two were stateful, with two PostgreSQL databases (under 1 GB and about 3 GB), about 62 GB of objects and about 300,000 encrypted fields held under dozens of per-customer keys. The plan gave users a maintenance window of about 3 to 4 hours. The data sync itself, measured on a full rehearsal, took 1.5 to 2 hours.

Moving stateful apps between clouds is a data project first. When data has to be re-keyed and hostnames change, the honest promise is a short, measured and reversible window rather than zero downtime. You get there by rehearsing the whole sync against the target, running the cutover as a repeat of that rehearsal, and planning the teardown of the old cloud as carefully as the cutover.

In this guide, you'll map GCP services to AWS, move each kind of data, rehearse the sync, re-encrypt HashiCorp Vault data between clusters, run the cutover with a rollback, redirect old URLs and decommission GCP.

Prerequisites#

This is a planning guide with real commands rather than a lab you run start to finish. It assumes you're comfortable with:

  • Kubernetes Deployments and kubectl, on GKE and on Amazon EKS.
  • PostgreSQL dumps and restores with psql.
  • The gcloud CLI, the AWS CLI and rclone installed on the machine that drives the cutover.
  • An AWS target that already exists, with its network, IAM and cluster in place. The AWS security checklist for small teams covers the baseline to get right first.

Step 1: Map GCP Services to AWS for the Migration#

Start with an inventory of every component and every contract between the apps: which app calls which, which one issues the tokens another accepts, which bucket each reads and writes. Those contracts are what break quietly after a move. My default for each component was "carry it forward and reuse the existing secret values", so the client only had to flag exceptions, and each exception became a conscious choice.

GCP AWS Outcome
GKE (Standard and Autopilot) EKS A namespace on existing clusters
Cloud SQL for PostgreSQL RDS for PostgreSQL One instance, a database per app
Cloud Storage S3 Copied with rclone
Mounted service account JSON keys IRSA No static cloud keys in the cluster
Cloud DNS Route 53 New names only; old zone kept
Istio gateway and Google Cloud load balancing ALB and ACM TLS at the ALB, then nginx
Container Registry ECR Used
Cloud Functions Lambda Not used: a container on EKS
Secret Manager Secrets Manager Not used: SOPS into Kubernetes Secrets
Cloud KMS (Vault auto-unseal) AWS KMS The existing Vault's unseal
Database Migration Service AWS DMS Not used, see Step 2

GCP to AWS service mapping as used in this migration.

Two rows need more than a cell. The source apps reached GCP with service account JSON key files mounted from Kubernetes Secrets. On AWS they use IRSA (IAM roles for service accounts), where a pod's Kubernetes service account maps to an IAM role and gets short-lived credentials, so no long-lived cloud key sits in the cluster any more. That was a small security win from the move itself. For a new setup, AWS now recommends EKS Pod Identity "whenever possible".

For buckets, Google's Storage Transfer Service only writes into Cloud Storage, so it can't help here; AWS DataSync can copy from GCS to S3 and runs agentless in Enhanced mode.

Several things were dropped rather than moved: Argo CD, Istio, the Elastic stack, a MinIO server (its data went to S3) and a staging environment. A migration is the cheapest moment to stop running something, because anything you carry across has to be rebuilt, secured and learned again on the target.

A dedicated EKS cluster was considered and rejected. A namespace on the shared cluster reused the platform's Terraform module pattern, its Vault, its Argo Workflows instance and its Prometheus, Grafana and Loki stack. The small Go service, rewritten from a Cloud Function, became an in-cluster Deployment rather than a Lambda function. The GCP to AWS migration case study covers the engagement as a whole.

Step 2: Choose How the Data Moves#

Every stateful component needs one of two strategies. A dump and restore stops writes, copies everything and starts the app on the copy. Change data capture (CDC) streams changes from the source database's log into the target, so the final switch takes minutes. AWS DMS lists Google Cloud for PostgreSQL as a supported source.

CDC buys a shorter window at the price of a replication pipeline to build, watch and verify. With databases under 1 GB and about 3 GB, that price wasn't worth paying. A full dump captures everything, deletes included, and the larger one restored in about half an hour. Row-level deltas, copying only rows changed since an earlier load, were rejected as unreliable.

Data Size Strategy Runs during the window?
CMS database under 1 GB Full dump and restore Yes
Django service database about 3 GB Full dump and restore Yes
Object storage about 62 GB Bulk copy early, changes only at cutover Only the delta
Vault-encrypted fields about 300,000 Decrypt and re-encrypt (Step 4) Yes

Each kind of data got the cheapest strategy its size allowed.

Export from Cloud SQL#

The Cloud SQL instance's own service account writes the export file, and it has no access to your bucket by default. Grant it roles/storage.objectAdmin on the bucket for the export, as the Cloud SQL export guide describes. Without the grant, the export fails with a 412 error.

SA=$(gcloud sql instances describe ${SOURCE_INSTANCE} \
  --format='value(serviceAccountEmailAddress)')
gcloud storage buckets add-iam-policy-binding gs://${BACKUP_BUCKET} \
  --member="serviceAccount:${SA}" --role=roles/storage.objectAdmin

gcloud sql export sql ${SOURCE_INSTANCE} \
  gs://${BACKUP_BUCKET}/${DB_NAME}.sql.gz \
  --database=${DB_NAME} --offload --async

--offload requests a serverless export, which takes the export work off the source instance. --async returns at once, so poll the operation until it finishes:

gcloud sql operations list --instance=${SOURCE_INSTANCE} --limit=1

When the export is done, the output will look similar to this (illustrative):

Output
NAME                                  TYPE    START                          END                            ERROR  STATUS
a1b2c3d4-0000-0000-0000-example00001  EXPORT  2026-01-01T10:00:00.000+00:00  2026-01-01T10:04:12.000+00:00  -      DONE

Then remove the grant and download the file under the same name:

gcloud storage buckets remove-iam-policy-binding gs://${BACKUP_BUCKET} \
  --member="serviceAccount:${SA}" --role=roles/storage.objectAdmin
gcloud storage cp gs://${BACKUP_BUCKET}/${DB_NAME}.sql.gz ./${DB_NAME}.sql.gz

The export guide also notes:

The export sql command does not contain triggers or stored procedures, but does contain views.

If your schema has either, add them with pg_dump or create them on the target first.

Restore into RDS#

A production RDS instance sits in private subnets, so you reach it over a VPN into the VPC or from a pod inside the cluster. Here the larger dump went from a laptop over the VPN. Streaming a multi-gigabyte dump into a pod through kubectl exec was too slow.

export PGHOST=${RDS_HOST} PGDATABASE=${DB_NAME} PGUSER=${DB_USER} PGSSLMODE=require
psql -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
gunzip -c ${DB_NAME}.sql.gz | psql -v ON_ERROR_STOP=0 -q 2> restore.log

ON_ERROR_STOP=0 keeps going past errors on purpose. A Cloud SQL dump refers to roles that don't exist on RDS, so ownership and GRANT statements fail while the data loads. The log will contain lines similar to these (illustrative):

restore.log
ERROR:  role "example_source_role" does not exist
ERROR:  role "example_source_role" does not exist

Filter those out and read whatever is left. Anything else is a real failure:

grep ERROR restore.log | grep -v 'does not exist'

Then search the restored data for anything that still points at the old cloud. The CMS needed its storage driver switched from local files to S3, its project URL changed, and hundreds of hard-coded old-host URLs rewritten in content fields:

UPDATE ${CONTENT_TABLE}
SET body = replace(body, 'old.example.com', 'new.example.com')
WHERE body LIKE '%old.example.com%';

Put these fix-ups in the runbook, because a fresh dump brings the old values back on every refresh.

Copy objects as a delta#

rclone copy is additive: it copies new and changed objects and never deletes from the destination. After a bulk copy days earlier, the cutover run only moves the difference. The command assumes rclone remotes named gcs and s3:

rclone copy gcs:${SOURCE_BUCKET} s3:${TARGET_BUCKET} --size-only -v

--size-only compares object sizes and nothing else, which makes the delta fast. That was acceptable here because these buckets are append-only: objects are written once and never rewritten. A six-file sha256 spot check on the CMS assets matched too. If your objects get rewritten in place, use a checksum comparison and budget the time.

Step 3: Rehearse the Full Sync on the Target#

The first estimate was 5 to 6 hours of downtime, most of it the re-encryption. Loading production data into the target once cut the plan to a 3 to 4 hour window (allowing up to 5), and a later rehearsal measured the sync itself at 1.5 to 2 hours.

Before the rehearsal, all five apps ran in dev on a restored copy of production data, and the stateful apps passed written checks. For the CMS, 583 of 583 files were present in S3, and reads and writes through IRSA were tested. One check, a row-count comparison between dev and the source, was skipped and recorded as skipped. A list that quietly drops a check reads later as a check that passed.

The rehearsal then ran the whole production sync, export to verification, without freezing GCP: a rehearsal doesn't need a final consistent state, and an offloaded export is a read-only copy. These are single rehearsal measurements:

Sync step Rehearsal time
Headless CMS: database restore and files about 15 min
Django service: database restore about 30 min
Object storage delta about 15 min
Vault re-key of encrypted fields about 40 min
Whole sync 1.5 to 2 h

Single rehearsal measurements. The real window was planned around them.

The rehearsal also fixes the order. Dependencies decide it: here the CMS came before the single-page app that reads from it, and the stateless services, which need only a deploy and a health check, came at the end.

Production data in dev can email real users

Before you restore production data anywhere outside production, make sure outbound email, SMS and webhooks point at a sink or are turned off. Otherwise a password reset in dev can reach a live user.

Step 4: Re-Encrypt Vault Data Between Clusters#

The Django service encrypted sensitive fields with Vault's transit secrets engine. Transit is encryption as a service: the app sends plaintext to Vault under a named key and stores the ciphertext that comes back, such as vault:v1:XjsPW..., in PostgreSQL. The prefix records the key version; the app knows which key name to use, here one per customer. The key material never leaves Vault, so copying the database copies ciphertext only the old Vault can read.

There are three ways out:

Option What it needs Outcome here
Snapshot the old Vault and restore it on the target Compatible Vault versions on both sides Rejected over version mismatch risk
Transit key backup and restore exportable and allow_plaintext_backup enabled on every key. Both are one-way settings, so key material becomes exportable in plaintext for good Not evaluated here; weigh it against your security policy
Decrypt under the old key, encrypt under a new key, update the row Both Vaults reachable from one job, and a resumable job Chosen

Three ways to move transit-encrypted data between Vault clusters.

Transit has its own rewrap endpoint, but it re-encrypts under the latest version of a key inside one Vault, so it doesn't help across clusters. The chosen job worked customer by customer. A simplified sketch of one batch:

rekey_batch.py (sketch)
rows = src_db.fetch("SELECT id, detail FROM findings WHERE customer_id = %s", cid)
plain = source_vault.post(f"transit/decrypt/{key}",
                          batch_input=[{"ciphertext": r.detail} for r in rows])
cipher = target_vault.post(f"transit/encrypt/{key}",
                           batch_input=[{"plaintext": p["plaintext"]} for p in plain])
for row, c in zip(rows, cipher):
    dst_db.execute("UPDATE findings SET detail = %s WHERE id = %s::uuid",
                   c["ciphertext"], row.id)

The names are illustrative, and the real job checked each result for errors. About 7,500 fields were left alone on purpose: history rows belonging to records that had already been deleted. If you skip data deliberately, give verification its own count of it, so "not re-keyed" and "missed" never look the same.

The first full run took about 31 minutes, with a median of 16 seconds per customer and a worst case of 574 seconds; the rehearsal budget used 40. Three problems surfaced on the way.

The first was a size limit. Vault rejected hundreds of fields larger than 1 MB, the largest over 20 MB. The TCP listener doc describes max_json_string_value_length, default 1048576:

Defines the maximum allowed length in bytes for a single string value within a JSON payload.

The JSON limits came with the HCSEC-2025-24 fix for CVE-2025-6203. Splitting fields wasn't possible because the app decrypts each field as one ciphertext, so the limits had to rise on the shared Vault:

vault.hcl (listener stanza)
listener "tcp" {
  # ...existing settings...
  max_request_size             = ${MAX_REQUEST_BYTES}
  max_json_string_value_length = ${MAX_FIELD_BYTES}
  max_json_array_element_count = ${MAX_BATCH_ITEMS}
}

Set the string limit above the base64 size of your largest field, and raise max_request_size (32 MB by default) to match, since a batch carries many fields. max_json_array_element_count caps the size of a batch_input array. On a highly available Vault, roll the change one pod at a time: standbys first, then the active node.

Measure your largest encrypted field before the rehearsal

Vault's default request limits reject large fields part way through a run. Query the largest ciphertext per table on the source first, and raise the listener limits on the target before the rehearsal, not during the cutover.

The second problem was the data path. Piping fields through kubectl exec and copying files with kubectl cp both stalled or truncated the large ones.

A colleague and I moved to staging: one phase decrypts a batch in the old cloud and writes it as an object to a bucket, the other reads it, encrypts it under the new key, updates the rows and deletes the object after the commit. That made the job resumable: on cutover day a transient 401 meant re-running it, and it skipped the customers already done.

Those objects are plaintext. Use a private bucket only the two job identities can read, delete each object on commit, add a short lifecycle rule as a backstop, and confirm the prefix is empty at the end.

The index the re-key job couldn't use

An early version guarded each update by comparing a UUID primary key cast to text with a text parameter. PostgreSQL uses a B-tree index when the indexed column itself is in the comparison; an expression such as id::text needs an index on that expression. So the cast turned every update into a scan. Comparing the column as its own type made the job about 23 times faster.

-- Slow: the cast hides the primary key index
UPDATE findings SET detail = $2 WHERE id::text = $1;

-- Fast: compare the indexed column as its own type
UPDATE findings SET detail = $2 WHERE id = $1::uuid;

The third problem came back two weeks later. To get moving, the app reached Vault with a hand-minted, long-lived token. It expired in dev and decryption broke.

The lasting fix was Vault's Kubernetes auth method: the pod logs in with its service account token and gets a Vault token that expires, so the app has to log in again before it does. Cache the client, re-authenticate before the lease runs out, and retry once on a 403. Without that, a short-lived token only moves the outage from weeks away to within the token's lifetime.

Step 5: Run the Cutover in a Fixed Order#

By cutover day nothing should be new: every command has run in the rehearsal except the write freeze and the DNS switch.

flowchart TD A[RDS snapshot<br/>rollback point] --> B[DNS TTL lowered<br/>to 60 s] B --> C[Freeze writes<br/>on GCP] C --> D[Export and restore<br/>databases] D --> E[Copy object delta] E --> F[Re-key encrypted fields] F --> G[Scale GCP to 0] G --> H[Start apps on EKS] H --> I{Checks pass?} I -- yes --> J[Repoint old DNS,<br/>canary first] I -- no --> K[Roll back:<br/>scale GCP up]

The cutover sequence. Nothing on GCP is deleted until the decommission change.

Take the snapshot and lower the TTL (time to live, how long resolvers cache a DNS answer) well before the window, so caches have expired by the time you switch:

aws rds create-db-snapshot \
  --db-instance-identifier ${RDS_INSTANCE} \
  --db-snapshot-identifier pre-cutover-$(date +%F)

The freeze stops every writer. Here the background workers went to zero first; the web pods stayed up but idle because the re-key job decrypted from inside one of them, and they went to zero once it finished. If your re-key needs a pod in the old cluster, plan that exception explicitly. Record the replica counts before you touch anything, because the rollback needs them:

kubectl --context ${GKE_CONTEXT} -n ${NAMESPACE} get deployments \
  -o custom-columns=NAME:.metadata.name,REPLICAS:.spec.replicas > pre-freeze-replicas.txt
kubectl --context ${GKE_CONTEXT} -n ${NAMESPACE} scale deployment ${WORKER} --replicas=0

Then run Step 2's export, restore and rclone copy, then Step 4's re-key job. Scale the remaining GCP pods to zero, start the apps on EKS, and validate before any DNS changes:

  • Every pod is Ready with 0 restarts
  • Every health check returns 200
  • Every table in each restored database matches the source row for row
  • The re-key verification reports no fields left undone, apart from the ones skipped on purpose
  • A cross-app login works: the auth broker issues a JWT and the service that checks it accepts it
  • Sentry receives events and Loki receives logs from the new pods

GCP stays at zero replicas rather than deleted so you can roll back: scale each Deployment back to its count in pre-freeze-replicas.txt and revert any DNS you changed. If the database on RDS is the problem, re-run the restore or restore the snapshot. An RDS snapshot restore always creates a new DB instance with a new endpoint, and it gets the default parameter group and default VPC security group unless you name others.

An autoscaler won't bring a frozen deployment back

Scaling a Deployment to 0 deactivates its HorizontalPodAutoscaler until you set the replicas above zero again. Put the explicit kubectl scale commands, with the right counts, in both the start-up step and the rollback.

After a successful cutover, rotate any secrets you carried forward. Reusing values kept the move simple, but they have now lived in two clouds.

Want a second pair of eyes on your cutover plan?

Most of the risk in this step is decided weeks earlier: the strategy for each piece of data, whether the rehearsal ran on real data, and whether the rollback was tested. I offer a focused review of cloud migration plans that covers the service map, the data sync and its timings, encrypted data, DNS and redirects, rollback and the decommission list, with written findings your team can act on. Ask for a migration plan review or book a free 20-minute intro call.

Step 6: Redirect Old URLs to the New Hostnames#

The apps moved to new hostnames, so the old ones had to answer with a 301 Moved Permanently for bookmarks, links in old emails and other apps' configs. The obvious home for the redirect was the internal ALB that already served the apps. A dry run in dev ruled it out: a VPN connector that served the old DNS zone re-advertised the internal ALB's addresses as /32 routes, which beat the broader subnet route but pointed at a connector that couldn't reach those addresses. Traffic to other internal apps on the same load balancer was blackholed.

The redirect went on the public ALB instead, which needs a certificate for each old hostname. Request it in ACM and add its validation record in the old zone, which still lives in Cloud DNS:

aws acm request-certificate --domain-name app.old.example.com --validation-method DNS
aws acm describe-certificate --certificate-arn ${OLD_HOST_CERT_ARN} \
  --query 'Certificate.DomainValidationOptions[0].ResourceRecord'
gcloud dns record-sets create ${VALIDATION_NAME} --zone=${OLD_ZONE} \
  --type=CNAME --ttl=300 --rrdatas=${VALIDATION_VALUE}

With the AWS Load Balancer Controller, a redirect is an Ingress whose backend is an action defined in an annotation:

old-url-redirect.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: old-url-redirect
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/group.name: ${PUBLIC_ALB_GROUP}
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}]'
    alb.ingress.kubernetes.io/certificate-arn: ${OLD_HOST_CERT_ARN}
    alb.ingress.kubernetes.io/actions.redirect-app: >
      {"type": "redirect", "redirectConfig": {"host": "app.new.example.com",
       "port": "443", "protocol": "HTTPS", "statusCode": "HTTP_301"}}
spec:
  ingressClassName: alb
  rules:
    - host: app.old.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: redirect-app
                port:
                  name: use-annotation

listen-ports is merged across every Ingress in an IngressGroup, so a new port here opens a listener for the whole shared ALB. Keep this Ingress to ports the group already serves.

The redirect only sets the host, port and protocol. ALB redirect actions keep the original path and query unless you override them, so deep links land on the matching page. Test the rule before touching DNS by pinning the old hostname to one of the ALB's addresses:

ALB_IP=$(dig +short ${ALB_DNS} | head -1)
curl -sSI --resolve app.old.example.com:443:${ALB_IP} \
  'https://app.old.example.com/some/path?q=1' | grep -iE 'HTTP/|location'

The output will look similar to this (illustrative):

Output
HTTP/2 301
location: https://app.new.example.com/some/path?q=1

Then repoint the old records one at a time, with the lowest-risk hostname as the canary, and repeat the check against live DNS.

Two follow-ups tend to get missed. The ACM doc says it renews a DNS-validated certificate "as long as the certificate is in use and your CNAME record remains in place". The old zone in Cloud DNS is now a dependency for as long as the redirects live, and the decommission list has to say so.

The other is a hostname nobody listed. One dev hostname's DNS record was never repointed, and nobody noticed for more than a month, until the decommission sweep found it. Keep a list of every old hostname and check each one with curl after the switch.

Step 7: Decommission GCP as a Separate Change#

Teardown deserves its own reviewed change, after the new setup has run for a while. The migrated apps' GCP resources were gone 5 to 8 weeks after the cutover, over three passes; the old DNS zone stays on purpose. Each pass found a resource type nobody had listed.

Leftover Why it survived How to find it
Reserved static IP addresses Deleting the cluster did not release them gcloud compute addresses list
A 1 TiB Filestore instance It kept billing after its volume claim was deleted, with nothing stored on it and no file operations in the 30 days checked gcloud filestore instances list
A network endpoint group (NEG) It blocked deletion of the VPC gcloud compute network-endpoint-groups list

Three leftovers from one teardown, each a resource type nobody had listed.

Kubernetes' documentation says dynamically provisioned volumes default to a Delete reclaim policy that removes the backing storage, and yet this instance survived. Why isn't known: a Retain policy and a cluster deleted before its volumes are both possible. Either way, check the backing resource, not the Kubernetes object.

A teardown checklist that would have caught all of them:

  • Export the project's billing report by service and SKU, and list each billable resource type
  • Before deleting a cluster, list its PersistentVolumes with their reclaim policies and the disks or file shares behind them
  • After deleting it, list addresses, disks, Filestore instances, NEGs, forwarding rules and backend services directly
  • List Cloud NAT gateways, Cloud Routers and VPC peerings; NAT IP addresses fall back to reserved and keep billing once their gateway is gone
  • Mark what must stay, such as the DNS zone that validates the redirect certificates
  • Watch the next billing cycle for anything that didn't drop to zero

Troubleshooting#

Symptom Cause Fix
gcloud sql export sql fails with a 412 error The instance's service account can't write to the bucket Grant roles/storage.objectAdmin on the bucket for the export, then remove it (Step 2)
Vault returns "JSON string value exceeds allowed length" A field is larger than max_json_string_value_length Raise the listener limits on the target before the run (Step 4)
kubectl exec pipes or kubectl cp stall or truncate data Large payloads through the Kubernetes API connection Stage batches in a private bucket, or restore from a host on the VPN (Steps 2 and 4)
A Deployment stays at 0 after the cutover Its HorizontalPodAutoscaler was deactivated at 0 replicas Scale it up by hand with the recorded count (Step 5)
Vault returns 403 "permission denied" weeks after deploy A long-lived token expired Use the Kubernetes auth method and re-authenticate before expiry (Step 4)
VPC deletion is refused A network endpoint group still references the network Delete the NEG with gcloud compute network-endpoint-groups delete, then retry (Step 7)

Conclusion#

The cost of this plan is a maintenance window users hear about in advance, a full rehearsal on production data, and a separate decommission change weeks later. In return, a 5 to 6 hour first estimate became a 3 to 4 hour planned window around a sync measured at 1.5 to 2 hours.

Next, take your own inventory and fill in the mapping table from Step 1, with a column for "dropped". Check whether your transit keys could be made exportable with plaintext backup, and whether your security policy allows it.

Measure your largest encrypted field, and time a full sync into the target before you promise anyone a window. If the move also restructures AWS accounts, read how to move an AWS account to another organization, and the guide to SCPs, RCPs and declarative policies for guarding the new accounts.

If your team is planning a move between clouds, or onto EKS, I can help plan and run it, from the service map to the decommission. You can book a free 20-minute intro call or tell me about your migration.

Frequently asked questions

Can you migrate from GCP to AWS with zero downtime?

For stateless apps, often yes: run both copies and move traffic. For stateful apps it is rarely an honest promise. Writes have to stop while the final data copy runs, encrypted data may need re-encrypting under new keys, and hostnames may change. A short, rehearsed maintenance window with a tested rollback is the safer commitment.

What is the AWS equivalent of Cloud SQL for PostgreSQL?

Amazon RDS for PostgreSQL, or Aurora PostgreSQL if you want its storage model. Small databases move well as a full dump and restore during a write freeze. Larger or busier ones are candidates for change data capture with AWS DMS, which lists Google Cloud for PostgreSQL as a supported source.

How do you copy Cloud Storage buckets to Amazon S3?

Use a tool that reads from Cloud Storage and writes to S3, such as rclone or AWS DataSync, which supports Google Cloud Storage as a source and runs agentless in Enhanced mode. Google's Storage Transfer Service is not an option for this direction, because it only writes into Cloud Storage. Copy most data early, then copy only the changes at cutover.

What gets left behind when you decommission a GCP project?

Resources nobody put on the list. Deleting a GKE cluster can leave reserved static IP addresses, network endpoint groups that block VPC deletion, and file storage such as Filestore instances whose Kubernetes volume objects are already gone. List every billable resource type in the project before you delete anything, and check each backing resource directly.

Shahid Yousuf

Senior Software Engineer

I build and consult on software across web, mobile, cloud and AI. Have something in mind? Let's talk.

Hire me Book a free intro call See case studies

Get new posts

No newsletter or email list. Follow along in any feed reader.

AWS Security Checklist for Small Teams

An AWS security checklist for small teams, built on the AWS Startup Security Baseline: 19 controls in three tiers, each with a command, a verify step and its cost.

AWS27 min read

GitHub Actions OIDC Trust Policy for AWS

GitHub now issues two OIDC subject formats. Here is a trust policy that accepts both, a way to prove it against every repository, and a safe order for retiring keys.

AWS14 min read

Have a project in mind?

Share your goals, timeline and any constraints, whether you need it built or want expert advice. I read every message myself, as the engineer who would build it, and reply with a clear view on approach, scope and next steps.

Hire me