<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pranit Raje</title>
    <description>The latest articles on DEV Community by Pranit Raje (@pranitraje).</description>
    <link>https://dev.to/pranitraje</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F22979%2Fd0ef9151-94a6-4bb4-b512-c2514a0092e3.jpeg</url>
      <title>DEV Community: Pranit Raje</title>
      <link>https://dev.to/pranitraje</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pranitraje"/>
    <language>en</language>
    <item>
      <title>How to Refactor a Terraform Monolith into Modules Without Downtime</title>
      <dc:creator>Pranit Raje</dc:creator>
      <pubDate>Fri, 23 Oct 2026 17:20:06 +0000</pubDate>
      <link>https://dev.to/pranitraje/how-to-refactor-a-terraform-monolith-into-modules-without-downtime-2koj</link>
      <guid>https://dev.to/pranitraje/how-to-refactor-a-terraform-monolith-into-modules-without-downtime-2koj</guid>
      <description>&lt;h2&gt;
  
  
  Part 2: Splitting a 1,600-line imported main.tf into seven modules with HashiCorp Agent Skills, the Terraform MCP Server and moved blocks, then retiring the CloudFormation stack that started it all
&lt;/h2&gt;




&lt;p&gt;At the end of &lt;a href="https://dev.to/pranitraje/how-to-import-existing-aws-infrastructure-into-terraform-without-downtime-1h17"&gt;Part 1&lt;/a&gt; I made a confession: I had a &lt;em&gt;managed&lt;/em&gt; application, not a &lt;em&gt;well-structured&lt;/em&gt; one. Everything was in Terraform state, the app never went down, and the code was still one enormous &lt;code&gt;main.tf&lt;/code&gt; that I would not wish on a colleague.&lt;/p&gt;

&lt;p&gt;Here is what that file looked like by the time I started the refactor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Resource names like &lt;code&gt;stack_subnets_3&lt;/code&gt;, &lt;code&gt;iam_roles_33&lt;/code&gt; and &lt;code&gt;ingress_rules_in_stack_groups_1_5&lt;/code&gt;. They describe the query that found the resource, not what the resource does.&lt;/li&gt;
&lt;li&gt;Hardcoded IDs and ARNs everywhere. Terraform did not know the subnets belonged to the VPC. The strings just happened to match.&lt;/li&gt;
&lt;li&gt;45 &lt;code&gt;import&lt;/code&gt; blocks still sitting next to the resources they imported.&lt;/li&gt;
&lt;li&gt;Nothing I could reuse for a second environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Part 1 closed with about 40 resources in state. While planning this refactor I went back and imported the few I had left out, mostly extra routes and security group rules, which brought the total to 45 and the file to around 1,600 lines. Everything below works from that complete set.&lt;/p&gt;

&lt;p&gt;This post is the follow-up I promised: refactoring that file into proper modules, quickly, with AI tooling grounded in HashiCorp's own guidance, &lt;strong&gt;without touching a single real resource&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  A note on what this is
&lt;/h3&gt;

&lt;p&gt;Same caveat as Part 1. This is a demo I built while working at HashiCorp to show what HCP Terraform, the Terraform MCP Server and HashiCorp's Agent Skills can do together. It is &lt;strong&gt;not production-ready&lt;/strong&gt;. Your environment, naming conventions and appetite for risk will differ from mine. Take the learnings, not the artefacts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here is the whole journey in one picture.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fphea9dwbi6710b3sdzrq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fphea9dwbi6710b3sdzrq.png" alt="Overview of the refactor workflow: monolith, AI assistant with skills and MCP server, seven steps with an approval gate, then plan, Sentinel and CloudFormation retirement" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The one number that matters
&lt;/h2&gt;

&lt;p&gt;The refactor had a single acceptance test:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;0 to add, 0 to change, 0 to destroy&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every resource keeps its real-world identity. Only its address in Terraform state and the layout of the code change. If the plan shows anything beyond moves, the refactor is wrong. No judgement call, no "looks about right".&lt;/p&gt;

&lt;p&gt;The two Sentinel policies from Part 1 were still attached to the workspace, which made that goal enforceable rather than aspirational. &lt;code&gt;no-destructive-changes&lt;/code&gt; fails the run on any delete or replace, and &lt;code&gt;flag-resource-updates&lt;/code&gt; flags any in-place update. A move-only plan trips neither. So the policies became an independent check on the AI's work, which is exactly where I want them. I would rather not rely on my own reading of a 45-resource plan.&lt;/p&gt;


&lt;h2&gt;
  
  
  The toolkit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;HashiCorp Agent Skills.&lt;/strong&gt; These are packaged instructions that teach an AI assistant how HashiCorp recommends doing something. I used two from the &lt;a href="https://github.com/hashicorp/agent-skills/tree/main/plugins/terraform" rel="noopener noreferrer"&gt;official repo&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/hashicorp/agent-skills/blob/main/plugins/terraform/skills/refactor-module/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;refactor-module&lt;/code&gt;&lt;/a&gt; turns monolithic configurations into modules following HashiCorp's module design principles. It covers module boundaries, inputs and outputs, and &lt;code&gt;moved&lt;/code&gt; blocks.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/hashicorp/agent-skills/blob/main/plugins/terraform/skills/terraform-style-guide/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;terraform-style-guide&lt;/code&gt;&lt;/a&gt; encodes HashiCorp's conventions for naming, file layout, typed and described variables and outputs, and &lt;code&gt;terraform fmt&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Terraform MCP Server.&lt;/strong&gt; Same role as in Part 1. My config pins &lt;code&gt;hashicorp/aws = 6.67.0&lt;/code&gt;, and whenever the assistant needed to know whether an argument existed, was computed, or conflicted with another, it checked the registry docs for that exact version instead of guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HCP Terraform.&lt;/strong&gt; The state lives here, so the authoritative plan runs here. A local &lt;code&gt;terraform plan&lt;/code&gt; is no use for this job, which I will come back to.&lt;/p&gt;

&lt;p&gt;The Part 1 lesson holds: a skill gives the assistant the conventions, and the MCP server gives it the facts. You want both.&lt;/p&gt;


&lt;h2&gt;
  
  
  The prompt
&lt;/h2&gt;

&lt;p&gt;I did not type "make this nicer". I gave the assistant a process, constraints and a definition of done. Condensed, the shape was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Process&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; Inventory every resource and import block: address, Name tag, references.
&lt;span class="p"&gt;2.&lt;/span&gt; Propose a module layout and the full old-address -&amp;gt; new-address map.
   STOP and wait for my approval.
&lt;span class="p"&gt;3.&lt;/span&gt; Rename logical names by role, not discovery order.
&lt;span class="p"&gt;4.&lt;/span&gt; Preserve state with moved blocks. Remove the import blocks.
&lt;span class="p"&gt;5.&lt;/span&gt; Replace hardcoded cross-references with real references.
&lt;span class="p"&gt;6.&lt;/span&gt; Add depends_on only where a reference can't express the ordering,
   each with a one-line justification.
&lt;span class="p"&gt;7.&lt;/span&gt; Clean up and apply the style guide.
&lt;span class="p"&gt;8.&lt;/span&gt; Validate: fmt, init, validate, plan.

&lt;span class="gh"&gt;# Constraints&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Never run apply, import, state mv, state rm or destroy.
&lt;span class="p"&gt;-&lt;/span&gt; Don't change any argument value that would alter real infrastructure.
&lt;span class="p"&gt;-&lt;/span&gt; Every argument must exist in the pinned aws 6.67.0 schema. Never guess.
&lt;span class="p"&gt;-&lt;/span&gt; Don't add resources that aren't in the current config. List them as open items.
&lt;span class="p"&gt;-&lt;/span&gt; If something is ambiguous, stop and ask.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constraints did as much work as the steps. Telling an assistant what &lt;em&gt;not&lt;/em&gt; to do is how it stays inside the lines.&lt;/p&gt;

&lt;p&gt;The sample prompts for the import and clean-up phases are in the demo repo. Adapt them rather than copying them, because your module boundaries will not be mine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The approval gate
&lt;/h3&gt;

&lt;p&gt;Step 2 is the one I would keep if I could keep only one. Before a single line of code was written, the assistant showed me the proposed module layout and a 45-row table mapping every old address to its new one. I reviewed it and approved it, and only then did it generate code.&lt;/p&gt;

&lt;p&gt;A wrong module boundary or a misnamed resource takes ten seconds to fix in a table. Finding it across 28 files is a miserable afternoon.&lt;/p&gt;




&lt;h2&gt;
  
  
  What came out
&lt;/h2&gt;

&lt;p&gt;The monolith became seven local modules, with no cycles. Data moves only through inputs and outputs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.
├── main.tf               # root composition: seven module calls
├── moved.tf              # 45 moved blocks
├── outputs.tf            # key IDs and ARNs
├── providers.tf
├── variables.tf          # region, stack_name, environment_name, cluster_name, dynamodb_table_name
└── modules/
    ├── network/          # VPC, IGW, 4 subnets, EIP, NAT, route tables, routes, associations
    ├── security_groups/  # 3 security groups, 8 ingress rules
    ├── dynamodb/         # application table
    ├── eks/              # IAM roles, launch template, cluster, node group, OIDC provider
    ├── irsa/             # ALB controller and app roles and policies
    ├── alb/              # target group, load balancer, listener
    └── ecr/              # container repository
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every module has the same four files (&lt;code&gt;main.tf&lt;/code&gt;, &lt;code&gt;variables.tf&lt;/code&gt;, &lt;code&gt;outputs.tf&lt;/code&gt;, &lt;code&gt;versions.tf&lt;/code&gt;), straight from the style guide. &lt;code&gt;network&lt;/code&gt; and &lt;code&gt;dynamodb&lt;/code&gt; sit at the bottom, &lt;code&gt;security_groups&lt;/code&gt; and &lt;code&gt;eks&lt;/code&gt; build on them, and &lt;code&gt;irsa&lt;/code&gt; and &lt;code&gt;alb&lt;/code&gt; sit on top.&lt;/p&gt;

&lt;p&gt;The root &lt;code&gt;main.tf&lt;/code&gt; went from roughly 1,600 lines to 47, and it is now pure composition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"security_groups"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./modules/security_groups"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"eks"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./modules/eks"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;security_groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_security_group_id&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_subnet_ids&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_subnet_ids&lt;/span&gt;
  &lt;span class="nx"&gt;private_node_subnet_ids&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_node_subnet_ids&lt;/span&gt;
  &lt;span class="nx"&gt;dynamodb_table_arn&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;table_arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can read the architecture off that file now. Before, you had to infer it from a wall of strings.&lt;/p&gt;




&lt;h2&gt;
  
  
  Names that mean something
&lt;/h2&gt;

