{"id":80734,"date":"2026-07-29T13:13:10","date_gmt":"2026-07-29T07:43:10","guid":{"rendered":"https:\/\/www.tothenew.com\/blog\/?p=80734"},"modified":"2026-07-30T15:54:52","modified_gmt":"2026-07-30T10:24:52","slug":"terraform-state-migration-seamlessly-moving-from-aws-s3-to-google-cloud-storage","status":"publish","type":"post","link":"https:\/\/www.tothenew.com\/blog\/terraform-state-migration-seamlessly-moving-from-aws-s3-to-google-cloud-storage\/","title":{"rendered":"Terraform State Migration: Seamlessly Moving from AWS S3 to Google Cloud Storage"},"content":{"rendered":"<p><strong>Introduction:<\/strong><\/p>\n<p>What if the single source of truth for your entire infrastructure lived in the wrong cloud?<br \/>\nFor years, our Terraform state lived in an AWS S3 bucket while our infrastructure increasingly ran on Google Cloud Platform. That split created cross-cloud dependencies, IAM complexity, and a growing mismatch between where we managed infrastructure and where we stored its state.<\/p>\n<p>Terraform state is the record of what Terraform believes exists in the real world. Move it incorrectly, and the next Terraform apply can propose destructive changes against production.<\/p>\n<p>This post covers how we migrated hundreds of Terraform projects from AWS S3 to Google Cloud Storage (GCS), how we verified each migration, and the challenges we solved along the way.<\/p>\n<h2>Why this matters:<\/h2>\n<p>Operational alignment\u00a0\u2014 State storage should live in the same cloud as the resources being managed.<br \/>\nOrg structure\u00a0\u2014 We operate multiple organizational tiers, each with its own GCS state bucket.<br \/>\nSafety at scale\u00a0\u2014 With 100+ CI-managed Terraform projects, ad-hoc\u00a0terraform state mv\u00a0commands were not an option.<\/p>\n<h2>What you&#8217;ll learn in this article:<\/h2>\n<p>Our migration architecture and phased approach<br \/>\nStep-by-step instructions for copying state and switching backends<br \/>\nHow we automated validation across multiple repos<br \/>\nChallenges we faced: path mismatches, stale datasources, and state drift and how we solved them<\/p>\n<h2>Understanding Our Starting Point:<\/h2>\n<p>Before (S3):<\/p>\n<div id=\"attachment_80733\" style=\"width: 652px\" class=\"wp-caption aligncenter\"><a href=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/s3-state.png\"><img aria-describedby=\"caption-attachment-80733\" decoding=\"async\" loading=\"lazy\" class=\"size-full wp-image-80733\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/s3-state.png\" alt=\"alt=&quot;&quot;\" width=\"642\" height=\"344\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2026\/07\/s3-state.png 642w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/s3-state-300x161.png 300w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/s3-state-624x334.png 624w\" sizes=\"(max-width: 642px) 100vw, 642px\" \/><\/a><p id=\"caption-attachment-80733\" class=\"wp-caption-text\">S3 backend config<\/p><\/div>\n<p>After (GCS):<\/p>\n<div id=\"attachment_80732\" style=\"width: 656px\" class=\"wp-caption aligncenter\"><img aria-describedby=\"caption-attachment-80732\" decoding=\"async\" loading=\"lazy\" class=\"wp-image-80732 size-full\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/gcs-state.png\" alt=\"\" width=\"646\" height=\"260\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2026\/07\/gcs-state.png 646w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/gcs-state-300x121.png 300w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/gcs-state-624x251.png 624w\" sizes=\"(max-width: 646px) 100vw, 646px\" \/><p id=\"caption-attachment-80732\" class=\"wp-caption-text\">GCS backend config<\/p><\/div>\n<p>Key convention: the S3 key maps verbatim to the GCS prefix.<\/p>\n<h2><strong>Migration Targets:<\/strong><\/h2>\n<p><img decoding=\"async\" loading=\"lazy\" class=\"size-full wp-image-80731 aligncenter\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-target.png\" alt=\"\" width=\"1588\" height=\"332\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-target.png 1588w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-target-300x63.png 300w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-target-1024x214.png 1024w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-target-768x161.png 768w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-target-1536x321.png 1536w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-target-624x130.png 624w\" sizes=\"(max-width: 1588px) 100vw, 1588px\" \/><\/p>\n<p>Some consumer projects that had already landed in the enterprise bucket were later moved to the consumer bucket via a separate\u00a0GCS-to-GCS\u00a0migration driver.<\/p>\n<h2><strong>Migration Architecture: Four Phases<\/strong><\/h2>\n<p><img decoding=\"async\" loading=\"lazy\" class=\"size-full wp-image-80730 aligncenter\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-architecture.png\" alt=\"\" width=\"1576\" height=\"192\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-architecture.png 1576w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-architecture-300x37.png 300w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-architecture-1024x125.png 1024w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-architecture-768x94.png 768w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-architecture-1536x187.png 1536w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-architecture-624x76.png 624w\" sizes=\"(max-width: 1576px) 100vw, 1576px\" \/><\/p>\n<p>We treated state migration as a pipeline, not a one-shot operation. Each Terraform project progresses through four distinct phases: Each phase is independently verifiable, which was critical when migrating at scale via pull-request-driven CI (e.g., Atlantis).<\/p>\n<p><strong>Phase 0: Inventory and Classification<br \/>\n<\/strong><br \/>\nBefore touching state, we built\u00a0validate_state_migration.py\u00a0to scan both repos and label every Terraform directory:<\/p>\n<div id=\"attachment_80893\" style=\"width: 1228px\" class=\"wp-caption aligncenter\"><a href=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/inventory-classification.png\"><img aria-describedby=\"caption-attachment-80893\" decoding=\"async\" loading=\"lazy\" class=\"size-full wp-image-80893\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/inventory-classification.png\" alt=\"alt=\" width=\"1218\" height=\"442\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2026\/07\/inventory-classification.png 1218w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/inventory-classification-300x109.png 300w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/inventory-classification-1024x372.png 1024w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/inventory-classification-768x279.png 768w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/inventory-classification-624x226.png 624w\" sizes=\"(max-width: 1218px) 100vw, 1218px\" \/><\/a><p id=\"caption-attachment-80893\" class=\"wp-caption-text\">validate state migration<\/p><\/div>\n<p>This became our living migration dashboard and primary completion gate.<\/p>\n<p>&nbsp;<\/p>\n<p><strong>Phase 1: Copy State Files (S3 \u2192 GCS)<br \/>\n<\/strong><br \/>\nInstead of per-project\u00a0gsutil cp, we built Terraform migration drivers:<\/p>\n<p>state-migration\/main.tf\u00a0\u2014 v1 (frozen, lookup-only)<br \/>\nstate-migration-v2\/main.tf\u00a0\u2014 v2 (new paths added here)<br \/>\ngcs-state-migration\/main.tf\u00a0\u2014 GCS-to-GCS (enterprise \u2192 consumer)<br \/>\nCore pattern:<\/p>\n<p style=\"text-align: left;\">data &#8220;aws_s3_object&#8221; &#8220;s3_to_gcs_migration&#8221; {<br \/>\nfor_each = toset(local.migration_paths)<br \/>\nbucket = local.source_bucket<br \/>\nkey = each.value<br \/>\n}<\/p>\n<p style=\"text-align: left;\">resource &#8220;google_storage_bucket_object&#8221; &#8220;s3_to_gcs_migration&#8221; {<br \/>\nfor_each = toset(local.migration_paths)<br \/>\nbucket = local.destination_bucket<br \/>\nname = &#8220;${each.value}\/default.tfstate&#8221;<br \/>\ncontent = data.aws_s3_object.s3_to_gcs_migration[each.value].body<br \/>\ndeletion_policy = &#8220;ABANDON&#8221;<br \/>\nlifecycle { ignore_changes = [content, crc32c, generation, md5hash] }<br \/>\n}<br \/>\nDesign choices:<\/p>\n<p>deletion_policy = &#8220;ABANDON&#8221;\u00a0\u2014 destroying the driver never deletes production state.<br \/>\nignore_changes\u00a0\u2014 driver doesn&#8217;t fight live state updates after initial copy.<br \/>\nOne-time copy, not continuous sync.<br \/>\nSteps:\u00a0add S3\u00a0key\u00a0to\u00a0migration_paths\u00a0\u2192 PR \u2192 CI apply \u2192 verify with\u00a0gsutil ls.<\/p>\n<p>Important: State changes between copy and backend switch cause drift. Delete the stale GCS object and re-run migration if needed.<\/p>\n<p><strong>Phase 2: Switch Backend<br \/>\n<\/strong><br \/>\nUpdate\u00a0backend &#8220;s3&#8221;\u00a0to\u00a0backend &#8220;gcs&#8221;\u00a0in the project&#8217;s\u00a0main.tf. Open a PR \u2014 CI runs\u00a0terraform init -reconfigure\u00a0and\u00a0terraform plan.\u00a0Zero unexpected changes is the validation gate.\u00a0We kept the old S3 backend commented out for easy rollback.<\/p>\n<p><strong>Phase 3: Migrate Datasources<br \/>\n<\/strong><br \/>\nMany projects read upstream state via\u00a0terraform_remote_state\u00a0\u2014 those also pointed at S3. We automated rewrites with a script that:<\/p>\n<p>Changes\u00a0backend = &#8220;s3&#8221;\u00a0\u2192\u00a0&#8220;gcs&#8221;, maps bucket, converts\u00a0key\u00a0\u2192\u00a0prefix, drops\u00a0region<br \/>\nFollows relative module sources into shared\u00a0implementation\/\u00a0dirs<br \/>\nSafety gate:\u00a0skips datasources whose upstream state isn&#8217;t copied yet<br \/>\nA two-phase MR workflow (whitespace baseline commit, then actual rewrite) ensured CI plans showed datasource-only diffs with no resource changes.<\/p>\n<p><strong>Phase 4: GCS-to-GCS (Org Split)<br \/>\n<\/strong><br \/>\nProjects that landed in the enterprise bucket but belonged to the consumer tier used the same driver pattern reading from one GCS bucket and writing to another.<\/p>\n<h2>How to Verify Migration: <!--more--><\/h2>\n<table style=\"border-collapse: collapse; width: 81.0102%; height: 96px;\">\n<tbody>\n<tr style=\"height: 24px;\">\n<th style=\"width: 33.3333%; height: 24px; text-align: center; border-style: solid; border-color: #050000;\"><strong>Level<\/strong><\/th>\n<th style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><strong>Check<\/strong><\/th>\n<th style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><strong>Past Criteria<\/strong><\/th>\n<\/tr>\n<tr style=\"height: 24px;\">\n<td style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><span style=\"color: #000000;\">Object<\/span><\/td>\n<td style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><span style=\"color: #000000;\">gsutil stat<\/span><\/td>\n<td style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><span style=\"color: #000000;\">State file at\u00a0{prefix}\/default.tfstate<\/span><\/td>\n<\/tr>\n<tr style=\"height: 24px;\">\n<td style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><span style=\"color: #000000;\">Plan<\/span><\/td>\n<td style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><span style=\"color: #000000;\">terraform plan<\/span><\/td>\n<td style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><span style=\"color: #000000;\">0 creates, 0 destroys, 0 changes<\/span><\/td>\n<\/tr>\n<tr style=\"height: 24px;\">\n<td style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><span style=\"color: #000000;\">Inventory<\/span><\/td>\n<td style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><span style=\"color: #000000;\">Validator<\/span><\/td>\n<td style=\"width: 33.3333%; text-align: center; height: 24px; border-style: solid; border-color: #050000;\"><span style=\"color: #000000;\">The label is *-CLEAN (not MIGRATED-BE)<\/span><\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>A plan showing creates or destroys means a stale state or wrong prefix\u2014stop and investigate.<\/p>\n<h2>Challenges We Faced:<\/h2>\n<p>1. Legacy path mismatches<br \/>\nNot every S3 key matched its GCS prefix. We maintained explicit key-mapping tables (e.g., infrastructure\/db\/app\/development \u2192 infrastructure\/db\/development). Blind copying by directory name silently copies wrong or empty state.<\/p>\n<p>2. State drift<br \/>\nApplies between copy and backend switch stale the GCS copy.<br \/>\nFix: delete the stale object, re-copy, and immediately merge the backend PR.<br \/>\nPrevention: freeze applies during the copy-to-switch window.<\/p>\n<p>3. Stale datasources<br \/>\nProjects stuck at MIGRATED-BE had split-brain dependencies. The migration script&#8217;s safety gate ensures upstream state exists in GCS before rewriting references.<\/p>\n<p>4. Shared implementation modules<br \/>\ndevelopment\/, staging\/, production\/ dirs often only contain a backend and module { source = &#8220;..\/implementation&#8221; }. Datasource reads live in the shared module; the script follows relative sources recursively.<\/p>\n<p>5. Transition limbo<br \/>\nTreat MIGRATED-BE as blocking. Migration isn&#8217;t done until the label reads CLEAN.<\/p>\n<p>6. Scale<br \/>\nHundreds of directories across two repos migrated in team-owned batches: register paths \u2192 copy \u2192 switch backend \u2192 migrate datasources \u2192 verify CLEAN.<\/p>\n<p>7. Premigration state surgery<br \/>\nSome projects needed moved\/removed blocks before switching backends (security boundary changes, phantom addresses to avoid destruction on removal).<\/p>\n<p>End-to-End Migration Flow (Summary):<br \/>\n<img decoding=\"async\" loading=\"lazy\" class=\"alignnone size-full wp-image-80729\" src=\"https:\/\/www.tothenew.com\/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-flow.png\" alt=\"\" width=\"1584\" height=\"888\" srcset=\"\/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-flow.png 1584w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-flow-300x168.png 300w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-flow-1024x574.png 1024w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-flow-768x431.png 768w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-flow-1536x861.png 1536w, \/blog\/wp-ttn-blog\/uploads\/2026\/07\/migration-flow-624x350.png 624w\" sizes=\"(max-width: 1584px) 100vw, 1584px\" \/><\/p>\n<p><strong>Conclusion:<\/strong><\/p>\n<p>Migrating Terraform state at scale is about building a repeatable, verifiable pipeline \u2014 not running a single command. Terraform-driven bulk copy, phased backend switching, automated data source migration, and inventory-based validation let us migrate hundreds of projects safely through CI with zero state-corruption incidents.<\/p>\n<p><strong>Key takeaways:<\/strong><\/p>\n<p>Map S3\u00a0key\u00a0to GCS\u00a0prefix\u00a0verbatim; override legacy mismatches explicitly.<br \/>\nNever switch a backend without a clean plan.<br \/>\nMigrate in phases, each independently gateable.<br \/>\nAutomate inventory with labels (CLEAN,\u00a0MIGRATED-BE,\u00a0NOT-MIGRATED).<br \/>\nProtect the state with deletion_policy = &#8220;ABANDON&#8221; and ignore changes.<br \/>\nStart with inventory, migrate in small batches, and treat every\u00a0terraform plan\u00a0as your safety net.<\/p>\n<p>Have you migrated Terraform state across cloud providers?\u00a0Share your experience in the comments \u2014 we&#8217;d love to compare notes.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Introduction: What if the single source of truth for your entire infrastructure lived in the wrong cloud? For years, our Terraform state lived in an AWS S3 bucket while our infrastructure increasingly ran on Google Cloud Platform. That split created cross-cloud dependencies, IAM complexity, and a growing mismatch between where we managed infrastructure and where [&hellip;]<\/p>\n","protected":false},"author":1982,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"iawp_total_views":0},"categories":[5877],"tags":[1853,248,5084,8733,6362,1587,4030,8734,6087,8732,1585],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts\/80734"}],"collection":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/users\/1982"}],"replies":[{"embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/comments?post=80734"}],"version-history":[{"count":5,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts\/80734\/revisions"}],"predecessor-version":[{"id":80965,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/posts\/80734\/revisions\/80965"}],"wp:attachment":[{"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/media?parent=80734"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/categories?post=80734"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.tothenew.com\/blog\/wp-json\/wp\/v2\/tags?post=80734"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}