&lt;p&gt;Names now come from each resource's role (its &lt;code&gt;Name&lt;/code&gt; tag, description or availability zone), not from the order the query found it. A single instance of a type is just &lt;code&gt;this&lt;/code&gt;. Where there are several, the name says which is which. A few of the 45:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Old address&lt;/th&gt;
&lt;th&gt;New address&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws_vpc.stack_vpcs_0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;module.network.aws_vpc.this&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws_subnet.stack_subnets_3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;module.network.aws_subnet.public_a&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws_route.routes_in_stack_route_tables_1_1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;module.network.aws_route.private_nat&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws_vpc_security_group_ingress_rule.ingress_rules_in_stack_groups_2_0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;module.security_groups.aws_vpc_security_group_ingress_rule.alb_http_public&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws_iam_role.iam_roles_33&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;module.irsa.aws_iam_role.alb_controller&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws_eks_node_group.stack_node_groups_0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;module.eks.aws_eks_node_group.primary&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I kept resources as separate blocks instead of forcing &lt;code&gt;for_each&lt;/code&gt;. For a demo that is clearer and less risky. You may well want &lt;code&gt;for_each&lt;/code&gt; for the subnets in a real environment.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;moved&lt;/code&gt; blocks are what make zero downtime possible
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;moved&lt;/code&gt; block tells Terraform that a resource has a new address but is still the same real object. Terraform rewrites the state entry instead of destroying and recreating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;moved&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;from&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stack_vpcs_0&lt;/span&gt;
  &lt;span class="nx"&gt;to&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;moved&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;from&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iam_roles_33&lt;/span&gt;
  &lt;span class="nx"&gt;to&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;irsa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb_controller&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The refactor produced 45 of them in a root-level &lt;code&gt;moved.tf&lt;/code&gt;, one per resource. HashiCorp's guide on &lt;a href="https://developer.hashicorp.com/terraform/language/modules/develop/refactoring" rel="noopener noreferrer"&gt;refactoring with &lt;code&gt;moved&lt;/code&gt; blocks&lt;/a&gt; covers the mechanics.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened to the &lt;code&gt;import&lt;/code&gt; blocks
&lt;/h3&gt;

&lt;p&gt;I deleted all 45. Every resource is already in state, so the imports have done their job. There is also a hard rule here: &lt;code&gt;import&lt;/code&gt; blocks cannot live inside child modules, and one left pointing at an old address would fight with the moves. The assistant listed the removed import IDs in its summary, so there is still a record of what was adopted.&lt;/p&gt;




&lt;h2&gt;
  
  
  Replacing hardcoded values with references
&lt;/h2&gt;

&lt;p&gt;This is the part I care about most, because it turns a snapshot of your infrastructure into a description of it. Some examples from this repo:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Argument&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws_route.private_nat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;route_table_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;literal &lt;code&gt;rtb-...&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws_route_table.private.id&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws_iam_role_policy.node_dynamodb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Resource&lt;/code&gt; ARNs&lt;/td&gt;
&lt;td&gt;literal table ARN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;var.dynamodb_table_arn&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws_iam_openid_connect_provider.this&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;literal issuer URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aws_eks_cluster.this.identity[0].oidc[0].issuer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IRSA trust policies&lt;/td&gt;
&lt;td&gt;OIDC condition keys&lt;/td&gt;
&lt;td&gt;literal issuer host&lt;/td&gt;
&lt;td&gt;&lt;code&gt;trimprefix(var.oidc_issuer_url, "https://")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws_subnet.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubernetes.io/cluster/...&lt;/code&gt; tag&lt;/td&gt;
&lt;td&gt;hardcoded cluster name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;var.cluster_name&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule I gave the assistant: a reference must resolve to &lt;strong&gt;exactly&lt;/strong&gt; the value that is there now. If it would not, keep the literal and flag it. A "tidy" reference that quietly changes a value is how a zero-change refactor becomes a one-change refactor.&lt;/p&gt;

&lt;p&gt;The subnet tags are the interesting case. The cluster depends on the subnets, so having the subnet tags reference the cluster's name would create a &lt;strong&gt;dependency cycle&lt;/strong&gt;. The fix is a shared &lt;code&gt;cluster_name&lt;/code&gt; variable that both sides read. The assistant spotted the cycle and picked the variable without being told.&lt;/p&gt;

&lt;p&gt;Genuinely external constants (AWS service principals, managed policy ARNs, CIDR blocks) stayed as literals. Not everything needs to be a reference.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;depends_on&lt;/code&gt;, but only where it earns its place
&lt;/h2&gt;

&lt;p&gt;The classic refactoring mistake is sprinkling &lt;code&gt;depends_on&lt;/code&gt; everywhere "to be safe". I asked for it only where no attribute reference can express an ordering requirement, with a one-line comment on each. It ended up in two places:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_nat_gateway"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# A public NAT gateway requires the internet gateway to be attached first.&lt;/span&gt;
  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_internet_gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_eks_node_group"&lt;/span&gt; &lt;span class="s2"&gt;"primary"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# The node role policy must be present before managed nodes start.&lt;/span&gt;
  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_iam_role_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_dynamodb&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The more interesting trick was routing the ordering through &lt;strong&gt;module outputs&lt;/strong&gt; instead of module-level &lt;code&gt;depends_on&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# modules/network/outputs.tf&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"private_node_subnet_ids"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="c1"&gt;# Ensure node consumers wait for private egress and subnet associations.&lt;/span&gt;
  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_nat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aws_route_table_association&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aws_route_table_association&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that consumes that output inherits the ordering. The node group cannot start before the private NAT route exists (nodes need egress to join the cluster), and the internet-facing ALB waits for the public default route. No module-level &lt;code&gt;depends_on&lt;/code&gt; needed.&lt;/p&gt;

&lt;p&gt;Just as useful was what it &lt;em&gt;rejected&lt;/em&gt;. The EIP-to-internet-gateway dependency was already covered by the NAT gateway one. The IRSA roles to OIDC provider link was already implicit through the trust policy. A &lt;code&gt;depends_on&lt;/code&gt; you do not need is still a &lt;code&gt;depends_on&lt;/code&gt; someone has to understand later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Clean-up
&lt;/h2&gt;

&lt;p&gt;With the style guide applied, the assistant also removed noise, but only where it caused no plan diff: redundant &lt;code&gt;provider = aws&lt;/code&gt; lines, arguments explicitly set to &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;timeouts {}&lt;/code&gt; blocks containing only nulls, and environment-specific values moved into typed, described variables. It also added a root &lt;code&gt;outputs.tf&lt;/code&gt; exposing the identifiers you actually want from outside: VPC ID, cluster name and ARN, ALB DNS name, ECR URL, DynamoDB ARN and the IRSA role ARNs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Proving it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform &lt;span class="nb"&gt;fmt&lt;/span&gt; &lt;span class="nt"&gt;-recursive&lt;/span&gt;
terraform init
terraform validate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three passed. Then I ran a local &lt;code&gt;terraform plan&lt;/code&gt;, and it said &lt;strong&gt;&lt;code&gt;45 to add&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is not a bug and it is not the answer. My local directory has no state, because the real state lives in HCP Terraform, so from where my laptop sits all 45 resources are brand new. This is exactly why the authoritative check has to be a &lt;strong&gt;speculative plan in the workspace&lt;/strong&gt;, the one that holds the state.&lt;/p&gt;

&lt;p&gt;So I ran that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plan: 0 to add, 0 to change, 0 to destroy.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every resource showed up as a move. Both Sentinel policies passed with nothing to flag. The app stayed up throughout, because nothing real was touched. Applying that plan only rewrites addresses in state.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F86tbbmnlln65aexg0miq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F86tbbmnlln65aexg0miq.png" alt="HCP Terraform plan showing resources moved to their new module addresses with 0 to add, 0 to change, 0 to destroy" width="799" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Retiring the CloudFormation stack without deleting anything
&lt;/h2&gt;

&lt;p&gt;One loose end. The original app came from a CloudFormation stack, and that stack still believes it owns every resource. Delete it naively and CloudFormation will cheerfully delete your running infrastructure, which Terraform now manages. Two systems must never both think they own the same resource.&lt;/p&gt;

&lt;p&gt;The safe way is &lt;code&gt;DeletionPolicy: Retain&lt;/code&gt;. In the demo template each of the 39 resources has the line commented out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;VPC&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::EC2::VPC&lt;/span&gt;
    &lt;span class="c1"&gt;# DeletionPolicy: Retain&lt;/span&gt;
    &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;CidrBlock&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;VpcCIDR&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Uncomment &lt;code&gt;DeletionPolicy: Retain&lt;/code&gt;&lt;/strong&gt; on every resource.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update the stack&lt;/strong&gt; with the modified template. Do not skip this. The policy only takes effect once the stack has been updated to include it. The update records &lt;code&gt;Retain&lt;/code&gt; for every resource and changes nothing about the resources themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the stack.&lt;/strong&gt; CloudFormation removes its own record and leaves every resource where it is, because each one is marked &lt;code&gt;Retain&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a plan in HCP Terraform&lt;/strong&gt; and confirm it still shows no changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Do this last.&lt;/strong&gt; Delete the stack only after the refactor is merged and the plan is clean, so Terraform has already been managing these resources successfully. And check your own template for nested stacks or custom resources, which may need individual handling.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What I would tell you before you try this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Define "done" as a plan result.&lt;/strong&gt; "0 add, 0 change, 0 destroy" is unambiguous. "Looks cleaner" is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put an approval gate before code generation.&lt;/strong&gt; Reviewing a mapping table is far cheaper than reviewing 28 files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tell the AI what not to do.&lt;/strong&gt; No &lt;code&gt;state mv&lt;/code&gt;, no guessing arguments, no new resources. The constraints kept it inside the lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let policy as code be your second reviewer.&lt;/strong&gt; Sentinel does not care how plausible the assistant sounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ground it in the registry.&lt;/strong&gt; Skills carry the conventions. The MCP server carries the facts for your exact provider version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify against real state.&lt;/strong&gt; A local plan against no state is noise. Use a speculative plan in the workspace that holds the state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the &lt;code&gt;moved&lt;/code&gt; blocks in your commit history.&lt;/strong&gt; They document what happened and let other workspaces migrate their state the same way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;In Part 1 the argument was that Search and Import changes the &lt;em&gt;economics&lt;/em&gt; of adopting brownfield infrastructure, and policy as code changes the &lt;em&gt;confidence&lt;/em&gt;. This part adds a third piece: AI tooling, when it is grounded in HashiCorp's skills and the registry docs, makes the cleanup affordable too. Cleanup is the step most teams quietly skip, and it is the step that decides whether you end up with Infrastructure as Code or just a very large state file.&lt;/p&gt;

&lt;p&gt;The sequence that worked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Import&lt;/strong&gt; to get management.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refactor&lt;/strong&gt; to get Infrastructure as Code, with &lt;code&gt;moved&lt;/code&gt; blocks and a 0/0/0 plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retire&lt;/strong&gt; the old tooling with &lt;code&gt;DeletionPolicy: Retain&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of it needed downtime.&lt;/p&gt;

&lt;p&gt;Same disclaimer as before: there is room to do all of this better, and your environment will want something different. If you find a better way, I would like to hear it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1&lt;/strong&gt;: &lt;a href="https://dev.to/pranitraje/how-to-import-existing-aws-infrastructure-into-terraform-without-downtime-1h17"&gt;How to Import Existing AWS Infrastructure into Terraform Without Downtime&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo repo from Part 1&lt;/strong&gt; (query file, prompts, app): &lt;a href="https://github.com/pranit-hashi/aws-brownfield-terraform-import" rel="noopener noreferrer"&gt;github.com/pranit-hashi/aws-brownfield-terraform-import&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HashiCorp Agent Skills for Terraform&lt;/strong&gt;: &lt;a href="https://github.com/hashicorp/agent-skills/tree/main/plugins/terraform" rel="noopener noreferrer"&gt;github.com/hashicorp/agent-skills&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform MCP Server&lt;/strong&gt;: &lt;a href="https://developer.hashicorp.com/terraform/mcp-server" rel="noopener noreferrer"&gt;developer.hashicorp.com/terraform/mcp-server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refactoring with &lt;code&gt;moved&lt;/code&gt; blocks&lt;/strong&gt;: &lt;a href="https://developer.hashicorp.com/terraform/language/modules/develop/refactoring" rel="noopener noreferrer"&gt;developer.hashicorp.com/terraform/language/modules/develop/refactoring&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform style guide&lt;/strong&gt;: &lt;a href="https://developer.hashicorp.com/terraform/language/style" rel="noopener noreferrer"&gt;developer.hashicorp.com/terraform/language/style&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Setup used: Terraform 1.14+, &lt;code&gt;hashicorp/aws&lt;/code&gt; 6.67.0, HCP Terraform with Sentinel policy sets, the Terraform MCP Server, and the &lt;code&gt;refactor-module&lt;/code&gt; and &lt;code&gt;terraform-style-guide&lt;/code&gt; Agent Skills.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you try this on your own app, I would like to hear where your plan stopped being clean.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>aws</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to Import Existing AWS Infrastructure into Terraform Without Downtime</title>
      <dc:creator>Pranit Raje</dc:creator>
      <pubDate>Wed, 07 Oct 2026 07:11:11 +0000</pubDate>
      <link>https://dev.to/pranitraje/how-to-import-existing-aws-infrastructure-into-terraform-without-downtime-1h17</link>
      <guid>https://dev.to/pranitraje/how-to-import-existing-aws-infrastructure-into-terraform-without-downtime-1h17</guid>
      <description>&lt;h2&gt;
  
  
  Bringing a live three-tier app under Terraform control with Terraform Search, HCP Terraform's Search and Import, Terraform MCP Server, HashiCorp Agent Skills and Sentinel policy that stopped a plan from replacing my EKS cluster
&lt;/h2&gt;




&lt;p&gt;Update: Part 2 is out. Once your app is imported, &lt;a href="https://dev.to/pranitraje/how-to-refactor-a-terraform-monolith-into-modules-without-downtime-2koj"&gt;here is how to refactor the monolith into modules without downtime&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Most organisations have at least one of these: an application that is genuinely important, genuinely in production, and genuinely not in code. Nobody planned it that way. Someone spun it up in the console, a team started depending on it, then a customer did, and now there is a quiet understanding that nobody touches it unless something is on fire.&lt;/p&gt;

&lt;p&gt;This is the real IaC adoption problem. Greenfield is easy. The hard part is the brownfield stuff that already works and therefore cannot be rebuilt just so it can be described properly.&lt;/p&gt;

&lt;p&gt;Terraform has had an answer for years — &lt;code&gt;terraform import&lt;/code&gt;, and later the declarative &lt;code&gt;import&lt;/code&gt; block. Both work. Both are a grind at any real scale. One &lt;code&gt;import&lt;/code&gt; block per resource, hand-written config to match whatever is actually deployed, then &lt;code&gt;plan&lt;/code&gt;, read the diff, fix a field, &lt;code&gt;plan&lt;/code&gt; again. For a forty-resource app that is a tense sprint, because every &lt;code&gt;plan&lt;/code&gt; against a live system is a small act of faith.&lt;/p&gt;

&lt;p&gt;So when Terraform shipped &lt;strong&gt;Search&lt;/strong&gt; (&lt;code&gt;list&lt;/code&gt; blocks in &lt;code&gt;.tfquery.hcl&lt;/code&gt;) and HCP Terraform shipped &lt;strong&gt;Search and Import&lt;/strong&gt;, I wanted to know whether the economics had actually changed. Not on a toy S3 bucket — on something with the shape of a real app: load balancer in front, compute in the middle, database at the back, and all the networking and IAM plumbing holding it together.&lt;/p&gt;




&lt;blockquote&gt;
&lt;h3&gt;
  
  
  A note on what this is
&lt;/h3&gt;

&lt;p&gt;This was a demo I built for a few clients while working at HashiCorp, to show what HCP Terraform, Search and Import, the Terraform MCP Server, and HashiCorp's official Agent Skills can do together. It is &lt;strong&gt;not production-ready&lt;/strong&gt;, and there is plenty of room to improve it.&lt;/p&gt;

&lt;p&gt;Every environment is different. Account layouts, tagging discipline, naming conventions, and blast-radius tolerance all vary. Take the learnings here, not the artefacts, and adapt them — you will likely find better approaches for your own setup. Search and Import is also still an &lt;strong&gt;experimental&lt;/strong&gt; feature as of this date, which is worth factoring into how much you lean on it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The workflow
&lt;/h2&gt;

&lt;p&gt;Everything below is one box in this picture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fonjsyhxu69dea01j58vf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fonjsyhxu69dea01j58vf.png" alt="High-level Terraform import workflow" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four moving parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Terraform Search&lt;/strong&gt; does discovery. I declare what I am looking for instead of clicking through the console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Terraform MCP Server and HashiCorp's Terraform Agent Skills&lt;/strong&gt; do the authoring. The assistant writes the query file and later fixes the generated config, but every argument comes from the official registry docs rather than from memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HCP Terraform's Search and Import&lt;/strong&gt; turns discovered resources into a starter configuration plus matching &lt;code&gt;import&lt;/code&gt; blocks, in bulk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentinel policies&lt;/strong&gt; sit on the workspace so no run can quietly turn an import into a rebuild.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That fourth one is the reason this was safe enough to attempt at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  The application
&lt;/h2&gt;

&lt;p&gt;Deliberately boring, because the interesting part is the import workflow.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;AWS services&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Front end&lt;/td&gt;
&lt;td&gt;Application Load Balancer, target group, HTTP listener&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web / app&lt;/td&gt;
&lt;td&gt;EKS cluster and managed node group, launch template, IAM roles, IRSA OIDC provider, ECR repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;DynamoDB table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foundation&lt;/td&gt;
&lt;td&gt;VPC, 4 subnets across 2 AZs, internet gateway, NAT gateway, EIP, 2 route tables, routes, subnet associations, 3 security groups and their ingress rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A small Flask app. User fills in a form, the app writes it to DynamoDB, a list view reads it back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkoeryt3fefbbmrc1bun1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkoeryt3fefbbmrc1bun1.png" alt="The demo application UI running on EKS" width="800" height="884"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That screenshot matters more than it looks like it should. Through everything that follows, this page stayed up. That was the actual success criterion — not "did &lt;code&gt;apply&lt;/code&gt; exit zero" but "did the thing I imported keep serving users while I imported it".&lt;/p&gt;

&lt;p&gt;I provisioned the stack with a &lt;strong&gt;CloudFormation template&lt;/strong&gt;, purely to create the brownfield condition. It could equally have been a console session, a Python script, or a runbook full of &lt;code&gt;aws&lt;/code&gt; CLI commands. The only property I needed was that Terraform had never seen it and had no state for it.&lt;/p&gt;

&lt;p&gt;The one convenience CFN gave me is that it tags everything with &lt;code&gt;aws:cloudformation:stack-name&lt;/code&gt;, which made a clean discovery handle. If your brownfield app has no such tag, you will lean harder on names, VPC IDs, and parent-resource scoping — which I had to do for plenty of resources anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Discovery as code
&lt;/h2&gt;

&lt;p&gt;Terraform Search works through &lt;code&gt;list&lt;/code&gt; blocks. Each one says "find resources of this type matching this filter". You run &lt;code&gt;terraform query&lt;/code&gt;, and Terraform returns what it found, including the identity attributes needed to import each resource.&lt;/p&gt;

&lt;p&gt;Small feature, but it changes the character of the work. Discovery stops being archaeology you do by hand and becomes a file you can commit, review, and re-run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you write anything, check that your resource types are actually supported.&lt;/strong&gt; List resource coverage is per-provider and per-version, and it is growing fast, so anything written down about it goes stale quickly. The reference I use every time is &lt;a href="https://github.com/quixoticmonk/extract-provider-supported-actions/blob/main/list-resources-report.json" rel="noopener noreferrer"&gt;this auto-generated report&lt;/a&gt;, which tracks supported list resources and refreshes as providers ship updates. Cheaper than discovering mid-build that the resource you care about has no &lt;code&gt;list&lt;/code&gt; support yet. You can also query your own provider schema directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform providers schema &lt;span class="nt"&gt;-json&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="s1"&gt;'.provider_schemas | to_entries | map({provider: .key, lists: (.value.list_resource_schemas // {} | keys)})'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing the query file is fiddly, though. Twenty-three resource types, each with its own filter semantics, and the argument names are not guessable. &lt;code&gt;aws_vpc&lt;/code&gt; takes an EC2-style &lt;code&gt;filter { name = "tag:..." }&lt;/code&gt;. &lt;code&gt;aws_launch_template&lt;/code&gt; takes &lt;code&gt;launch_template_names&lt;/code&gt;. &lt;code&gt;aws_eks_node_group&lt;/code&gt; takes &lt;code&gt;cluster_name&lt;/code&gt;. &lt;code&gt;aws_iam_role&lt;/code&gt; takes nothing and returns every non-service-linked role in the account.&lt;/p&gt;

&lt;p&gt;Exactly the kind of work where an AI assistant helps, and exactly the kind where it will confidently invent an argument that does not exist. So I constrained it with two things: the &lt;strong&gt;&lt;code&gt;terraform-search-import&lt;/code&gt; Agent Skill&lt;/strong&gt; (Search conventions and workflow) and the &lt;strong&gt;Terraform MCP Server&lt;/strong&gt; (live access to registry docs). Then I made the lookup non-optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Process&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; &lt;span class="gs"&gt;**Inventory**&lt;/span&gt;: Parse &lt;span class="sb"&gt;`three-tier-app.yaml`&lt;/span&gt; and list every resource
   (logical ID + &lt;span class="sb"&gt;`Type`&lt;/span&gt;).
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**Map each resource to a list resource**&lt;/span&gt;:
   a. Map each CloudFormation type to its Terraform resource type in &lt;span class="sb"&gt;`hashicorp/aws`&lt;/span&gt;.
   b. Use the Terraform MCP server to check whether the latest &lt;span class="sb"&gt;`hashicorp/aws`&lt;/span&gt;
      provider offers a &lt;span class="gs"&gt;**list resource**&lt;/span&gt; for that type. Check the registry docs;
      don't rely on memory.
   c. If, and only if, &lt;span class="sb"&gt;`aws`&lt;/span&gt; has no list resource for it, check &lt;span class="sb"&gt;`hashicorp/awscc`&lt;/span&gt;.
   d. If neither provider supports it, don't invent one. Record it as unsupported.
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="gs"&gt;**Filter by tag**&lt;/span&gt;: If a list resource can't filter by tag, use the narrowest
   documented alternative (e.g., VPC ID, name prefix, parent resource).
   Add an HCL comment that explains why you used it.

&lt;span class="gh"&gt;# Constraints&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Every list resource and argument must exist in the registry docs you looked up.
  Never guess.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three instructions did most of the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Don't rely on memory."&lt;/strong&gt; On an earlier attempt, before I was this explicit, one filter argument came back from what was effectively a general web search. It looked plausible. It was wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Don't invent one. Record it as unsupported."&lt;/strong&gt; List resource coverage is expanding fast but incomplete. In the end there was one gap: &lt;code&gt;AWS::EC2::VPCGatewayAttachment&lt;/code&gt; has no &lt;code&gt;aws&lt;/code&gt; list resource, so it fell back to &lt;code&gt;awscc&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Add an HCL comment that explains why."&lt;/strong&gt; The most valuable line in the prompt. The query file now documents its own compromises.&lt;/p&gt;

&lt;p&gt;Clean, tag-filtered discovery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc"&lt;/span&gt; &lt;span class="s2"&gt;"stack_vpcs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws&lt;/span&gt;
  &lt;span class="nx"&gt;include_resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tag:aws:cloudformation:stack-name"&lt;/span&gt;
      &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stack_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the compromises, which are the more honest half of the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Route resources cannot be tagged; discover routes beneath the tagged tables.&lt;/span&gt;
&lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="s2"&gt;"aws_route"&lt;/span&gt; &lt;span class="s2"&gt;"routes_in_stack_route_tables"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_route_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stack_route_tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws&lt;/span&gt;
  &lt;span class="nx"&gt;include_resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;route_table_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# The EKS cluster list resource has no filter arguments and lists clusters region-wide.&lt;/span&gt;
&lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="s2"&gt;"aws_eks_cluster"&lt;/span&gt; &lt;span class="s2"&gt;"eks_clusters"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws&lt;/span&gt;
  &lt;span class="nx"&gt;include_resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you take one piece of syntax from this post, take the &lt;code&gt;for_each&lt;/code&gt; in that first block. Child resources — routes, route table associations, security group rules, listeners — generally cannot be tagged, so you cannot find them by tag. But you can find their tagged parents and pivot: feed the parents' IDs into a &lt;code&gt;for_each&lt;/code&gt; on the child's &lt;code&gt;list&lt;/code&gt; block. &lt;code&gt;list.aws_route_table.stack_route_tables.data&lt;/code&gt; is the output of one query used as input to the next. Discovery composes.&lt;/p&gt;

&lt;p&gt;That pivot is also why &lt;code&gt;include_resource = true&lt;/code&gt; appears on every block. By default a query returns only resource &lt;em&gt;identities&lt;/em&gt;. Set it to &lt;code&gt;true&lt;/code&gt; and the provider returns full resource objects, which is what makes &lt;code&gt;table.state.id&lt;/code&gt; readable by the next query. Without it the &lt;code&gt;for_each&lt;/code&gt; has nothing to pivot on. The &lt;a href="https://developer.hashicorp.com/terraform/language/import/bulk#include_resource" rel="noopener noreferrer"&gt;docs note&lt;/a&gt; it may affect performance, so it is worth being deliberate about rather than setting everywhere out of habit — though for a few dozen resources I never noticed the cost.&lt;/p&gt;

&lt;p&gt;The resources with no filters at all (EKS clusters, load balancers, DynamoDB tables, ECR repositories, IAM roles) return everything in the region or account. Fine in a demo account. Noisy in a busy shared one — a real limitation, and one more reason the phased approach below is worth the extra runs.&lt;/p&gt;

&lt;p&gt;Twenty-three &lt;code&gt;list&lt;/code&gt; blocks. One &lt;code&gt;terraform query&lt;/code&gt;. Every resource found, with its import identity attached.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: From discovery to configuration
&lt;/h2&gt;

&lt;p&gt;With the query file validated, I moved to HCP Terraform's &lt;strong&gt;Search and Import&lt;/strong&gt;. Point it at the query, it runs discovery, you tick the resources you want, and it generates a starter configuration.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0s4g7xkywj13cnjc92um.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0s4g7xkywj13cnjc92um.png" alt="Selecting discovered resources and generating a starter configuration in HCP Terraform" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Have your resource IDs ready before you open this screen
&lt;/h3&gt;

&lt;p&gt;This is the prerequisite nobody tells you about, and it caught me out on my first attempt.&lt;/p&gt;

&lt;p&gt;Search and Import returns &lt;strong&gt;everything&lt;/strong&gt; your query matched — resources already managed by Terraform alongside the unmanaged ones you are trying to adopt. It is a discovery tool, not a filter. Selecting the right rows is your job.&lt;/p&gt;

&lt;p&gt;Say you want to import your unmanaged security groups. Your &lt;code&gt;list&lt;/code&gt; block matches on the VPC or a tag, and you get back a list of every security group in scope. Some are already in state. Some belong to a different team. Some are the ones you actually want. To tell them apart on that screen you need the &lt;strong&gt;exact security group IDs&lt;/strong&gt; of your targets in hand — &lt;code&gt;sg-0bd9f4d46a38xxxxx&lt;/code&gt;, not "the one for the EKS nodes".&lt;/p&gt;

&lt;p&gt;Two things follow from that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build your inventory first.&lt;/strong&gt; Before you touch the UI, have a list of the resources you intend to import and their unique identifiers. Console, &lt;code&gt;aws&lt;/code&gt; CLI, a tagging report, your CMDB — however you get there, get there before you start selecting. Trying to assemble that list while staring at a few hundred discovered rows is how you import something that belongs to somebody else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know which identifier each resource type uses&lt;/strong&gt;, because it is not consistent. A security group is identified by &lt;code&gt;id&lt;/code&gt;, an EKS cluster by &lt;code&gt;name&lt;/code&gt;, a load balancer or IAM policy by &lt;code&gt;arn&lt;/code&gt;, an EKS node group by &lt;code&gt;cluster_name&lt;/code&gt; &lt;em&gt;plus&lt;/em&gt; &lt;code&gt;node_group_name&lt;/code&gt;. The identity schema is documented per resource in the Terraform Registry — check it there rather than assuming, which is another spot where having the MCP Server wired in paid off. You can see the shape of it in the generated &lt;code&gt;import&lt;/code&gt; blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Security group — identified by id&lt;/span&gt;
&lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1243XXXXXXXX"&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sg-0bd9f4d46a38XXXXX"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-southeast-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# EKS node group — composite identity, no id at all&lt;/span&gt;
&lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;account_id&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1243XXXXXXXX"&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"demo-eks-cluster"&lt;/span&gt;
  &lt;span class="nx"&gt;node_group_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"demo-eks-nodegroup"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-southeast-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gets worse the busier your account is. In a demo account with one stack, the discovered list is short and obvious. In a shared production account where &lt;code&gt;list "aws_iam_role"&lt;/code&gt; returns every role you own, the identifiers are the only thing standing between you and adopting a resource another team depends on.&lt;/p&gt;

&lt;p&gt;The same thing works from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
terraform query                                  &lt;span class="c"&gt;# discover&lt;/span&gt;
terraform query &lt;span class="nt"&gt;-generate-config-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;main.tf     &lt;span class="c"&gt;# generate resource + import blocks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each resource you get a &lt;code&gt;resource&lt;/code&gt; block populated from the live API response, plus an &lt;code&gt;import&lt;/code&gt; block keyed on identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stack_vpcs_0&lt;/span&gt;
  &lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1243XXXXXXXX"&lt;/span&gt;
    &lt;span class="nx"&gt;id&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc-037c4dd02c90XXXXX"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-southeast-1"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc"&lt;/span&gt; &lt;span class="s2"&gt;"stack_vpcs_0"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt;                           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt;
  &lt;span class="nx"&gt;enable_dns_hostnames&lt;/span&gt;                 &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;enable_dns_support&lt;/span&gt;                   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;enable_network_address_usage_metrics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;instance_tenancy&lt;/span&gt;                     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;                               &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-southeast-1"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"demo-vpc"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Previously I would have written that &lt;code&gt;import&lt;/code&gt; block by hand, looked up the ID, hand-written the &lt;code&gt;resource&lt;/code&gt; block, and iterated on &lt;code&gt;plan&lt;/code&gt; until the diff was empty — forty times. Here, forty resources' worth of scaffolding arrived at once with correct identities.&lt;/p&gt;

&lt;p&gt;That is the real win, and I want to state it plainly: &lt;strong&gt;Search and Import does not give you production-ready Terraform. It gives you a very good head start on the mechanical half of the job.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: "Starter configuration" means exactly that
&lt;/h2&gt;

&lt;p&gt;HCP Terraform is upfront about it. The feature is experimental, and the console tells you to &lt;em&gt;review and modify this configuration before applying&lt;/em&gt;. Having worked through it, that label is precise.&lt;/p&gt;

&lt;p&gt;The reason is structural. The generator populates config from everything the API returned, but a provider schema mixes three kinds of fields: things you set, things the API computes, and things that are mutually exclusive with other things. The API response does not distinguish between them. So you get attributes that cannot legally be set, pairs of conflicting arguments, and a lot of zero-valued noise.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;terraform validate&lt;/code&gt; on the raw file produced a long list of errors. The patterns repeat, so they are worth naming:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-only attributes written as inputs.&lt;/strong&gt; &lt;code&gt;tags_all&lt;/code&gt; is the classic — computed from &lt;code&gt;tags&lt;/code&gt; plus provider default tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;= null&lt;/code&gt; and zero-value noise.&lt;/strong&gt; &lt;code&gt;disk_size = 0&lt;/code&gt;, &lt;code&gt;read_capacity = 0&lt;/code&gt; on a &lt;code&gt;PAY_PER_REQUEST&lt;/code&gt; DynamoDB table, empty &lt;code&gt;timeouts&lt;/code&gt; blocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutually exclusive arguments both set.&lt;/strong&gt; The biggest category. &lt;code&gt;launch_template.id&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;launch_template.name&lt;/code&gt;. &lt;code&gt;max_unavailable&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;max_unavailable_percentage&lt;/code&gt;. &lt;code&gt;subnets&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;subnet_mapping&lt;/code&gt; on an LB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Values outside valid ranges.&lt;/strong&gt; &lt;code&gt;order = 0&lt;/code&gt; on a listener action, where the provider needs 1 or higher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline vs standalone conflicts.&lt;/strong&gt; Subtle and important: the generator emitted inline &lt;code&gt;ingress&lt;/code&gt;/&lt;code&gt;egress&lt;/code&gt; blocks on &lt;code&gt;aws_security_group&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; standalone &lt;code&gt;aws_vpc_security_group_ingress_rule&lt;/code&gt; resources for the same rules, because the query discovered both. Those cannot coexist. Same story with inline &lt;code&gt;route&lt;/code&gt; blocks vs &lt;code&gt;aws_route&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redundant &lt;code&gt;provider = aws&lt;/code&gt; lines&lt;/strong&gt;, which &lt;code&gt;validate&lt;/code&gt; rejects inside &lt;code&gt;import&lt;/code&gt; blocks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rather than fix 1,300 lines by hand, I pointed the same MCP-grounded setup at the generated file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Objective&lt;/span&gt;
Find and fix broken or invalid code in &lt;span class="sb"&gt;`main.tf`&lt;/span&gt;. Keep the intended
infrastructure exactly as it is.

&lt;span class="gh"&gt;# Process&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; &lt;span class="gs"&gt;**Baseline**&lt;/span&gt;: Run &lt;span class="sb"&gt;`terraform validate`&lt;/span&gt;. Record every error and warning.
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**Documentation review**&lt;/span&gt;: Use the Terraform MCP server to check that every
   argument is valid in the schema, that conflicting arguments aren't set
   together (&lt;span class="sb"&gt;`ConflictsWith`&lt;/span&gt; / &lt;span class="sb"&gt;`ExactlyOneOf`&lt;/span&gt;), and that read-only attributes
   are not being set.
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="gs"&gt;**Classify**&lt;/span&gt; each finding as Breaking or Warning, based on a specific doc
   page or validate error. Never guess.
&lt;span class="p"&gt;4.&lt;/span&gt; &lt;span class="gs"&gt;**Fix**&lt;/span&gt;: smallest change that works. If a fix could force a resource to be
   replaced, or the right value is unclear, stop and ask.
&lt;span class="p"&gt;5.&lt;/span&gt; &lt;span class="gs"&gt;**Re-validate**&lt;/span&gt; and repeat until validate passes or the rest needs my input.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two instructions I would not drop: &lt;strong&gt;"keep the intended infrastructure exactly as it is"&lt;/strong&gt; and &lt;strong&gt;"if a fix could force a replacement, stop and ask."&lt;/strong&gt; An assistant optimising for a clean &lt;code&gt;validate&lt;/code&gt; will happily delete an argument to make an error go away. During a brownfield import, deleting an argument is how you end up with a plan that wants to replace your production node group. Correctness here is not "validate passes" — it is "validate passes &lt;em&gt;and&lt;/em&gt; the plan is a pure import".&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;terraform validate&lt;/code&gt; only checks schema and syntax. It cannot tell you whether your plan is about to replace a live resource. A clean &lt;code&gt;validate&lt;/code&gt; is the start of the review, not the end of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I needed something that &lt;em&gt;could&lt;/em&gt; tell me that, on every run, before anything was applied.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Guardrails first, imports second
&lt;/h2&gt;

&lt;p&gt;I want to be emphatic about the ordering, because it is the most transferable idea here.&lt;/p&gt;

&lt;p&gt;I attached the policies &lt;strong&gt;before&lt;/strong&gt; I imported anything.&lt;/p&gt;

&lt;p&gt;The failure mode of a brownfield import is not subtle, but it is quiet. You misread one attribute. That attribute turns out to be &lt;code&gt;ForceNew&lt;/code&gt;. Your plan, which you believed was an import, now contains a &lt;code&gt;replace&lt;/code&gt;. Reading a forty-resource plan at the end of a long day, you can absolutely miss that — and the blast radius is your running application.&lt;/p&gt;

&lt;p&gt;That is a textbook policy-as-code problem: a specific, machine-checkable invariant that must hold on every run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Policy one: no destructive changes (hard-mandatory)
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;An import run must not destroy or replace anything.&lt;/em&gt; In plan terms, three action sets are forbidden: &lt;code&gt;["delete"]&lt;/code&gt;, &lt;code&gt;["delete","create"]&lt;/code&gt;, and &lt;code&gt;["create","delete"]&lt;/code&gt;. Everything else — imports, no-ops, updates — is fine as far as this policy is concerned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tfplan/v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tfplan&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tfrun&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Skip enforcement for explicit destroy runs.
&lt;/span&gt;&lt;span class="n"&gt;is_destroy_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tfrun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_destroy&lt;/span&gt;

&lt;span class="n"&gt;destructive_actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;destructive_changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;filter&lt;/span&gt; &lt;span class="n"&gt;tfplan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_changes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_destroy_run&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;any&lt;/span&gt; &lt;span class="n"&gt;destructive_actions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;da&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;actions_equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;da&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;main&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;is_destroy_run&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;violation_count&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth copying. The &lt;code&gt;tfrun.is_destroy&lt;/code&gt; escape hatch means &lt;code&gt;terraform destroy&lt;/code&gt; still works when you genuinely mean it — a guardrail that blocks legitimate operations gets switched off, and a switched-off guardrail protects nothing. And the policy prints the address, type, and action of every offending resource, so a failure tells you &lt;em&gt;why&lt;/em&gt; without hunting.&lt;/p&gt;

&lt;p&gt;This runs at &lt;strong&gt;hard-mandatory&lt;/strong&gt;. No override. I cannot talk myself past it at 7pm.&lt;/p&gt;

&lt;h3&gt;
  
  
  Policy two: flag in-place updates (soft-mandatory)
&lt;/h3&gt;

&lt;p&gt;A correct import produces a plan full of imports and nothing else. If the plan also wants to &lt;code&gt;update&lt;/code&gt; something, that is not dangerous but it is always informative — it means my generated config does not exactly match what is deployed. Either the generator got a field slightly wrong, or there is real drift.&lt;/p&gt;

&lt;p&gt;I do not want to block that. I want to be told and then decide.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;updated_resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;filter&lt;/span&gt; &lt;span class="n"&gt;tfplan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_changes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;any&lt;/span&gt; &lt;span class="n"&gt;update_actions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ua&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;actions_equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;main&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;update_count&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="s2"&gt;"no-destructive-changes"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./policies/no-destructive-changes.sentinel"&lt;/span&gt;
  &lt;span class="nx"&gt;enforcement_level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hard-mandatory"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="s2"&gt;"flag-resource-updates"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./policies/flag-resource-updates.sentinel"&lt;/span&gt;
  &lt;span class="nx"&gt;enforcement_level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"soft-mandatory"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two policies, about 120 lines of Sentinel. That is the entire safety apparatus.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Import in phases
&lt;/h2&gt;

&lt;p&gt;The temptation, once you have a working query file and generated config, is to apply all forty resources in one run. I did not, and I would push back on anyone who wanted to. I imported in dependency order, validating each layer before starting the next:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Networking&lt;/strong&gt; — VPC, subnets, gateways, route tables, routes, associations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAM&lt;/strong&gt; — roles, inline policies, customer-managed policies, OIDC provider&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security groups&lt;/strong&gt; — and their ingress rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute&lt;/strong&gt; — launch template, EKS cluster, node group&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load balancing&lt;/strong&gt; — target group, ALB, listener&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data&lt;/strong&gt; — DynamoDB table&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registry&lt;/strong&gt; — ECR repository&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Networking:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F78uadd6skxjir493v05h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F78uadd6skxjir493v05h.png" alt="VPC resource import plan in HCP Terraform" width="800" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;IAM:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fluwsxheszbx7k8hplof1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fluwsxheszbx7k8hplof1.png" alt="IAM resource import plan in HCP Terraform" width="800" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Compute:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fow3b2capdpoydrmmijqk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fow3b2capdpoydrmmijqk.png" alt="EKS resource import plan in HCP Terraform" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Why this is worth the extra runs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blast radius is one layer.&lt;/strong&gt; If something looks wrong in the security group phase, there are three resources to inspect, not forty across seven services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependencies resolve naturally.&lt;/strong&gt; The EKS cluster references subnet IDs, security group IDs, and a role ARN. By the time I reach compute, those are already in state and verified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plans stay readable.&lt;/strong&gt; This is the one I care about most. A plan with five imports is a plan I will actually read. A plan with forty is a plan I will scroll past while telling myself it looks fine. The policies exist because I do not fully trust myself on that — but the best way to use a guardrail is to not need it, and small plans are how you get there.&lt;/p&gt;

&lt;p&gt;The full sequence in the workspace history, each entry one layer, reviewed and applied on its own:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fooqrjkyyh39h9m5excmv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fooqrjkyyh39h9m5excmv.png" alt="Full Terraform run history for the phased import" width="800" height="401"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the guardrails earned their keep
&lt;/h2&gt;

&lt;p&gt;They fired twice. Both times they were right.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one that would have cost me my EKS cluster
&lt;/h3&gt;

&lt;p&gt;The starter configuration for my cluster came back with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_eks_cluster"&lt;/span&gt; &lt;span class="s2"&gt;"eks_clusters_0"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bootstrap_self_managed_addons&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One boolean, generated for me, with a value that looks like a sensible default. But &lt;code&gt;bootstrap_self_managed_addons&lt;/code&gt; is &lt;code&gt;ForceNew&lt;/code&gt; on &lt;code&gt;aws_eks_cluster&lt;/code&gt;, because it describes a decision made at creation time. My live cluster was created with it &lt;code&gt;true&lt;/code&gt;. A mismatch on that field does not produce an update — it produces a &lt;strong&gt;replacement&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So a plan I thought was importing my EKS cluster was proposing to destroy and recreate the cluster that was, at that moment, serving the app in the screenshot at the top of this post.&lt;/p&gt;

&lt;p&gt;The hard-mandatory policy caught it and failed the run:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfnv32blxbyfvdcu04bi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdfnv32blxbyfvdcu04bi.png" alt="Hard-mandatory Sentinel policy blocking a destructive EKS change" width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the whole argument, so it is worth sitting with. I &lt;em&gt;did&lt;/em&gt; review the config. &lt;code&gt;terraform validate&lt;/code&gt; passed cleanly — &lt;code&gt;false&lt;/code&gt; is a perfectly valid value for that argument, the right type, in the right place. Nothing about the line was wrong in isolation; it just did not match reality. The only way to catch it by eye was to already know that this specific boolean is &lt;code&gt;ForceNew&lt;/code&gt;, and then to spot the &lt;code&gt;# forces replacement&lt;/code&gt; marker in several hundred lines of plan output for a resource with a dozen nested blocks.&lt;/p&gt;

&lt;p&gt;A policy reading the plan's action list needs none of that context. It does not need to know what &lt;code&gt;bootstrap_self_managed_addons&lt;/code&gt; means. It needs to know that &lt;code&gt;["delete","create"]&lt;/code&gt; is not allowed during an import. That is the strength of policy as code — it checks a structural invariant instead of relying on my attention span.&lt;/p&gt;

&lt;p&gt;The fix was a one-character change: flip it to &lt;code&gt;true&lt;/code&gt; to match the cluster as deployed, re-run, and the plan became a clean import. Found by a guardrail instead of by an incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one that was just drift
&lt;/h3&gt;

&lt;p&gt;During the security group phase, the soft-mandatory policy spoke up. A few in-place &lt;code&gt;update&lt;/code&gt; actions — not destructive, just minor differences between my generated config and the live rules.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5rbbr163v7jfmblq0msi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5rbbr163v7jfmblq0msi.png" alt="Soft-mandatory Sentinel policy warning about in-place updates" width="799" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Exactly the behaviour I wanted. The run paused, told me which resources it intended to modify, and waited. I checked each one, confirmed they were benign, overrode deliberately, and applied.&lt;/p&gt;

&lt;p&gt;The difference in handling between these two cases is the point of having enforcement levels. &lt;code&gt;hard-mandatory&lt;/code&gt; for "this must never happen", &lt;code&gt;soft-mandatory&lt;/code&gt; for "a human needs to look at this". Collapse both into a blocking rule and the import becomes unworkable. Collapse both into a warning and the EKS replacement goes through.&lt;/p&gt;




&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Every component of the app is now in Terraform state and managed through HCP Terraform. Around 40 resources across 23 types, including the one that needed &lt;code&gt;awscc&lt;/code&gt;. About 1,300 lines of validated HCL, with the &lt;code&gt;import&lt;/code&gt; blocks as a precise record of what was adopted and when.&lt;/p&gt;

&lt;p&gt;And the app never went down. Not because I was careful, though I tried to be, but because the system was set up so that carelessness would be caught.&lt;/p&gt;

&lt;p&gt;What I had at the end is a &lt;em&gt;managed&lt;/em&gt; application. What I did not have is &lt;em&gt;well-structured&lt;/em&gt; code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next: a 1,600-line &lt;code&gt;main.tf&lt;/code&gt; is not where this ends
&lt;/h2&gt;

&lt;p&gt;Let me be honest about what came out of this, because "I imported it successfully" papers over a lot.&lt;/p&gt;

&lt;p&gt;The file is a monolith. Every ID is hardcoded — &lt;code&gt;vpc-037c4dd02c90XXXXX&lt;/code&gt; appears a dozen times, security group IDs are inlined into the EKS cluster's &lt;code&gt;vpc_config&lt;/code&gt;, role ARNs are string literals. Resource names are generator artefacts (&lt;code&gt;stack_subnets_0&lt;/code&gt;, &lt;code&gt;iam_roles_32&lt;/code&gt;, &lt;code&gt;ingress_rules_in_stack_groups_1_5&lt;/code&gt;) that describe the query that found them, not what they do. The region repeats on nearly every resource.&lt;/p&gt;

&lt;p&gt;This is a reasonable place for an import to &lt;strong&gt;end&lt;/strong&gt;. It is an unreasonable place to &lt;strong&gt;stay&lt;/strong&gt;. You cannot reuse any of it for a second environment, and the hardcoded IDs mean Terraform does not actually know the subnets belong to the VPC — it just happens to have the right strings in the right places.&lt;/p&gt;

&lt;p&gt;So the next step is refactoring, and I used &lt;strong&gt;HashiCorp's Agent Skills&lt;/strong&gt; again for it, since they encode HashiCorp's own module design best practices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decompose into &lt;code&gt;network&lt;/code&gt;, &lt;code&gt;iam&lt;/code&gt;, &lt;code&gt;security&lt;/code&gt;, &lt;code&gt;eks&lt;/code&gt;, &lt;code&gt;alb&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;registry&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Replace every hardcoded ID with a real reference, so the dependency graph reflects actual dependencies&lt;/li&gt;
&lt;li&gt;Promote region, environment name, and sizing to variables&lt;/li&gt;
&lt;li&gt;Name resources after their purpose, not their discovery order&lt;/li&gt;
&lt;li&gt;Define proper inputs and outputs at each module boundary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ID-to-reference conversion is the bulk of the work and the highest-value part, because it is what turns a snapshot of your infrastructure into a description of it. That turned out to be a post of its own, and it is now live: &lt;a href="https://dev.to/pranitraje/how-to-refactor-a-terraform-monolith-into-modules-without-downtime-2koj"&gt;Part 2: How to Refactor a Terraform Monolith into Modules Without Downtime&lt;/a&gt;. It covers splitting this file into seven modules with HashiCorp's Agent Skills and the Terraform MCP Server, preserving state with &lt;code&gt;moved&lt;/code&gt; blocks to get a &lt;code&gt;0 to add, 0 to change, 0 to destroy plan&lt;/code&gt;, and retiring the CloudFormation stack safely with &lt;code&gt;DeletionPolicy: Retain&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The framing I have settled on: &lt;strong&gt;import gets you &lt;em&gt;management&lt;/em&gt;; refactoring gets you &lt;em&gt;Infrastructure as Code&lt;/em&gt;.&lt;/strong&gt; Two different milestones, and worth being clear-eyed about which one you have reached.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would tell you before you try this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Discovery is now a reviewable artefact.&lt;/strong&gt; Moving from "paste IDs into a spreadsheet" to "a committed &lt;code&gt;.tfquery.hcl&lt;/code&gt; file" is a bigger shift than it looks. When someone adds a resource next month, I re-run the query instead of repeating the archaeology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check support before you plan the work, not during it.&lt;/strong&gt; List resource coverage varies by provider and version. The &lt;a href="https://github.com/quixoticmonk/extract-provider-supported-actions/blob/main/list-resources-report.json" rel="noopener noreferrer"&gt;supported-resources report&lt;/a&gt; takes a minute to check and tells you whether this workflow is even available for the resources you care about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bring your resource IDs to the party.&lt;/strong&gt; Search and Import shows you managed and unmanaged resources together. Picking the right ones out of that list requires knowing their exact identifiers beforehand — and knowing which identifier each resource type uses, because it varies. Do that inventory before you open the UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Learn the parent-pivot pattern.&lt;/strong&gt; Most child resources cannot be tagged. &lt;code&gt;for_each&lt;/code&gt; over a parent &lt;code&gt;list&lt;/code&gt; block's output is how you reach them. That one pattern unlocked routes, associations, SG rules, and listeners.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ground your AI assistant in the registry, or don't use it.&lt;/strong&gt; An Agent Skill for conventions plus the MCP Server for version-specific docs is what made AI assistance trustworthy rather than a source of plausible-looking errors. "Check the docs, never guess, record what you couldn't cover" belongs in your prompt verbatim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read "starter configuration" literally.&lt;/strong&gt; The generator writes back everything the API returned — computed attributes, conflicting arguments, zero-value noise. Budget real time for cleanup. Still dramatically faster than hand-writing forty &lt;code&gt;import&lt;/code&gt; blocks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A clean &lt;code&gt;terraform validate&lt;/code&gt; is not safety.&lt;/strong&gt; The one I would underline. The gap between "valid configuration" and "safe plan" is exactly where brownfield imports go wrong, and exactly the gap policy as code fills.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attach policies before the first import, not after the first scare.&lt;/strong&gt; Mine were in place before any resource entered state, which is why the EKS near-miss is an anecdote rather than an incident report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use both enforcement levels.&lt;/strong&gt; Hard for invariants that must never break, soft for things a human must consciously approve. One without the other gives you either a workflow you cannot complete or a warning nobody reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Import in layers.&lt;/strong&gt; Smaller plans get read properly, dependencies resolve in order, failures stay localised. The extra runs cost minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;The capability to import brownfield infrastructure has existed for years. What has been missing is not capability but &lt;em&gt;economics and confidence&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Search changes the economics — discovery becomes declarative and generation becomes bulk, so adopting forty resources is an afternoon rather than a sprint. Policy as code changes the confidence — you no longer have to be certain you read the plan correctly, because a machine checks the one invariant that matters on every run. MCP-grounded AI assistance makes the authoring in between fast without making it unreliable.&lt;/p&gt;

&lt;p&gt;The infrastructure that most needs to be in code is the infrastructure people are most afraid to touch. That fear is rational. It is also, I think, now addressable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 2&lt;/strong&gt;: &lt;a href="https://dev.to/pranitraje/how-to-refactor-a-terraform-monolith-into-modules-without-downtime-2koj"&gt;How to Refactor a Terraform Monolith into Modules Without Downtime&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo repo&lt;/strong&gt; (CloudFormation template, query file, prompts, app): &lt;a href="https://github.com/pranit-hashi/aws-brownfield-terraform-import" rel="noopener noreferrer"&gt;github.com/pranit-hashi/aws-brownfield-terraform-import&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentinel policies&lt;/strong&gt; used as guardrails: &lt;a href="https://github.com/pranit-hashi/import-sentinel-policies" rel="noopener noreferrer"&gt;github.com/pranit-hashi/import-sentinel-policies&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HashiCorp Agent Skills for Terraform&lt;/strong&gt; — highly recommended reading before you use them: &lt;a href="https://github.com/hashicorp/agent-skills/tree/main/plugins/terraform" rel="noopener noreferrer"&gt;github.com/hashicorp/agent-skills/tree/main/plugins/terraform&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform MCP Server&lt;/strong&gt;: &lt;a href="https://developer.hashicorp.com/terraform/mcp-server" rel="noopener noreferrer"&gt;developer.hashicorp.com/terraform/mcp-server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which resources support list/import?&lt;/strong&gt; An auto-updating report that tracks provider-level support, and the first thing I check before planning an import: &lt;a href="https://github.com/quixoticmonk/extract-provider-supported-actions/blob/main/list-resources-report.json" rel="noopener noreferrer"&gt;list-resources-report.json&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Setup used: Terraform 1.14+, &lt;code&gt;hashicorp/aws&lt;/code&gt; 6.67.0 and &lt;code&gt;hashicorp/awscc&lt;/code&gt; 1.104.0, HCP Terraform with Sentinel policy sets, the Terraform MCP Server, and the &lt;code&gt;terraform-search-import&lt;/code&gt; Agent Skill.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you try this on your own brownfield app, I would like to hear which resource type caught you out. My money is on something in IAM.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>aws</category>
      <category>devops</category>
      <category>eks</category>
    </item>
    <item>
      <title>How to create a CodePipeline with source from another AWS account?</title>
      <dc:creator>Pranit Raje</dc:creator>
      <pubDate>Sun, 22 Mar 2020 15:22:30 +0000</pubDate>
      <link>https://dev.to/pranitraje/how-to-create-a-codepipeline-with-source-from-another-aws-account-n0m</link>
      <guid>https://dev.to/pranitraje/how-to-create-a-codepipeline-with-source-from-another-aws-account-n0m</guid>
      <description>&lt;p&gt;When using AWS services, more often than not, we need multiple AWS accounts for various reasons. If you work on AWS DevOps tools, you must have come across &lt;a href="https://aws.amazon.com/codepipeline/" rel="noopener noreferrer"&gt;AWS CodePipeline&lt;/a&gt; which is a pipeline automation tool from AWS. While working with CodePipeline in multi-account scenario, I'm sure you all have faced many issues like &lt;em&gt;Access Denied&lt;/em&gt; errors which is the most puzzling and frustrating error IMHO because it doesn't always say where we're lacking permissions. In multi-account scenario for CodePipeline, if you're deploying your application in another AWS account, AWS has this excellent &lt;a href="https://docs.aws.amazon.com/codepipeline/latest/userguide/pipelines-create-cross-account.html" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; which I'd highly recommend you to go through. But what if you have a unique requirement when the source of your pipeline itself is in another AWS account and the deployment is in the same account as of CodePipeline? &lt;/p&gt;

&lt;p&gt;Recently, I came across this use-case and tried to find some starting guide to create a CodePipeline having source from another AWS account but couldn't find any useful resources. Hence, I decided to write this blog to help others who might run into such use-case.&lt;/p&gt;

&lt;p&gt;This blog will walk you through an example of deploying from &lt;a href="https://aws.amazon.com/ecr/" rel="noopener noreferrer"&gt;Amazon ECR&lt;/a&gt; to &lt;a href="https://aws.amazon.com/ecs/" rel="noopener noreferrer"&gt;Amazon ECS&lt;/a&gt; with the help of CodePipeline. Here, I'm having a use-case in which,&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Account-A has already-built Docker image hosted in ECR (Source stage)&lt;/li&gt;
&lt;li&gt;Account-B has S3 bucket (Source stage) containing &lt;a href="https://docs.aws.amazon.com/codepipeline/latest/userguide/file-reference.html#pipelines-create-image-definitions" rel="noopener noreferrer"&gt;imagedefinitions.json&lt;/a&gt; and ECS (Deploy stage).&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;NOTE: All AWS resources in this example exist in the same AWS region.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our requirement is such that we have a team of developers in Account-A who will build their Docker images locally, push it in ECR repository in Account-A and this Docker image should be deployed automatically to the resources (ECS) present in Account-B whenever our developers make changes to their Docker images and push it in ECR repository of Account-A. And, we want our CodePipeline to be set up in Account-B itself where our deployment is done. Sounds challenging right? No worries. Let's get started...&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Two active &lt;a href="https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-creating.html" rel="noopener noreferrer"&gt;AWS accounts&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A working &lt;a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/get-set-up-for-amazon-ecs.html" rel="noopener noreferrer"&gt;Amazon ECS cluster&lt;/a&gt; up and running with all other related resources e.g., &lt;a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/create-task-definition.html" rel="noopener noreferrer"&gt;Task Definition&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/AmazonECR/latest/userguide/repository-create.html" rel="noopener noreferrer"&gt;ECR repository&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/create-service-console-v2.html" rel="noopener noreferrer"&gt;ECS Service&lt;/a&gt;, any Docker image in one of the AWS account.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Target Architecture:
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fveblcjtmb550rq9b8q1u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fveblcjtmb550rq9b8q1u.png" alt="Target Architecture Diagram of Cross-Account CodePipeline where source is in another AWS account" width="800" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The above diagram shows the following workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Initially, the developers will build the Docker image and push it to their central ECR repository in Account A.&lt;/li&gt;
&lt;li&gt;Account A will have a EventBridge Rule created listening to our ECR repository in Account A. As soon as a successful Docker image is pushed to our ECR repository, an EventBridge Rule will be triggered in Account A.&lt;/li&gt;
&lt;li&gt;Default EventBridge Bus in Account B will be configured as a target to our EventBridge rule in Account A.&lt;/li&gt;
&lt;li&gt;We will have another EventBridge rule created in Account B which will listen to the push events from the ECR repository in Account A.&lt;/li&gt;
&lt;li&gt;The EventBridge rule in Account B will trigger our CodePipeline.&lt;/li&gt;
&lt;li&gt;CodePipeline will assume a cross-account IAM role created in Account A to list and describe the latest Docker image pushed into ECR repository.&lt;/li&gt;
&lt;li&gt;CodePipeline will read the Docker image and pass it to the next stage of CodePipeline.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://docs.aws.amazon.com/codepipeline/latest/userguide/file-reference.html#pipelines-create-image-definitions" rel="noopener noreferrer"&gt;imagedefinitions.zip&lt;/a&gt; file from S3 bucket will contain the information about the container name and the image URI of our Docker image stored in Account A’s ECR repository which will be used in ECS Standard deployment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now, let's see how we can build such pipeline from AWS Management Console in a step-by-step manner, &lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: IN ACCOUNT-B -&amp;gt; Set up a normal CodePipeline:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Firstly, create a standard pipeline having all the components of pipeline (ECR,S3,ECS) residing in same Account-B normally the way you would. We are creating this pipeline in the same account first just to get a backbone structure for our cross-account pipeline which we will modify according to our requirements in later steps.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;In this pipeline, Source stage will have two actions i.e. ECR and S3 where ECR will be your repository with image tag and S3 bucket will be having zip file of the JSON file named imagedefinitions.json. This JSON file is required for the ECS deployment as it contains container name and the ECR image URI with its image tag. Refer to this &lt;a href="https://docs.aws.amazon.com/codepipeline/latest/userguide/file-reference.html#pipelines-create-image-definitions" rel="noopener noreferrer"&gt;official documentation&lt;/a&gt; to know more about this file. Do remember to zip the file when S3 is used in the source stage.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;NOTE: This S3 bucket should be &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/user-guide/enable-versioning.html" rel="noopener noreferrer"&gt;versioned&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;When you include ECS (Standard deployment) as a deployment provider in your pipeline, make sure to point S3 action's output as an input to ECS because ECR action's output will contain information only about the Docker image and not the container name which is expected by CodePipeline job worker for ECS. &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Once you have working pipeline in Account-B, we will modify it for cross-account deployments having source from Account-A. &lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 2: IN ACCOUNT-B -&amp;gt; Create custom AWS KMS key:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;For any cross-account deployments, CodePipeline require custom KMS key allowing another AWS account to access encrypted CodePipeline artifacts stored in CodePipeline’s S3 bucket (artifact store).&lt;/li&gt;
&lt;li&gt;Create custom AWS KMS key allowing Account-A to access CodePipeline artifacts residing in Account-B’s S3 bucket.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;While creating KMS policy via console, when you reach to the 5th step &lt;em&gt;Review and edit key policy&lt;/em&gt;, enter the below policy by replacing appropriate ARN at the commented places.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    "Id": "key-consolepolicy",
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Enable IAM User Permissions",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::&amp;lt;ACCOUNT-B_ID&amp;gt;:root"  //MAKE CHANGES HERE
            },
            "Action": "kms:*",
            "Resource": "*"
        },
        {
            "Sid": "Allow access for Key Administrators",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::&amp;lt;ACCOUNT-B_ID&amp;gt;:user/&amp;lt;YOUR_IAM_USER&amp;gt;"   //MAKE CHANGES HERE
            },
           "Action": [
                "kms:Create*",
                "kms:Describe*",
                "kms:Enable*",
                "kms:List*",
                "kms:Put*",
                "kms:Update*",
                "kms:Revoke*",
                "kms:Disable*",
                "kms:Get*",
                "kms:Delete*",
                "kms:TagResource",
                "kms:UntagResource",
                "kms:ScheduleKeyDeletion",
                "kms:CancelKeyDeletion"
            ],
            "Resource": "*"
        },
        {
            "Sid": "Allow use of the key",
            "Effect": "Allow",
            "Principal": {
                "AWS": [
                    "arn:aws:iam::&amp;lt;ACCOUNT-B_ID&amp;gt;:user/&amp;lt;YOUR_IAM_USER&amp;gt;",   //MAKE CHANGES HERE
                    "arn:aws:iam::&amp;lt;ACCOUNT-B_ID&amp;gt;:role/service-role/AWSCodePipelineServiceRole-&amp;lt;AWS_REGION&amp;gt;-&amp;lt;CODEPIPELINE_NAME&amp;gt;",  //REPLACE WITH CODEPIPELINE SERVICE ROLE ARN
                    "arn:aws:iam::&amp;lt;ACCOUNT-A_ID&amp;gt;:root"  //MAKE CHANGES HERE
                ]
            },
            "Action": [
                "kms:Encrypt",
                "kms:Decrypt",
                "kms:ReEncrypt*",
                "kms:GenerateDataKey*",
                "kms:DescribeKey"
            ],
            "Resource": "*"
        },
        {
            "Sid": "Allow attachment of persistent resources",
            "Effect": "Allow",
            "Principal": {
                "AWS": [
                    "arn:aws:iam::&amp;lt;ACCOUNT-B_ID&amp;gt;:user/&amp;lt;YOUR_IAM_USER&amp;gt;",   //MAKE CHANGES HERE
                    "arn:aws:iam::&amp;lt;ACCOUNT-B_ID&amp;gt;:role/service-role/AWSCodePipelineServiceRole-&amp;lt;AWS_REGION&amp;gt;-&amp;lt;CODEPIPELINE_NAME&amp;gt;",  //REPLACE WITH CODEPIPELINE SERVICE ROLE ARN
                    "arn:aws:iam::&amp;lt;ACCOUNT-A_ID&amp;gt;:root"  //MAKE CHANGES HERE
                ]
            },
            "Action": [
                "kms:CreateGrant",
                "kms:ListGrants",
                "kms:RevokeGrant"
            ],
            "Resource": "*",
            "Condition": {
                "Bool": {
                    "kms:GrantIsForAWSResource": "true"
                }
            }
        }
    ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The above KMS key policy will allow your IAM user, CodePipeline service role and Account-A to access this KMS key with which our artifacts will be encrypted.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 3: IN ACCOUNT-A -&amp;gt; Create a custom IAM role for cross-account access:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create a new IAM role named &lt;em&gt;CrossAccount-A_Role&lt;/em&gt;. You can name this role anything you want as long as it follows the naming conventions in IAM. Consider giving the role a name that clearly states its purpose.&lt;/li&gt;
&lt;li&gt;On the &lt;em&gt;Create role&lt;/em&gt; page, choose &lt;em&gt;Another AWS account&lt;/em&gt; option from &lt;em&gt;Select type of trusted entity&lt;/em&gt; section. Enter the AWS Account ID of Account-B in the &lt;em&gt;Specify accounts that can use this role&lt;/em&gt; section. Click &lt;em&gt;Next: Permissions&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Attach two AWS managed policies named &lt;em&gt;AmazonEC2ContainerRegistryReadOnly&lt;/em&gt; and &lt;em&gt;AmazonS3FullAccess&lt;/em&gt; to this IAM Role. &lt;/li&gt;
&lt;li&gt;Create another custom inline IAM policy named &lt;em&gt;cross-account-KMS&lt;/em&gt; for this same IAM role to access Account-B’s KMS key which we created in Step 2. Enter the below IAM policy by replacing ARN of KMS key in Account-B at the commented place for this inline policy.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "kms:DescribeKey",
                "kms:GenerateDataKey*",
                "kms:Encrypt",
                "kms:ReEncrypt*",
                "kms:Decrypt"
            ],
            "Resource": [
                "&amp;lt;ARN_OF_KMS_KEY_IN_ACCOUNT-B&amp;gt;"  //MAKE CHANGE HERE
            ]
        }
    ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: IN ACCOUNT-B -&amp;gt; Create a policy for the S3 bucket that grants access to Account A:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Choose your CodePipeline’s S3 bucket i.e. artifact store (not the source S3 bucket) and edit its Bucket policy according to the following policy by replacing appropriate ARN at the commented places.&lt;/li&gt;
&lt;li&gt;The below S3 bucket policy will allow Account-A to access our pipeline's artifacts.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    "Version": "2012-10-17",
    "Id": "SSEAndSSLPolicy",
    "Statement": [
    {
        "Sid": "DenyUnEncryptedObjectUploads",
        "Effect": "Deny",
        "Principal": "*",
        "Action": "s3:PutObject",
        "Resource": "arn:aws:s3:::&amp;lt;CODEPIPELINE'S_S3_BUCKET_NAME&amp;gt;/*",   //MAKE CHANGE HERE
        "Condition": {
                "StringNotEquals": {
                    "s3:x-amz-server-side-encryption": "aws:kms"
                }
            }
    },
    {
        "Sid": "DenyInsecureConnections",
        "Effect": "Deny",
        "Principal": "*",
        "Action": "s3:*",
        "Resource": "arn:aws:s3:::&amp;lt;CODEPIPELINE'S_S3_BUCKET_NAME&amp;gt;/*",   //MAKE CHANGE HERE
        "Condition": {
            "Bool": {
                    "aws:SecureTransport": false
                }
            }
    },
    {
        "Sid": "",
        "Effect": "Allow",
        "Principal": {
            "AWS": "arn:aws:iam::&amp;lt;ACCOUNT-A_ID&amp;gt;:root"   //MAKE CHANGE HERE
            },
        "Action": [
                "s3:Get*",
                "s3:Put*"
            ],
        "Resource": "arn:aws:s3:::&amp;lt;CODEPIPELINE'S_S3_BUCKET_NAME&amp;gt;/*"   //MAKE CHANGE HERE
    },
    {
        "Sid": "",
        "Effect": "Allow",
        "Principal": {
            "AWS": "arn:aws:iam::&amp;lt;ACCOUNT-A_ID&amp;gt;:root"   //MAKE CHANGE HERE
                },
        "Action": "s3:ListBucket",
        "Resource": "arn:aws:s3:::&amp;lt;CODEPIPELINE'S_S3_BUCKET_NAME&amp;gt;"   //MAKE CHANGE HERE
        }
    ]
} 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: IN ACCOUNT-B -&amp;gt; Create an inline policy for service role of CodePipeline:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;As we want CodePipeline service role to assume the cross-account IAM role we created in Step 3, we should add an extra inline IAM policy to the CodePipeline service role.&lt;/li&gt;
&lt;li&gt;Enter the below IAM policy by replacing Account-A ID at the commented place for this inline policy.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    "Version": "2012-10-17",
    "Statement": {
        "Effect": "Allow",
        "Action": "sts:AssumeRole",
        "Resource": [
            "arn:aws:iam::&amp;lt;ACCOUNT-A_ID&amp;gt;:role/*"   //MAKE CHANGE HERE
        ]
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 6: IN ACCOUNT-A -&amp;gt; Apply ECR Repository Policy:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;To allow CodePipeline in Account-B to pull ECR images residing in Account-A, ECR repository should allow Account-B to pull those images from its repository. For this, we will apply resource-based policy to our ECR repository.&lt;/li&gt;
&lt;li&gt;You can use the below ECR repository policy by replacing appropriate ARN at the commented places.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "Version": "2008-10-17",
  "Statement": [
    {
      "Sid": "Cross-account-policy",
      "Effect": "Allow",
      "Principal": {
        "AWS": [
          "arn:aws:iam::&amp;lt;ACCOUNT-B_ID&amp;gt;:root",   //MAKE CHANGE HERE
          "arn:aws:iam::&amp;lt;ACCOUNT-A_ID&amp;gt;:role/CrossAccount-A_Role"   //ARN OF CROSS-ACCOUNT IAM ROLE CREATED IN ACCOUNT-A AT STEP 3   
        ]
      },
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:BatchGetImage",
        "ecr:CompleteLayerUpload",
        "ecr:DescribeImages",
        "ecr:DescribeRepositories",
        "ecr:GetDownloadUrlForLayer",
        "ecr:GetLifecyclePolicy",
        "ecr:GetLifecyclePolicyPreview",
        "ecr:GetRepositoryPolicy",
        "ecr:ListImages",
        "ecr:UploadLayerPart"
      ]
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 7: IN ACCOUNT-B -&amp;gt; Modify your pipeline’s configuration file:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Now that we have most of the required components of our cross-account pipeline ready, we will modify the configuration file of our pipeline that we created in Step 1 by changing the source to our ECR repository in Account-A and we will also add KMS related details.&lt;/li&gt;
&lt;li&gt;To do that, run get-pipeline command &lt;code&gt;aws codepipeline get-pipeline --name &amp;lt;CODEPIPELINE_NAME&amp;gt; --region &amp;lt;AWS_REGION&amp;gt; &amp;gt; pipeline.json&lt;/code&gt; from AWS CLI.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Open this pipeline.json file in your favourite text editor (vim?) and add ‘encryptionKey’ in ‘artifactStore’ section.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; "artifactStore": {
    "type": "S3",
    "location": "codepipeline-&amp;lt;AWS_REGION&amp;gt;-XXXXXXXXXX",  //MAKE CHANGE HERE
    "encryptionKey": {
        "id": "arn:aws:kms:&amp;lt;AWS_REGION&amp;gt;:&amp;lt;ACCOUNT-B_ID&amp;gt;:key/XXXXXXXXXX-XXXX-XXX-XXXX-XXXXXXXXXX",    //MAKE CHANGE HERE    
        "type": "KMS"
        }
     }
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Then, modify your ECR action of Source stage where you will change the values of ECR repository name and its image tag which is in Account-A.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;In the same ECR action, include the &lt;code&gt;"roleArn": "arn:aws:iam::&amp;lt;ACCOUNT-A_ID&amp;gt;:role/CrossAccount-A_Role"&lt;/code&gt; which we created in Step 3 so that this cross-account IAM role from Account-A will be used for ECR action. Following is the snippet for our cross-account source action in pipeline's JSON file.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;NOTE: Replace appropriate values at commented places below&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    "name": "Source",
    "actionTypeId": {
      "category": "Source",
      "owner": "AWS",
      "provider": "ECR",
      "version": "1"
     },
    "runOrder": 1,
    "configuration": {
      "ImageTag": "latest",    //MAKE CHANGE HERE IN CASE OF CUSTOM TAG
      "RepositoryName": "XXXXX"    //MAKE CHANGE HERE
     },
    "outputArtifacts": [
      {
        "name": "SourceArtifact"
      }
     ],
    "inputArtifacts": [],
    "region": "&amp;lt;AWS_REGION&amp;gt;",  //MAKE CHANGE HERE
    "namespace": "SourceVariables",
    "roleArn": "arn:aws:iam::&amp;lt;ACCOUNT-A_ID&amp;gt;:role/CrossAccount-A_Role"  //MENTION CROSS-ACCOUNT IAM ROLE    
}
&lt;/code&gt;&lt;/pre&gt;


&lt;blockquote&gt;
&lt;p&gt;NOTE: Above is just a snippet of our ECR action of Source stage and not the entire JSON configuration of pipeline.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Make sure to perform the 4th step of this &lt;a href="https://docs.aws.amazon.com/codepipeline/latest/userguide/pipelines-create-cross-account.html#pipelines-create-cross-account-create" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; to remove the unwanted metadata section from configuration file.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Once you make the above changes, update the pipeline from update-pipeline CLI command &lt;code&gt;aws codepipeline update-pipeline --region &amp;lt;AWS_REGION&amp;gt; --cli-input-json file://pipeline.json&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;After you update your pipeline, don't forget to update your imagedefintions.json file with the correct imageURI from Account-A if you haven't done so. It should look like below,&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[
  {
    "name": "&amp;lt;CONTAINER_NAME&amp;gt;",  //MAKE CHANGES HERE
    "imageUri": "&amp;lt;ACCOUNT-A_ID&amp;gt;.dkr.ecr.&amp;lt;AWS_REGION&amp;gt;.amazonaws.com/&amp;lt;REPOSITORY_NAME&amp;gt;:&amp;lt;IMAGE_TAG&amp;gt;"  //MAKE CHANGES HERE    
  }
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Once updated the imagedefinitions.json file, zip it and upload it again to your source S3 bucket. &lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As you might be aware that &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/WhatIsCloudWatchEvents.html" rel="noopener noreferrer"&gt;CloudWatch Event (CWE)&lt;/a&gt; Rules are the resources in the background which trigger your pipeline whenever there are any changes to your source present in AWS. Since, one of our source action, i.e. ECR is existing in another AWS account, CodePipeline cannot create this CWE rule automatically for you for ECR as it did for S3 action. Hence, we will now create CWE rule for ECR repository with the help of &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/CloudWatchEvents-CrossAccountEventDelivery.html" rel="noopener noreferrer"&gt;CloudWatch Event Bus&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: IN ACCOUNT-B -&amp;gt; Edit default Event Bus to allow account A:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to CloudWatch console and select your default Event Bus.&lt;/li&gt;
&lt;li&gt;Click on &lt;em&gt;Add permission&lt;/em&gt; to add your Account-A ID and check the &lt;em&gt;Everybody(*)&lt;/em&gt; option.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 9: IN ACCOUNT-A -&amp;gt; Create CWE Rule for your ECR repository:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Follow this &lt;a href="https://docs.aws.amazon.com/codepipeline/latest/userguide/create-cwe-ecr-source-console.html" rel="noopener noreferrer"&gt;official documentation&lt;/a&gt; till &lt;strong&gt;Step 5&lt;/strong&gt; to create CWE rule for your ECR repository as a source for your CWE rule.&lt;/li&gt;
&lt;li&gt;As a target for this CWE rule, select &lt;em&gt;Event bus in another AWS account&lt;/em&gt;. Enter your Account-B ID and select &lt;em&gt;Create a new role for this specific resource&lt;/em&gt; option for IAM role which will automatically create IAM role for this CWE rule. &lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 10: IN ACCOUNT-B -&amp;gt; Create CWE Rule with CodePipeline as a target:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Create a new CWE rule with Event Source exactly same as you made in Account-A. Edit this event pattern by adding &lt;em&gt;account&lt;/em&gt; section with the value as Account-A’s ID. This will listen to any changes occurring to your ECR repository present in Account-A with the help of Event Bus. Refer to this &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/CloudWatchEvents-CrossAccountEventDelivery.html#WritingRulesThatMatchEventsFromAnotherAccount" rel="noopener noreferrer"&gt;official documentation&lt;/a&gt; to know how to do this exactly. Once you create Event pattern, it should look like below,&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "detail-type": [
    "ECR Image Action"
  ],
  "account": [
    "ACCOUNT-A_ID"  //MAKE CHANGES HERE
  ],
  "source": [
    "aws.ecr"
  ],
  "detail": {
    "action-type": [
      "PUSH"
    ],
    "image-tag": [
      "latest"  //MAKE CHANGE HERE IN CASE OF CUSTOM TAG
    ],
    "repository-name": [
      "&amp;lt;REPOSITORY_NAME&amp;gt;"  //MAKE CHANGES HERE
    ],
    "result": [
      "SUCCESS"
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Put your CodePipeline ARN (&lt;code&gt;arn:aws:codepipeline:&amp;lt;AWS_REGION&amp;gt;:&amp;lt;ACCOUNT-B_ID&amp;gt;:&amp;lt;PIPELINE_NAME&amp;gt;&lt;/code&gt;) as a target for this CWE rule. &lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hooray!! Now, we have successfully set up CodePipeline in Account-B with ECR being in Account-A, S3 bucket containing only zipped imagedefinitions.json file for ECS deployment and ECS service in Account-B. Whenever you make any changes to any of your source, your pipeline will be triggered in a CI/CD manner. &lt;/p&gt;

&lt;h3&gt;
  
  
  Automation &amp;amp; Scale:
&lt;/h3&gt;

&lt;p&gt;Following the step-by-step instructions from the above section can be useful if we want to understand what we are doing to implement the architecture. However, once we understand that and when we want to automate the whole target architecture, it can be deployed in an automated way using &lt;a href="https://aws.amazon.com/cloudformation/" rel="noopener noreferrer"&gt;AWS CloudFormation&lt;/a&gt; which is an Infrastructure as Code (IaC) tool from AWS. To automate the deployment in multiple AWS accounts, make sure to use the CloudFormation templates from &lt;a href="https://github.com/StanForever/cross-account-codepipeline/" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;. In the repository, we have three CloudFormation templates which should be deployed sequentially as noted below:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In Account B, create a new CloudFormation stack using AccB-pipeline-1.yaml template.&lt;/li&gt;
&lt;li&gt;In Account A, create a new CloudFormation stack using AccA-pipeline.yaml template.&lt;/li&gt;
&lt;li&gt;In Account B, run following AWS CLI command from Account B to allow Account A to send ECR push events to Event Bus in Account B.
&lt;code&gt;aws events put-permission --action events:PutEvents --statement-id MySid --principal &amp;lt;ACCOUNT_A_ID&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;In Account B, update the CloudFormation stack created in Step 1 using AccB-pipeline-2.yaml template.  In this template, we will use one additional parameter named CrossAccountRoleName which will be the name of cross-account role created in Step 2. The IAM role name will be available from the Outputs section of CloudFormation console. Along with that, we will also put the ECR repository name created in Account A in Step 2 which is available in the Outputs section of CloudFormation console.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;In Account B, make sure to update imagedefintions.json file with the correct imageURI from Account A. It may look like below,&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[
{
"name": "&amp;lt;CONTAINER_NAME&amp;gt;",  //MAKE CHANGES HERE
"imageUri": "&amp;lt;ACCOUNT-A_ID&amp;gt;.dkr.ecr.&amp;lt;AWS_REGION&amp;gt;.amazonaws.com/&amp;lt;REPOSITORY_NAME&amp;gt;:&amp;lt;IMAGE_TAG&amp;gt;"  //MAKE CHANGES HERE    
}
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;In Account A, push already built Docker image to ECR repository in Account A.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;In Account B, notice how CodePipeline will get triggered automatically as soon as any Docker image is pushed to ECR repository in Account A. This Docker image will be deployed to ECS cluster in Account B in CI/CD manner.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Thanks for reading this blog! I hope you will find this useful. Feel free to like, share and comment on this article with your constructive feedback. If you find this blog helpful or if you're stuck at any of the above step, do let me know via comments section and I'd be more than happy to help!&lt;/p&gt;

&lt;p&gt;Have an AWSome Day! 😎&lt;/p&gt;

</description>
      <category>codepipeline</category>
      <category>aws</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
