<?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: Karl Schriek</title>
    <description>The latest articles on DEV Community by Karl Schriek (@karlschriek).</description>
    <link>https://dev.to/karlschriek</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%2F3761645%2Fd85e3ad5-4809-4396-b6e2-f67f3a4b7068.png</url>
      <title>DEV Community: Karl Schriek</title>
      <link>https://dev.to/karlschriek</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/karlschriek"/>
    <language>en</language>
    <item>
      <title>Scaling Terraform Infrastructure Beyond a Single Team</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Mon, 06 Jul 2026 12:38:25 +0000</pubDate>
      <link>https://dev.to/karlschriek/scaling-terraform-infrastructure-beyond-a-single-team-1nf</link>
      <guid>https://dev.to/karlschriek/scaling-terraform-infrastructure-beyond-a-single-team-1nf</guid>
      <description>&lt;p&gt;When a single engineer manages all the Terraform in an organisation, everything is simple. One repo, one state, one pipeline, one set of credentials. There's no coordination overhead because there's no one to coordinate with.&lt;/p&gt;

&lt;p&gt;That stops working the moment a second team needs to deploy infrastructure. And by the time you have three or four teams — networking, platform, application, security — the single-team model is actively slowing everyone down.&lt;/p&gt;

&lt;p&gt;This guide covers what breaks, how teams typically work around it, and how to set up a structure where each team owns their slice of infrastructure independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks
&lt;/h2&gt;

&lt;h3&gt;
  
  
  State lock contention
&lt;/h3&gt;

&lt;p&gt;Terraform's state locking is per-state. When the networking team is running &lt;code&gt;terraform plan&lt;/code&gt;, the application team's pipeline is blocked — even though they're changing completely unrelated resources. The more teams share a state, the more time everyone spends waiting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blast radius
&lt;/h3&gt;

&lt;p&gt;A junior engineer deploying a new application service shouldn't be able to accidentally destroy the VPC. But if application resources and networking resources share a state, a single misconfigured &lt;code&gt;terraform apply&lt;/code&gt; can touch anything. Code review catches some of this. Not all of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Credential sprawl
&lt;/h3&gt;

&lt;p&gt;A shared pipeline needs credentials for everything — the networking team's Azure subscription, the application team's AWS account, the security team's DNS provider. Every team's secrets end up in one CI environment, accessible to anyone who can trigger a run. This fails most compliance audits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approval bottlenecks
&lt;/h3&gt;

&lt;p&gt;In many organisations, one person or a small group gatekeeps all infrastructure changes. Every PR needs their review. Every apply needs their approval. The gatekeeper becomes a bottleneck not because they're slow, but because they're a single point of serialisation for all infrastructure work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backend access as implicit access control
&lt;/h3&gt;

&lt;p&gt;Terraform has no built-in concept of per-team or per-workspace permissions. All workspaces in a backend share the same credentials, so &lt;a href="https://github.com/hashicorp/terraform/issues/23874" rel="noopener noreferrer"&gt;giving a user access to one workspace implicitly grants access to all of them&lt;/a&gt;. There's been a &lt;a href="https://github.com/hashicorp/terraform/issues/16627" rel="noopener noreferrer"&gt;long-standing request to support separate backend configurations per workspace&lt;/a&gt;, and a related request to &lt;a href="https://github.com/hashicorp/terraform/issues/13022" rel="noopener noreferrer"&gt;allow variables in backend configuration blocks&lt;/a&gt; — both still open. Teams that need isolation end up managing separate backends per team — which works, but now the cross-team dependency problem (how to pass outputs between backends) sits on top of the access control problem. The demand for a scalable multi-root-module architecture is significant — OpenTofu's proposal to &lt;a href="https://github.com/opentofu/opentofu/issues/2860" rel="noopener noreferrer"&gt;make terraliths a thing of the past&lt;/a&gt; has drawn significant community support.&lt;/p&gt;

&lt;h3&gt;
  
  
  Knowledge boundaries
&lt;/h3&gt;

&lt;p&gt;The networking team understands route tables and peering. The application team understands container orchestration and databases. When both work in the same Terraform codebase, they need to understand each other's resources well enough to avoid breaking them. That cross-training is expensive and doesn't scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typical approaches
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Separate repos and pipelines per team
&lt;/h3&gt;

&lt;p&gt;The most common first attempt: give each team their own repo, their own CI pipeline, and their own state backend. This solves the isolation problem but creates a new one — how do teams share outputs? The networking team produces a &lt;code&gt;vpc_id&lt;/code&gt; that the application team needs.&lt;/p&gt;

&lt;p&gt;Teams end up with one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manual handoff&lt;/strong&gt;: someone copies an output value into another team's &lt;code&gt;terraform.tfvars&lt;/code&gt;. This is error-prone and doesn't trigger re-deploys when the upstream value changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;terraform_remote_state&lt;/code&gt;&lt;/strong&gt;: each consuming team configures a data source pointing at the producer's state backend. This tightly couples teams to each other's backend configuration and provides no change detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shell scripts or CI glue&lt;/strong&gt;: a pipeline runs &lt;code&gt;terraform output&lt;/code&gt; on one state and feeds the result into &lt;code&gt;terraform apply -var&lt;/code&gt; on the next. The dependency graph lives in CI configuration rather than in code, and it's fragile.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Workspaces
&lt;/h3&gt;

&lt;p&gt;Terraform workspaces let you run the same configuration against multiple state files. Some teams use this to give each team their own workspace. But workspaces don't solve cross-team dependencies — they're designed for multiple instances of the same infrastructure (dev, staging, prod), not for splitting ownership of different infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Terragrunt
&lt;/h3&gt;

&lt;p&gt;Terragrunt adds a layer on top of Terraform that can manage dependencies between configurations. It works, but introduces its own complexity — &lt;code&gt;terragrunt.hcl&lt;/code&gt; files, &lt;code&gt;dependency&lt;/code&gt; blocks, wrapper commands. Teams now need to learn Terragrunt in addition to Terraform, and debugging requires understanding both layers. Your Terraform code also becomes coupled to Terragrunt's conventions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Platform team as intermediary
&lt;/h3&gt;

&lt;p&gt;Some organisations create a platform team that owns all the Terraform and exposes a simplified interface (YAML files, internal portals, or custom tooling) to application teams. This can work well, but it means application teams can't deploy infrastructure directly — they file tickets or submit YAML and wait. The platform team becomes the bottleneck instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  A better structure
&lt;/h2&gt;

&lt;p&gt;The goal is straightforward: each team owns their own Terraform modules with their own state, credentials, and approval workflows, while cross-team dependencies are handled automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Define ownership boundaries
&lt;/h3&gt;

&lt;p&gt;Start by mapping teams to infrastructure boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Platform team       → networking, DNS, shared services
Application team A  → their databases, caches, storage
Application team B  → their databases, queues, functions
Security team       → IAM policies, compliance resources, audit logging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each boundary becomes an independent Terraform root module with its own state. The platform team's networking module produces outputs (&lt;code&gt;vpc_id&lt;/code&gt;, &lt;code&gt;subnet_ids&lt;/code&gt;) that the application teams consume as inputs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scope credentials per team
&lt;/h3&gt;

&lt;p&gt;Each team's deployment environment should only have the credentials it needs. The platform team's Runner has access to the networking subscription. Application team A's Runner has access to their project's service account. No team has access to another team's cloud credentials.&lt;/p&gt;

&lt;p&gt;This isn't just a security measure — it's an organisational one. When teams know they can't accidentally (or intentionally) touch resources outside their boundary, they move faster and with more confidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scope approvals per team
&lt;/h3&gt;

&lt;p&gt;The platform team should approve changes to networking. Application team A should approve changes to their own databases. Neither team should need the other's approval for changes within their boundary.&lt;/p&gt;

&lt;p&gt;This requires an approval system that understands infrastructure boundaries — not just "can this user approve?" but "can this user approve changes to &lt;em&gt;this specific Module&lt;/em&gt;?"&lt;/p&gt;

&lt;h3&gt;
  
  
  Wire dependencies declaratively
&lt;/h3&gt;

&lt;p&gt;When the platform team changes a subnet, the application teams that depend on those subnets should automatically re-plan and re-deploy. This should happen without the platform team needing to notify anyone, without the application teams needing to poll for changes, and without a CI pipeline encoding the dependency graph in YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Snap CD handles this
&lt;/h2&gt;

&lt;p&gt;Snap CD's architecture maps directly to the multi-team structure described above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Modules as ownership units
&lt;/h3&gt;

&lt;p&gt;Each team's Terraform root becomes a Snap CD &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Module&lt;/a&gt;. Modules are grouped into &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Namespaces&lt;/a&gt; within a &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Stack&lt;/a&gt;, creating a natural hierarchy:&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;"snapcd_stack"&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod"&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;"snapcd_namespace"&lt;/span&gt; &lt;span class="s2"&gt;"platform"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"platform"&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_namespace"&lt;/span&gt; &lt;span class="s2"&gt;"app_a"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-a"&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-networking.git"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"app_a_database"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"database"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/app-a-database.git"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Scoped permissions
&lt;/h3&gt;

&lt;p&gt;Snap CD's &lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;permission system&lt;/a&gt; lets you assign roles at any level of the hierarchy — Organization, Stack, Namespace, or individual Module:&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;# Platform team owns their Namespace&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_namespace_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"platform_team"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform_team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Group"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Owner"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;# App team A owns their Namespace&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_namespace_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"app_a_team"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_a_team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Group"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Owner"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_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;# App team A can read platform Outputs (to see what's available)&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_namespace_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"app_a_reads_platform"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_a_team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Group"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Reader"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each team can deploy, approve, and manage their own Modules without involving anyone else. They can read the platform team's Outputs but can't modify platform resources.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isolated Runners
&lt;/h3&gt;

&lt;p&gt;Each team deploys their own &lt;a href="https://docs.snapcd.io/resources/runner/" rel="noopener noreferrer"&gt;Runner&lt;/a&gt; with only the credentials they need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The platform team's Runner has Azure Networking Contributor credentials.&lt;/li&gt;
&lt;li&gt;App team A's Runner has access to their specific resource group.&lt;/li&gt;
&lt;li&gt;Neither Runner can access the other team's cloud resources.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Snap CD's permission system also controls which Modules can use which Runners, so even if a team tried to point their Module at the platform Runner, it would be denied.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automatic dependency wiring
&lt;/h3&gt;

&lt;p&gt;Cross-team dependencies are declared once and enforced automatically:&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;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_a_database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the platform team changes networking and the &lt;code&gt;vpc_id&lt;/code&gt; Output updates, Snap CD automatically queues a re-plan for app team A's database Module. The app team's approval workflow decides whether to apply it. No manual handoff, no polling, no CI glue.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical example
&lt;/h2&gt;

&lt;p&gt;An organisation with three teams:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;Namespace&lt;/th&gt;
&lt;th&gt;Modules&lt;/th&gt;
&lt;th&gt;Runner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prod/platform&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;networking, dns, shared-services&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;runner-platform&lt;/code&gt; (Azure Networking + DNS credentials)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App team A&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prod/app-a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;api-database, api-cache, api-storage&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;runner-app-a&lt;/code&gt; (Azure App A resource group credentials)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App team B&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prod/app-b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;worker-queue, worker-functions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;runner-app-b&lt;/code&gt; (AWS App B account credentials)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each team:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Owns their Namespace and everything in it.&lt;/li&gt;
&lt;li&gt;Deploys using their own Runner with scoped credentials.&lt;/li&gt;
&lt;li&gt;Approves their own changes without involving other teams.&lt;/li&gt;
&lt;li&gt;Receives automatic re-plans when upstream dependencies change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The platform team can ship a networking change without notifying anyone. Both app teams automatically re-plan if relevant Outputs changed. If nothing changed that affects them, nothing happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with two teams, not five.&lt;/strong&gt; Split the most obvious boundary first — usually platform vs. application. Add more boundaries as the need becomes clear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give each team a Namespace, not just Modules.&lt;/strong&gt; Namespaces let you assign permissions once for the whole group rather than per-Module.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;Reader&lt;/code&gt; roles for cross-team visibility.&lt;/strong&gt; Teams should be able to see what other teams are deploying without being able to modify it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't share Runners across trust boundaries.&lt;/strong&gt; A Runner that has both prod networking and prod application credentials defeats the purpose of isolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document the dependency graph.&lt;/strong&gt; Even though Snap CD manages it automatically, teams should understand which of their Inputs come from other teams and what would trigger a re-plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resist the urge to centralise approvals.&lt;/strong&gt; If you've scoped permissions correctly, each team is qualified to approve their own changes. A central approval requirement reintroduces the bottleneck you're trying to eliminate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — how the Module, Namespace, and Stack hierarchy works in detail&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — per-team Runner deployment patterns&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/permission-system" rel="noopener noreferrer"&gt;A Permission System Built for Infrastructure&lt;/a&gt; — scoped RBAC for multi-team workflows&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/event-driven-cd" rel="noopener noreferrer"&gt;Event-driven Continuous Deployment&lt;/a&gt; — how Output changes cascade through the dependency graph&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/large-terraform-states" rel="noopener noreferrer"&gt;The Problem with Large Terraform States&lt;/a&gt; — why splitting states is a prerequisite for team independence&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>terraform</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Managing Terraform Across Multiple Cloud Providers</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Mon, 06 Jul 2026 12:37:27 +0000</pubDate>
      <link>https://dev.to/karlschriek/managing-terraform-across-multiple-cloud-providers-k48</link>
      <guid>https://dev.to/karlschriek/managing-terraform-across-multiple-cloud-providers-k48</guid>
      <description>&lt;p&gt;Most organisations don't live in a single cloud. You might run compute in AWS, DNS in Cloudflare, identity in Azure AD, and logging in GCP. Terraform handles each provider fine on its own, but the moment you need to coordinate across providers the tooling fights you.&lt;/p&gt;

&lt;p&gt;This guide walks through the common pain points of multi-cloud Terraform setups and the approaches teams use to cope — then shows how Snap CD makes cross-cloud dependency management a solved problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it gets difficult
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Credential sprawl
&lt;/h3&gt;

&lt;p&gt;Each cloud provider has its own authentication mechanism. AWS uses IAM roles and access keys. Azure uses service principals and managed identities. GCP uses service accounts and workload identity federation. A single Terraform state that spans providers needs credentials for all of them — which means your CI runner or developer workstation holds keys to everything.&lt;/p&gt;

&lt;p&gt;That's a security problem. A compromised CI pipeline with AWS and Azure credentials exposes both clouds simultaneously. And it's an operational problem — rotating credentials means updating every pipeline that touches that state. This problem compounds at scale: Terraform &lt;a href="https://github.com/hashicorp/terraform/issues/32994" rel="noopener noreferrer"&gt;couples provider processes tightly to credentials&lt;/a&gt;, so managing hundreds of accounts across clouds means spawning thousands of provider processes, which quickly becomes unmanageable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Provider version conflicts
&lt;/h3&gt;

&lt;p&gt;Terraform providers are versioned independently. Upgrading the AWS provider to fix a bug in &lt;code&gt;aws_eks_cluster&lt;/code&gt; shouldn't require you to also test a new version of the Azure provider. But when they share a state, a &lt;code&gt;terraform init -upgrade&lt;/code&gt; pulls new versions for everything, and a regression in one provider blocks all deployments. Terraform also lacks built-in support for &lt;a href="https://github.com/hashicorp/terraform/issues/19932" rel="noopener noreferrer"&gt;instantiating multiple providers with a loop&lt;/a&gt; and &lt;a href="https://github.com/hashicorp/terraform/issues/24476" rel="noopener noreferrer"&gt;passing providers to modules in &lt;code&gt;for_each&lt;/code&gt;&lt;/a&gt;, making multi-cloud configurations especially verbose and repetitive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blast radius across clouds
&lt;/h3&gt;

&lt;p&gt;A misconfigured &lt;code&gt;terraform apply&lt;/code&gt; in a single-cloud state damages resources in one cloud. A misconfigured apply in a multi-cloud state can damage resources everywhere. The blast radius scales with the number of providers in the state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Slow plans
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;terraform plan&lt;/code&gt; refreshes every resource in state. When your state contains resources across three clouds, the plan makes API calls to all three — and it's only as fast as the slowest provider. A plan that takes 30 seconds per cloud takes 90 seconds when they're all in one state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typical approaches
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Separate repos per cloud
&lt;/h3&gt;

&lt;p&gt;The simplest split: one repo for AWS infrastructure, one for Azure, one for GCP. Each has its own state, its own CI pipeline, its own credentials.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;infra&lt;/span&gt;-&lt;span class="n"&gt;aws&lt;/span&gt;/        &lt;span class="c"&gt;# VPCs, EKS, S3 buckets
&lt;/span&gt;&lt;span class="n"&gt;infra&lt;/span&gt;-&lt;span class="n"&gt;azure&lt;/span&gt;/      &lt;span class="c"&gt;# AKS, Azure SQL, Key Vault
&lt;/span&gt;&lt;span class="n"&gt;infra&lt;/span&gt;-&lt;span class="n"&gt;gcp&lt;/span&gt;/        &lt;span class="c"&gt;# GKE, Cloud SQL, BigQuery
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This solves credential isolation and blast radius. But it introduces a new problem: cross-cloud dependencies. Your Azure DNS zone needs the IP address of an AWS load balancer. Your GCP logging sink needs the ARN of an AWS S3 bucket. These values have to flow between repos somehow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monorepo with directory-per-cloud
&lt;/h3&gt;

&lt;p&gt;Keep everything in one repo but separate by directory. Each directory has its own state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;infra&lt;/span&gt;/
  &lt;span class="n"&gt;aws&lt;/span&gt;/
    &lt;span class="n"&gt;networking&lt;/span&gt;/
    &lt;span class="n"&gt;compute&lt;/span&gt;/
  &lt;span class="n"&gt;azure&lt;/span&gt;/
    &lt;span class="n"&gt;dns&lt;/span&gt;/
    &lt;span class="n"&gt;identity&lt;/span&gt;/
  &lt;span class="n"&gt;gcp&lt;/span&gt;/
    &lt;span class="n"&gt;logging&lt;/span&gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better for code organisation, but the dependency problem remains. You still need to pass outputs from &lt;code&gt;aws/networking&lt;/code&gt; to &lt;code&gt;azure/dns&lt;/code&gt;, and nothing in Terraform's native tooling handles that.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;terraform_remote_state&lt;/code&gt; across clouds
&lt;/h3&gt;

&lt;p&gt;The built-in approach: each consuming state reads the producer's state directly.&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;data&lt;/span&gt; &lt;span class="s2"&gt;"terraform_remote_state"&lt;/span&gt; &lt;span class="s2"&gt;"aws_networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-terraform-state"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aws/networking/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-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;"azurerm_dns_a_record"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt;
  &lt;span class="nx"&gt;zone_name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_dns_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_dns_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;ttl&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
  &lt;span class="nx"&gt;records&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;terraform_remote_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;load_balancer_ip&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;This works but has the same drawbacks it always does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every consumer needs the backend configuration of every producer — including cross-cloud backend access (Azure state reading from an S3 bucket needs AWS credentials too).&lt;/li&gt;
&lt;li&gt;No automatic re-plan when the upstream state changes. You have to trigger it manually or via CI glue.&lt;/li&gt;
&lt;li&gt;The dependency graph lives in your head, not in code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Wrapper scripts and CI orchestration
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;terraform_remote_state&lt;/code&gt; gets too painful, teams write wrapper scripts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Apply AWS networking first&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;infra/aws/networking
terraform apply &lt;span class="nt"&gt;-auto-approve&lt;/span&gt;

&lt;span class="c"&gt;# Extract outputs&lt;/span&gt;
&lt;span class="nv"&gt;LB_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; load_balancer_ip&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Apply Azure DNS with the output&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../../azure/dns
terraform apply &lt;span class="nt"&gt;-auto-approve&lt;/span&gt; &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"load_balancer_ip=&lt;/span&gt;&lt;span class="nv"&gt;$LB_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or they build multi-step CI pipelines that chain applies in order, passing outputs via pipeline variables or artifacts. This is fragile — the dependency graph is encoded in CI config, not infrastructure code. Adding a new dependency means editing the pipeline, not just the Terraform.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Snap CD handles multi-cloud
&lt;/h2&gt;

&lt;p&gt;Snap CD was built for exactly this problem. Each cloud's infrastructure becomes one or more Snap CD &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Modules&lt;/a&gt;, each assigned to a &lt;a href="https://docs.snapcd.io/resources/runner/" rel="noopener noreferrer"&gt;Runner&lt;/a&gt; with the appropriate credentials. The dependency graph is declared in code via &lt;a href="https://docs.snapcd.io/resources/module-inputs/" rel="noopener noreferrer"&gt;Inputs&lt;/a&gt;, and Snap CD handles the orchestration.&lt;/p&gt;

&lt;h3&gt;
  
  
  One Runner per cloud
&lt;/h3&gt;

&lt;p&gt;Deploy a Runner in each cloud environment with only the credentials it needs. Each Runner is a separate process — one running in AWS with an IAM role, one in Azure with a managed identity, one in GCP with workload identity federation. See &lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; for the full deployment model.&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;data&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_runner"&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aws-runner"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_runner"&lt;/span&gt; &lt;span class="s2"&gt;"azure"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"azure-runner"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_runner"&lt;/span&gt; &lt;span class="s2"&gt;"gcp"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"gcp-runner"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each Runner only has access to its own cloud. A compromised AWS Runner can't touch Azure resources.&lt;/p&gt;

&lt;h3&gt;
  
  
  Modules per cloud component
&lt;/h3&gt;

&lt;p&gt;Each piece of infrastructure is a Module, assigned to the appropriate Runner:&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;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"aws_networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-aws-networking.git"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"azure_dns"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dns"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-azure-dns.git"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azure&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cross-cloud dependencies as code
&lt;/h3&gt;

&lt;p&gt;The load balancer IP from AWS flows into Azure DNS via a &lt;code&gt;snapcd_module_input_from_output&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="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"lb_ip_to_dns"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azure_dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"load_balancer_ip"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"load_balancer_ip"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this in place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Snap CD knows to apply &lt;code&gt;aws_networking&lt;/code&gt; before &lt;code&gt;azure_dns&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;When &lt;code&gt;aws_networking&lt;/code&gt; is applied and its &lt;code&gt;load_balancer_ip&lt;/code&gt; output changes, &lt;code&gt;azure_dns&lt;/code&gt; automatically re-plans and re-applies.&lt;/li&gt;
&lt;li&gt;No wrapper scripts. No CI orchestration. No cross-cloud &lt;code&gt;terraform_remote_state&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The AWS Runner never needs Azure credentials and vice versa.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A practical multi-cloud example
&lt;/h3&gt;

&lt;p&gt;A common pattern: compute in AWS, DNS and identity in Azure, logging in GCP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Namespace: aws
  Module: networking     (Runner: aws-runner)
  Module: compute        (Runner: aws-runner)

Namespace: azure
  Module: identity       (Runner: azure-runner)
  Module: dns            (Runner: azure-runner)

Namespace: gcp
  Module: logging        (Runner: gcp-runner)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dependencies:&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;# compute needs vpc_id from networking&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"vpc_to_compute"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# dns needs load_balancer_ip from compute&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"lb_to_dns"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"load_balancer_ip"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"load_balancer_ip"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# logging needs cluster_name from compute and subscription_id from identity&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"cluster_to_logging"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cluster_name"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cluster_name"&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;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"sub_to_logging"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"azure_subscription_id"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"subscription_id"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A commit to &lt;code&gt;infra-aws-networking&lt;/code&gt; triggers a cascade: networking re-applies, compute re-plans (because &lt;code&gt;vpc_id&lt;/code&gt; might have changed), DNS re-plans if &lt;code&gt;load_balancer_ip&lt;/code&gt; changed, and logging re-plans if &lt;code&gt;cluster_name&lt;/code&gt; changed. Each step runs on the Runner with the right credentials. No manual intervention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Single multi-cloud state&lt;/th&gt;
&lt;th&gt;Separate repos + CI glue&lt;/th&gt;
&lt;th&gt;Snap CD&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Credential isolation&lt;/td&gt;
&lt;td&gt;None — one set of creds for all clouds&lt;/td&gt;
&lt;td&gt;Per-repo/pipeline&lt;/td&gt;
&lt;td&gt;Per-Runner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blast radius&lt;/td&gt;
&lt;td&gt;All clouds&lt;/td&gt;
&lt;td&gt;Single cloud&lt;/td&gt;
&lt;td&gt;Single Module&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-cloud dependencies&lt;/td&gt;
&lt;td&gt;Direct references&lt;/td&gt;
&lt;td&gt;Scripts / CI variables&lt;/td&gt;
&lt;td&gt;Declarative wiring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automatic cascading&lt;/td&gt;
&lt;td&gt;N/A (single state)&lt;/td&gt;
&lt;td&gt;Manual triggers&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan speed&lt;/td&gt;
&lt;td&gt;Slowest provider wins&lt;/td&gt;
&lt;td&gt;Per-cloud&lt;/td&gt;
&lt;td&gt;Per-Module&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with one Runner per cloud.&lt;/strong&gt; You can split further later (e.g., separate Runners for &lt;code&gt;prod-aws&lt;/code&gt; and &lt;code&gt;dev-aws&lt;/code&gt;), but one per cloud is the natural starting point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep cross-cloud dependencies narrow.&lt;/strong&gt; A handful of outputs flowing between clouds (IPs, ARNs, resource IDs) is normal. If you're passing dozens, you might have a boundary in the wrong place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Namespaces&lt;/a&gt; to mirror your cloud structure.&lt;/strong&gt; &lt;code&gt;aws/networking&lt;/code&gt;, &lt;code&gt;azure/dns&lt;/code&gt;, &lt;code&gt;gcp/logging&lt;/code&gt; makes the dependency graph readable at a glance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't share state backends across clouds.&lt;/strong&gt; An S3 backend for AWS state and an Azure Storage Account for Azure state is fine — Snap CD manages the dependency graph, not the backends.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — how the Module and Input system works in detail&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — per-cloud Runner deployment patterns&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/event-driven-cd" rel="noopener noreferrer"&gt;Event-driven Continuous Deployment&lt;/a&gt; — how Source changes and Output changes cascade through the dependency graph&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/large-terraform-states" rel="noopener noreferrer"&gt;The Problem with Large Terraform States&lt;/a&gt; — why splitting by cloud provider reduces plan time and API throttling&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/splitting-terraform-monolith" rel="noopener noreferrer"&gt;Splitting a Terraform Monolith&lt;/a&gt; — how to break a multi-cloud monolith into per-cloud states&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>cloud</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Why Snap CD: Non-invasive Orchestration</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Sun, 05 Jul 2026 14:36:17 +0000</pubDate>
      <link>https://dev.to/karlschriek/why-snap-cd-non-invasive-orchestration-3i18</link>
      <guid>https://dev.to/karlschriek/why-snap-cd-non-invasive-orchestration-3i18</guid>
      <description>&lt;p&gt;Most infrastructure CD tools ask you to change the way you write Terraform. Some require a proprietary wrapper CLI. Others impose a specific directory layout, inject custom backends, or parse plans through a format only they understand. The trade-off is always the same: you get orchestration, but your code now only works inside that tool's ecosystem.&lt;/p&gt;

&lt;p&gt;Snap CD takes a different approach. It orchestrates deployments without modifying how Terraform runs. Your code stays portable, your commands stay standard, and nothing is hidden behind an abstraction you can't inspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lock-in pattern
&lt;/h2&gt;

&lt;p&gt;Infrastructure CD tools typically insert themselves between you and Terraform in one or more of these ways:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrapper CLIs.&lt;/strong&gt; Instead of &lt;code&gt;terraform plan&lt;/code&gt;, you run &lt;code&gt;toolname plan&lt;/code&gt; or &lt;code&gt;toolname run -- terraform plan&lt;/code&gt;. The wrapper intercepts the command, adds flags, manages state configuration, and sometimes alters the output. Your CI pipeline, your local workflow, and your debugging sessions all depend on the wrapper being present.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proprietary plan formats.&lt;/strong&gt; Some tools parse Terraform's plan output into their own internal representation for policy checks or approval workflows. When Terraform changes its plan format — which it does across major versions — you're waiting on the tool vendor to update their parser before you can upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Opinionated directory structures.&lt;/strong&gt; A tool might require your repo to follow a specific layout: one directory per environment, a configuration file at the root describing which directories map to which workspaces, naming conventions that the tool uses to infer relationships. Reorganise your repo and the tool breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom state backends.&lt;/strong&gt; Some tools manage Terraform state themselves, replacing the S3/GCS/Azure backend you'd normally configure. This can simplify initial setup, but it means your state is locked inside the tool. Migrating away requires state surgery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DSL layers.&lt;/strong&gt; A few tools go further: you write configuration in a tool-specific language or templating system that generates Terraform code. At that point, you're not really writing Terraform anymore — you're writing input to a code generator.&lt;/p&gt;

&lt;p&gt;Each of these creates a dependency. The more a tool wraps Terraform, the harder it is to leave, and the more your team needs to learn beyond Terraform itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What non-invasive means in practice
&lt;/h2&gt;

&lt;p&gt;When Snap CD deploys a &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Module&lt;/a&gt;, here's what actually happens on the &lt;a href="https://docs.snapcd.io/resources/runner/" rel="noopener noreferrer"&gt;Runner&lt;/a&gt;:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Clone the Source
&lt;/h3&gt;

&lt;p&gt;The Runner clones your Git repository (or downloads from a Terraform registry) into a local working directory. This is the same code you'd check out on your laptop.&lt;/p&gt;

&lt;p&gt;The working directory follows a predictable path: &lt;code&gt;~/.snapcd/runner/&amp;lt;stack&amp;gt;/&amp;lt;namespace&amp;gt;/&amp;lt;module&amp;gt;&lt;/code&gt;. If the Module specifies a subdirectory within the repo, the Runner navigates into it before running any commands.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Provide Inputs through standard mechanisms
&lt;/h3&gt;

&lt;p&gt;Snap CD writes the &lt;a href="https://docs.snapcd.io/resources/module-inputs/" rel="noopener noreferrer"&gt;Inputs&lt;/a&gt; your Module needs into a &lt;code&gt;.snapcd&lt;/code&gt; subdirectory using formats that Terraform already understands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inputs.tfvars&lt;/code&gt;&lt;/strong&gt; — Terraform variables (values wired from other Modules' outputs or from static configuration).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;snapcd.env&lt;/code&gt;&lt;/strong&gt; — environment variables your providers or scripts might need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shell scripts&lt;/strong&gt; (&lt;code&gt;init.sh&lt;/code&gt;, &lt;code&gt;plan.sh&lt;/code&gt;, &lt;code&gt;apply.sh&lt;/code&gt;, etc.) — wrap the Terraform commands with the correct flags and environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's nothing proprietary about these files. The &lt;code&gt;.tfvars&lt;/code&gt; file is a standard Terraform variable file. You can open it, read it, and pass it to &lt;code&gt;terraform apply -var-file=&lt;/code&gt; yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Run standard Terraform commands
&lt;/h3&gt;

&lt;p&gt;The Runner executes &lt;code&gt;terraform init&lt;/code&gt;, then &lt;code&gt;terraform plan&lt;/code&gt;, then (after approval) &lt;code&gt;terraform apply&lt;/code&gt;. These are the real Terraform binaries — not a wrapper, not a fork, not a shim. The Runner captures stdout and stderr and streams them back to the Snap CD &lt;a href="https://docs.snapcd.io/components/server/" rel="noopener noreferrer"&gt;Server&lt;/a&gt; for logging, but it doesn't intercept or alter the commands.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Collect Outputs
&lt;/h3&gt;

&lt;p&gt;After a successful apply, the Runner runs &lt;code&gt;terraform output -json&lt;/code&gt; and reports the results back to the Server. These Outputs become available as Inputs to dependent Modules. Standard Terraform, standard JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can always drop to the shell
&lt;/h2&gt;

&lt;p&gt;Because the Runner operates on a plain directory with real Terraform files, you can inspect and interact with it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# SSH into the Runner host&lt;/span&gt;
ssh runner-prod

&lt;span class="c"&gt;# Navigate to the Module's working directory&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/.snapcd/runner/&amp;lt;stack&amp;gt;/&amp;lt;namespace&amp;gt;/&amp;lt;module&amp;gt;

&lt;span class="c"&gt;# Look at what Snap CD prepared&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt;
&lt;span class="c"&gt;# main.tf&lt;/span&gt;
&lt;span class="c"&gt;# variables.tf&lt;/span&gt;
&lt;span class="c"&gt;# outputs.tf&lt;/span&gt;
&lt;span class="c"&gt;# .snapcd/&lt;/span&gt;
&lt;span class="c"&gt;#   inputs.tfvars    ← Inputs from Snap CD&lt;/span&gt;
&lt;span class="c"&gt;#   snapcd.env       ← environment variables&lt;/span&gt;
&lt;span class="c"&gt;#   init.sh          ← the init command Snap CD ran&lt;/span&gt;
&lt;span class="c"&gt;#   plan.sh          ← the plan command Snap CD ran&lt;/span&gt;
&lt;span class="c"&gt;#   apply.sh         ← the apply command Snap CD ran&lt;/span&gt;
&lt;span class="c"&gt;#   output.sh        ← the output command Snap CD ran&lt;/span&gt;

&lt;span class="c"&gt;# Run a plan yourself&lt;/span&gt;
terraform init
terraform plan &lt;span class="nt"&gt;-var-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.snapcd/inputs.tfvars
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is useful for debugging ("why is this plan showing a diff?"), for one-off operations (&lt;code&gt;terraform import&lt;/code&gt;, &lt;code&gt;terraform state mv&lt;/code&gt;), and for building confidence that nothing magical is happening behind the scenes. The &lt;code&gt;.snapcd&lt;/code&gt; directory contains a &lt;code&gt;.gitignore&lt;/code&gt; that excludes all its contents, so none of these generated files pollute your repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your code doesn't know about Snap CD
&lt;/h2&gt;

&lt;p&gt;A Terraform module managed by Snap CD is identical to one that isn't. There's no &lt;code&gt;snapcd {}&lt;/code&gt; block, no special provider, no required metadata annotation. You won't find a single line in your &lt;code&gt;.tf&lt;/code&gt; files that reveals which tool deploys them. If you searched a managed module for the string "snapcd", you'd get zero results.&lt;/p&gt;

&lt;p&gt;All of the orchestration configuration — which Runner deploys the Module, which Inputs to provide, which Outputs to wire to downstream consumers — lives in Snap CD itself, typically managed via the &lt;a href="https://docs.snapcd.io/terraform-provider/" rel="noopener noreferrer"&gt;Terraform Provider for Snap CD&lt;/a&gt;. Your infrastructure code stays portable: it works with Snap CD, without it, or with something else entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contrast with the alternatives
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Typical CD tool&lt;/th&gt;
&lt;th&gt;Snap CD&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How plans run&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;toolname plan&lt;/code&gt; or tool-managed wrapper&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;terraform plan&lt;/code&gt; (standard binary)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input delivery&lt;/td&gt;
&lt;td&gt;Tool-specific config files or API injection&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.tfvars&lt;/code&gt;, environment variables, shell scripts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State management&lt;/td&gt;
&lt;td&gt;Often tool-managed custom backend&lt;/td&gt;
&lt;td&gt;Your existing backend (S3, GCS, Azure, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Directory structure&lt;/td&gt;
&lt;td&gt;Must follow tool's conventions&lt;/td&gt;
&lt;td&gt;Any structure — Snap CD points at your repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Through the tool's UI/logs only&lt;/td&gt;
&lt;td&gt;SSH to Runner, inspect files, run commands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaving the tool&lt;/td&gt;
&lt;td&gt;State migration, code restructuring&lt;/td&gt;
&lt;td&gt;Change nothing — your code already works standalone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan format dependency&lt;/td&gt;
&lt;td&gt;Tool must parse each TF version's plan format&lt;/td&gt;
&lt;td&gt;No plan parsing — Snap CD reads Outputs, not plans&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When this matters
&lt;/h2&gt;

&lt;p&gt;The value of non-invasiveness shows up in specific moments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upgrading Terraform.&lt;/strong&gt; You upgrade from 1.5 to 1.9. With Snap CD, you update the binary on your Runner and you're done. There's no intermediary that needs to understand the new plan format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debugging a failed apply.&lt;/strong&gt; Instead of reading logs through a web UI and guessing, you SSH into the Runner, look at the actual files, and run the command yourself to reproduce the error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding a new team member.&lt;/strong&gt; They already know Terraform. They don't need to learn a wrapper CLI, a directory convention, or a configuration DSL. The Snap CD concepts — &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Modules, Stacks, Namespaces&lt;/a&gt; — are the orchestration layer; they don't change how Terraform itself works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluating alternatives.&lt;/strong&gt; If you decide Snap CD isn't the right fit, your Terraform code doesn't need to change. Your state files are where they've always been. You take your code and go.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/introducing-snapcd" rel="noopener noreferrer"&gt;Introducing Snap CD&lt;/a&gt; — full architecture overview including Runner execution model&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — how the Module, Namespace, and Stack hierarchy works in detail&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — how Runners execute standard Terraform without wrappers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/event-driven-cd" rel="noopener noreferrer"&gt;Event-driven Continuous Deployment&lt;/a&gt; — how Source watching and dependency cascading trigger deployments&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/supporting-toolset" rel="noopener noreferrer"&gt;An Extensive Supporting Toolset&lt;/a&gt; — the Terraform provider and broader tooling ecosystem&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>cloud</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Why Snap CD: An Extensive Supporting Toolset</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Sun, 05 Jul 2026 14:35:03 +0000</pubDate>
      <link>https://dev.to/karlschriek/why-snap-cd-an-extensive-supporting-toolset-13dg</link>
      <guid>https://dev.to/karlschriek/why-snap-cd-an-extensive-supporting-toolset-13dg</guid>
      <description>&lt;p&gt;Snap CD ships with a full supporting ecosystem — documentation, a Terraform provider, deployment references, a guided sample, and a migration tool. This article walks through each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation
&lt;/h2&gt;

&lt;p&gt;The documentation site at &lt;a href="https://docs.snapcd.io/" rel="noopener noreferrer"&gt;docs.snapcd.io&lt;/a&gt; covers three layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.snapcd.io/quickstart/" rel="noopener noreferrer"&gt;Quickstart guides&lt;/a&gt;&lt;/strong&gt; — step-by-step walkthroughs for both the &lt;a href="https://docs.snapcd.io/quickstart/cloud/" rel="noopener noreferrer"&gt;Cloud&lt;/a&gt; and &lt;a href="https://docs.snapcd.io/quickstart/self-hosted/" rel="noopener noreferrer"&gt;Self-Hosted&lt;/a&gt; editions. From zero to a working deployment in minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.snapcd.io/resources/" rel="noopener noreferrer"&gt;Resource reference&lt;/a&gt;&lt;/strong&gt; — detailed pages for every configurable resource: &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Stacks, Namespaces, Modules&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/runner/" rel="noopener noreferrer"&gt;Runners&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/module-inputs/" rel="noopener noreferrer"&gt;Module Inputs&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/secrets/" rel="noopener noreferrer"&gt;Secrets&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;Identity &amp;amp; Access Management&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/agent/" rel="noopener noreferrer"&gt;Agents&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/mission/" rel="noopener noreferrer"&gt;Missions&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/integration/" rel="noopener noreferrer"&gt;Integrations&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/hooks/" rel="noopener noreferrer"&gt;Hooks&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/flags/" rel="noopener noreferrer"&gt;Flags&lt;/a&gt;, and more. Each page explains what the resource is, how it relates to other resources, and how to configure it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.snapcd.io/components/" rel="noopener noreferrer"&gt;Component documentation&lt;/a&gt;&lt;/strong&gt; — architecture and operational details for the &lt;a href="https://docs.snapcd.io/components/server/" rel="noopener noreferrer"&gt;Server&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/components/runner/" rel="noopener noreferrer"&gt;Runner&lt;/a&gt;, and &lt;a href="https://docs.snapcd.io/components/agent/" rel="noopener noreferrer"&gt;Agent&lt;/a&gt; (including Sidecars). Covers deployment topology, configuration settings, and the execution model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Terraform Provider
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://registry.terraform.io/providers/schrieksoft/snapcd/latest/docs" rel="noopener noreferrer"&gt;Snap CD Terraform Provider&lt;/a&gt; lets you manage all Snap CD configuration as code. Stacks, Namespaces, Modules, Runners, Sources, Inputs, Role Assignments, Agents, Missions, Integrations — everything you can configure in the dashboard, you can express in HCL.&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;"snapcd_namespace"&lt;/span&gt; &lt;span class="s2"&gt;"platform"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"platform"&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/example/infra.git"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"compute"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"compute"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/example/infra.git"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is standard Terraform — you plan it, review it, apply it. Your Snap CD configuration lives in version control, goes through code review, and is reproducible across environments. You don't click through a UI to set up a new environment — you copy a Terraform module and change the variables.&lt;/p&gt;

&lt;h3&gt;
  
  
  The module-within-module pattern
&lt;/h3&gt;

&lt;p&gt;The provider enables a composition pattern: a Snap CD Module that deploys additional Snap CD Modules.&lt;/p&gt;

&lt;p&gt;Say you have a platform team that maintains base infrastructure. Application teams each need their own set of Modules that depend on platform Outputs. Rather than manually creating Modules for each team, you write a Terraform module that creates a Snap CD Namespace, creates the application's Modules within it, and wires the Inputs from platform Outputs:&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;"snapcd_namespace"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;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;app_name&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&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_id&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;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"database"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"database"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&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;database_source_url&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&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;runner_id&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;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"cluster_endpoint"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cluster_endpoint"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&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;platform_compute_module_id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cluster_endpoint"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy this through Snap CD itself and you have a self-service system: the platform team defines the pattern once, and new applications are onboarded by adding an entry to a configuration file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference Deployments
&lt;/h2&gt;

&lt;p&gt;Snap CD components ship as Docker images and as zipped binaries on &lt;a href="https://github.com/schrieksoft/snapcd/releases" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;. Three reference deployment repositories cover every common substrate, each containing a &lt;code&gt;components/&lt;/code&gt; directory with one self-contained sub-deployment per component (&lt;a href="https://docs.snapcd.io/components/server/" rel="noopener noreferrer"&gt;Server&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/components/runner/" rel="noopener noreferrer"&gt;Runner&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/components/agent/" rel="noopener noreferrer"&gt;Agent&lt;/a&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Substrate&lt;/th&gt;
&lt;th&gt;Repository&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Docker / Compose&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/schrieksoft/snapcd-deployment-docker" rel="noopener noreferrer"&gt;schrieksoft/snapcd-deployment-docker&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes (Kustomize)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/schrieksoft/snapcd-deployment-kubernetes" rel="noopener noreferrer"&gt;schrieksoft/snapcd-deployment-kubernetes&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local (native binaries)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/schrieksoft/snapcd-deployment-local" rel="noopener noreferrer"&gt;schrieksoft/snapcd-deployment-local&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can bring up all three components together, or just the one you need — a Runner pointed at the Cloud edition, an Agent attached to a remote Server, etc. The images are version-pinned, the environment variables are documented, and each repo's README walks through both shapes.&lt;/p&gt;

&lt;p&gt;These are the same deployment specifications used to run the Snap CD Cloud offering — not simplified demo versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sample Deployment
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/schrieksoft/snapcd/tree/main/samples/sample-deployment" rel="noopener noreferrer"&gt;sample-deployment&lt;/a&gt; repository is a guided walkthrough that creates a realistic set of Snap CD resources using the Terraform Provider. It deploys four Modules with mock resources (no real cloud infrastructure needed) arranged in a dependency graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;       |-----&amp;gt; cluster  ----- |
vpc ---|                      | ---&amp;gt; app
       |-----&amp;gt; database ----- |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sample is organized into numbered sections, each introducing a new resource type with inline commentary explaining the reasoning:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stack and Namespace&lt;/strong&gt; — &lt;code&gt;snapcd_namespace&lt;/code&gt;, &lt;code&gt;snapcd_namespace_input_from_literal&lt;/code&gt;, &lt;code&gt;snapcd_namespace_hook&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Module and literal Inputs&lt;/strong&gt; — &lt;code&gt;snapcd_module&lt;/code&gt;, &lt;code&gt;snapcd_module_input_from_literal&lt;/code&gt; (both &lt;code&gt;Param&lt;/code&gt; and &lt;code&gt;EnvVar&lt;/code&gt; kinds), &lt;code&gt;snapcd_module_hook&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output wiring&lt;/strong&gt; — &lt;code&gt;snapcd_module_input_from_output&lt;/code&gt; (single Output), approval thresholds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output Sets&lt;/strong&gt; — &lt;code&gt;snapcd_module_input_from_output_set&lt;/code&gt; (all Outputs by name match), &lt;code&gt;snapcd_module_terraform_flag&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets and non-string types&lt;/strong&gt; — &lt;code&gt;snapcd_module_input_from_secret&lt;/code&gt;, &lt;code&gt;type = "NotString"&lt;/code&gt; for numeric values&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents and Missions&lt;/strong&gt; — &lt;code&gt;snapcd_agent_namespace_supply&lt;/code&gt;, &lt;code&gt;snapcd_namespace_mission&lt;/code&gt; (&lt;code&gt;SummarizeJob&lt;/code&gt;, &lt;code&gt;AutoDiagnose&lt;/code&gt;, &lt;code&gt;ApprovalRecommend&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The goal is to be copied and adapted. After completing the &lt;a href="https://docs.snapcd.io/quickstart/self-hosted/" rel="noopener noreferrer"&gt;Self-Hosted Quickstart&lt;/a&gt;, you can &lt;code&gt;terraform apply&lt;/code&gt; the sample and have a working multi-Module environment with dependency wiring, approval gates, hooks, secrets, and AI Missions configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demonolith — monolith migration tool
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/schrieksoft/demonolith" rel="noopener noreferrer"&gt;Demonolith&lt;/a&gt; is a Go CLI that refactors a monolithic Terraform/OpenTofu root into independent per-module roots — the first step toward managing them with Snap CD.&lt;/p&gt;

&lt;p&gt;The problem it solves: a single-root monolith gets slow, risky, and coupled. A one-line change re-plans everything, and one broken resource can block unrelated ones. Splitting it by hand is error-prone — you need to move resources, carve state, create &lt;code&gt;variable&lt;/code&gt;/&lt;code&gt;output&lt;/code&gt; boundaries at every cross-module reference, and verify that nothing is inadvertently recreated.&lt;/p&gt;

&lt;p&gt;Demonolith automates all of this. You annotate your resources with decorator comments indicating which Module each belongs to:&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;# @demono:move networking&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;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# @demono:move compute&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_instance"&lt;/span&gt; &lt;span class="s2"&gt;"web"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_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;public&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Emit carved roots (code only, offline):&lt;/span&gt;
demonolith &lt;span class="nb"&gt;split&lt;/span&gt; ./infra

&lt;span class="c"&gt;# Also carve state into per-module local files:&lt;/span&gt;
demonolith &lt;span class="nb"&gt;split&lt;/span&gt; ./infra &lt;span class="nt"&gt;--state&lt;/span&gt;

&lt;span class="c"&gt;# Carve + prove every module plans to zero create/destroy:&lt;/span&gt;
demonolith &lt;span class="nb"&gt;split&lt;/span&gt; ./infra &lt;span class="nt"&gt;--state&lt;/span&gt; &lt;span class="nt"&gt;--proof&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parse&lt;/strong&gt; — builds a resource-level reference graph via AST traversal (not regex — it catches refs inside &lt;code&gt;templatefile()&lt;/code&gt;, &lt;code&gt;jsonencode()&lt;/code&gt;, and index expressions).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Place&lt;/strong&gt; — resolves decorators into a total assignment. Undecorated resources fall to a configurable remainder module.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boundary&lt;/strong&gt; — references crossing module boundaries become &lt;code&gt;variable&lt;/code&gt;/&lt;code&gt;output&lt;/code&gt; pairs. &lt;code&gt;depends_on&lt;/code&gt;-only edges become ordering dependencies (no spurious value wiring).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cycle gate&lt;/strong&gt; — refuses impossible splits with a named cycle path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emit&lt;/strong&gt; — writes per-module roots via &lt;code&gt;hclwrite&lt;/code&gt; (formatting preserved), rewrites cross-module references to &lt;code&gt;var.&amp;lt;input&amp;gt;&lt;/code&gt;, propagates providers and locals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State carve&lt;/strong&gt; — &lt;code&gt;terraform state mv&lt;/code&gt; over local copies. Never touches the real backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proof&lt;/strong&gt; — walks modules in topological order, threads each producer's extracted outputs into its consumers' inputs (the role Snap CD plays at runtime), and plans each against its carved state. Zero creates and zero destroys = the split is operationally inert.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The carved roots are plain Terraform — valid standalone, with the cross-module edges being exactly the wiring you'd configure in Snap CD via &lt;code&gt;snapcd_module_input_from_output&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/introducing-snapcd" rel="noopener noreferrer"&gt;Introducing Snap CD&lt;/a&gt; — full architecture overview&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — how the Module, Namespace, and Stack hierarchy works&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — deploying and configuring Runners&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/non-invasive-orchestration" rel="noopener noreferrer"&gt;Non-invasive Orchestration&lt;/a&gt; — how Snap CD runs standard Terraform without wrappers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/event-driven-cd" rel="noopener noreferrer"&gt;Event-driven Continuous Deployment&lt;/a&gt; — how Source watching and dependency cascading trigger deployments&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>terraform</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Why Snap CD: AI on a Leash</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Sun, 05 Jul 2026 13:58:23 +0000</pubDate>
      <link>https://dev.to/karlschriek/why-snap-cd-ai-on-a-leash-396k</link>
      <guid>https://dev.to/karlschriek/why-snap-cd-ai-on-a-leash-396k</guid>
      <description>&lt;p&gt;AI coding agents are showing up in infrastructure workflows. They can diagnose a failed &lt;code&gt;terraform apply&lt;/code&gt;, summarise what changed across a dozen modules overnight, draft a fix for a misconfigured security group, and recommend whether a plan is safe to approve. The potential to eliminate toil is real.&lt;/p&gt;

&lt;p&gt;But so is the potential to break things. A bad &lt;code&gt;terraform apply&lt;/code&gt; can delete a production database. An agent that auto-approves plans without understanding blast radius is not a productivity tool — it's a liability. The question isn't whether to use AI in infrastructure management. It's how to use it without handing over the keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with unrestricted agents
&lt;/h2&gt;

&lt;p&gt;Most AI agent frameworks assume broad access. Give the agent credentials, point it at your infrastructure, and let it figure things out. This works fine for generating code in a branch. It's a terrible model for infrastructure, where the gap between "run this command" and "destroy this resource" is one flag.&lt;/p&gt;

&lt;p&gt;The usual mitigations are crude:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-only API keys.&lt;/strong&gt; The agent can observe but not act. You get diagnostics but no automation — the human still has to do everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrapper scripts with allow-lists.&lt;/strong&gt; You write a shell script that only permits certain Terraform commands. Fragile, hard to maintain, and easy to outgrow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate CI pipelines.&lt;/strong&gt; The agent commits to a branch, CI runs the plan, a human reviews. This works but adds latency and doesn't let the agent participate in approval or deployment at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these give you a spectrum of trust. It's all-or-nothing: either the agent can do everything, or it's limited to generating text that a human has to act on manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually want
&lt;/h2&gt;

&lt;p&gt;A useful model looks more like how you'd onboard a new team member:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start them with &lt;strong&gt;read access&lt;/strong&gt; so they can learn the system and diagnose issues.&lt;/li&gt;
&lt;li&gt;Give them &lt;strong&gt;deploy access to test&lt;/strong&gt; so they can move fast without risk.&lt;/li&gt;
&lt;li&gt;Let them &lt;strong&gt;approve low-risk changes&lt;/strong&gt; in staging once they've proven reliable.&lt;/li&gt;
&lt;li&gt;Grant &lt;strong&gt;production access&lt;/strong&gt; only when trust is established — and even then, scoped to the systems they own.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same progression makes sense for an AI agent. The challenge is finding a system that supports this without building a separate authorization layer just for AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Snap CD handles this
&lt;/h2&gt;

&lt;p&gt;Snap CD has first-class support for AI agents — but rather than inventing a parallel permission system, it treats an Agent as a principal governed by the same RBAC that controls human access. The result is two complementary layers: a &lt;strong&gt;&lt;a href="https://docs.snapcd.io/resources/mission/" rel="noopener noreferrer"&gt;Missions framework&lt;/a&gt;&lt;/strong&gt; that gives agents a narrow, event-driven interface to the deployment lifecycle, and a &lt;strong&gt;&lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;Permission System&lt;/a&gt;&lt;/strong&gt; that controls what those agents (or any other AI) can actually do.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Agent component and Missions
&lt;/h3&gt;

&lt;p&gt;Snap CD's &lt;a href="https://docs.snapcd.io/components/agent/" rel="noopener noreferrer"&gt;Agent&lt;/a&gt; is a self-hosted process that consumes deployment events and runs AI-driven &lt;a href="https://docs.snapcd.io/resources/mission/" rel="noopener noreferrer"&gt;Missions&lt;/a&gt;. Rather than giving an AI broad access and hoping it does the right thing, Missions provide a narrow frame — each Mission type is bound to a specific trigger:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mission&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AutoDiagnose&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Job fails&lt;/td&gt;
&lt;td&gt;Posts a root-cause hypothesis with relevant log excerpts and suggested next steps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AutoFix&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Job fails&lt;/td&gt;
&lt;td&gt;Attempts an automated fix based on the diagnosis, then retries the Job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ApprovalRecommend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Job reaches approval-required state&lt;/td&gt;
&lt;td&gt;Analyzes the plan output and recommends whether to approve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SummarizeJob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Job succeeds&lt;/td&gt;
&lt;td&gt;Generates a human-readable summary of what changed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You create an Agent, assign it a &lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;Service Principal&lt;/a&gt; (which determines what it can do via RBAC), and supply it to the scopes it should serve — the same supply model used for &lt;a href="https://docs.snapcd.io/resources/runner/" rel="noopener noreferrer"&gt;Runners&lt;/a&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="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_agent"&lt;/span&gt; &lt;span class="s2"&gt;"ai"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ai-agent"&lt;/span&gt;
  &lt;span class="nx"&gt;service_principal_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_service_principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;is_supplied_to_all_modules&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;"snapcd_agent_stack_supply"&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;agent_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_stack_mission"&lt;/span&gt; &lt;span class="s2"&gt;"diagnose_test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;agent_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;mission_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AutoDiagnose"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sets up an Agent that auto-diagnoses any failed Job in the test Stack. Without a supply covering prod, the Agent won't receive Missions there — even if someone accidentally creates a prod-scoped Mission for it. You can scope Missions down to individual Namespaces or Modules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scoped role assignments
&lt;/h3&gt;

&lt;p&gt;The Agent's Service Principal still needs the appropriate RBAC role to perform its actions. You grant roles at whatever granularity makes sense — scoped to a &lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;Stack, Namespace, or Module&lt;/a&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;# The agent can read everything in prod — diagnose issues, view plans, inspect state&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_stack_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"agent_prod_reader"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_service_principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ServicePrincipal"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Reader"&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&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;# The agent can deploy freely in test — run plans, apply&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_stack_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"agent_test_contributor"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_service_principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ServicePrincipal"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Contributor"&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&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;# The agent can manage jobs in staging, but only for the networking namespace&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_namespace_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"agent_staging_jobs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_service_principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ServicePrincipal"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"JobManager"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;staging_networking&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the agent tries to approve a production deploy, it gets a permission denied — same as any user without the right role on that scope. No special-case logic, no wrapper scripts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approval gates as natural checkpoints
&lt;/h3&gt;

&lt;p&gt;Snap CD's approval system works the same regardless of who (or what) created the plan. A Module can require a minimum number of approvals before an apply proceeds. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An agent can trigger a plan and recommend approval (via the &lt;code&gt;ApprovalRecommend&lt;/code&gt; Mission).&lt;/li&gt;
&lt;li&gt;A human reviews the plan output and approves or rejects.&lt;/li&gt;
&lt;li&gt;The apply only proceeds once the required approval count is met.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also set up a workflow where the agent itself is one of multiple required approvers. Two humans and one agent, or two agents and one human — whatever quorum makes sense for the risk level. The approval system doesn't care whether the approver is biological.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full audit trail
&lt;/h3&gt;

&lt;p&gt;Every action an agent takes — triggering a plan, approving a deployment, reading state — is logged and attributed to its Service Principal. When the Service Principal is attached to an Agent resource, Snap CD stamps an &lt;code&gt;agent_id&lt;/code&gt; claim on the token, so the audit log distinguishes between "service principal X acting as agent Y" and "service principal X acting as a plain service account." You can answer "what did the agent do last Tuesday?" the same way you'd answer it for any user: check the audit log.&lt;/p&gt;

&lt;h3&gt;
  
  
  Integrations: pushing events to external systems
&lt;/h3&gt;

&lt;p&gt;Missions and permissions govern what an agent can do. &lt;a href="https://docs.snapcd.io/resources/integration/" rel="noopener noreferrer"&gt;Integrations&lt;/a&gt; govern what your team sees. An Integration connects Snap CD to an external system — Slack is the first supported sink — and delivers notifications for deployment lifecycle events and Mission milestones.&lt;/p&gt;

&lt;p&gt;Like Agents and Runners, Integrations use a supply model: you supply the Integration to the scopes it should serve, then subscribe specific &lt;a href="https://docs.snapcd.io/resources/integration-event/" rel="noopener noreferrer"&gt;Integration Events&lt;/a&gt; (triggers) at those scopes. A notification is delivered only when both the supply and the subscription exist.&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;data&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_integration"&lt;/span&gt; &lt;span class="s2"&gt;"alerts"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"alerts"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# The integration serves every module in the production stack&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_integration_stack_supply"&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;integration_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;production&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;# Notify Slack when any job in the production stack fails&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_stack_integration_event"&lt;/span&gt; &lt;span class="s2"&gt;"failed"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;production&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;integration_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;trigger&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"JobFailed"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Notify Slack when a Mission reports a milestone (diagnosis, fix attempt, etc.)&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_stack_integration_event"&lt;/span&gt; &lt;span class="s2"&gt;"milestone"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;production&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;integration_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;trigger&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"MissionMilestoneReported"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Available triggers include &lt;code&gt;JobSucceeded&lt;/code&gt;, &lt;code&gt;JobFailed&lt;/code&gt;, &lt;code&gt;JobAwaitingApproval&lt;/code&gt;, &lt;code&gt;JobCancelled&lt;/code&gt;, and &lt;code&gt;MissionMilestoneReported&lt;/code&gt;. You can scope subscriptions at the Organization, Stack, Namespace, or Module level and use optional message templates with tokens like &lt;code&gt;{{moduleName}}&lt;/code&gt;, &lt;code&gt;{{jobUrl}}&lt;/code&gt;, &lt;code&gt;{{missionType}}&lt;/code&gt;, and &lt;code&gt;{{message}}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For Mission milestones, Snap CD threads all updates from a single Mission run under one Slack message. This means a multi-step &lt;code&gt;AutoFix&lt;/code&gt; run — diagnosis, attempted fix, retry — shows up as a single threaded conversation rather than a spray of unrelated messages.&lt;/p&gt;

&lt;h3&gt;
  
  
  End-to-end: AutoFix in action
&lt;/h3&gt;

&lt;p&gt;Here's what happens when a deployment fails and &lt;code&gt;AutoFix&lt;/code&gt; is configured. The setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;test Stack&lt;/strong&gt; with an &lt;code&gt;AutoFix&lt;/code&gt; Mission configured for the Agent&lt;/li&gt;
&lt;li&gt;The Agent's Service Principal has &lt;code&gt;Contributor&lt;/code&gt; on the test Stack&lt;/li&gt;
&lt;li&gt;A Slack &lt;a href="https://docs.snapcd.io/resources/integration/" rel="noopener noreferrer"&gt;Integration&lt;/a&gt; is supplied to the Stack with &lt;code&gt;JobFailed&lt;/code&gt;, &lt;code&gt;JobSucceeded&lt;/code&gt; and &lt;code&gt;MissionMilestoneReported&lt;/code&gt; triggers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Someone pushes a commit that introduces a typo in a Terraform variable name. Snap CD detects the source change and triggers a plan-and-apply Job on the affected Module. The apply fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Slack: Job failure notification&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;JobFailed&lt;/code&gt; Integration Event fires. Slack receives a message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;❌ Apply failed on &lt;strong&gt;vpc&lt;/strong&gt; (test/networking)&lt;br&gt;
&lt;a href="https://mydomain.com/jobs/abc-123" rel="noopener noreferrer"&gt;https://mydomain.com/jobs/abc-123&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;2. AutoFix Mission dispatched&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Server dispatches an &lt;code&gt;AutoFix&lt;/code&gt; Mission to the Agent. The Agent routes it to its Sidecar (e.g. the &lt;code&gt;claude-sidecar&lt;/code&gt;), which works through a structured sequence: read the job logs via Snap CD's MCP server, diagnose the root cause, clone the source repo, make the minimal fix, and open a pull request. The Sidecar never pushes to the default branch directly — it always creates a fix branch and opens a PR. As it works through each step, it emits milestone events that stream back through the Agent to the Server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slack: AutoFix milestones (threaded under the failure message)&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔧 &lt;strong&gt;AutoFix&lt;/strong&gt; — Job on vpc failed — investigating.&lt;/p&gt;

&lt;p&gt;🔧 &lt;strong&gt;AutoFix&lt;/strong&gt; — Root cause: variable &lt;code&gt;vnet_cidr_block&lt;/code&gt; referenced in &lt;code&gt;main.tf:42&lt;/code&gt; does not exist. The variable was renamed to &lt;code&gt;vnet_address_space&lt;/code&gt; in the latest commit but the reference was not updated. Fixing.&lt;/p&gt;

&lt;p&gt;🔧 &lt;strong&gt;AutoFix&lt;/strong&gt; — Opened PR: &lt;a href="https://github.com/example/vpc-module/pull/47" rel="noopener noreferrer"&gt;https://github.com/example/vpc-module/pull/47&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;3. Human merges the PR&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A human reviews the PR, sees the one-line fix, and merges it. Snap CD detects the source change on the tracked branch and automatically triggers a new plan-and-apply Job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Retry succeeds&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The new Job runs. The apply succeeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slack: success notification&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ Apply succeeded on &lt;strong&gt;vpc&lt;/strong&gt; (test/networking)&lt;br&gt;
&lt;a href="https://mydomain.com/jobs/def-456" rel="noopener noreferrer"&gt;https://mydomain.com/jobs/def-456&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The entire sequence — failure, diagnosis, fix PR, human merge, retry, success — plays out across one Slack thread and a GitHub PR. A human glancing at the channel sees the original failure, the agent's reasoning, and where to review the fix. Every action is attributed to the Agent's Service Principal in the audit log.&lt;/p&gt;

&lt;p&gt;If the failure had been transient (a provider rate limit or timeout), the AutoFix Mission would have simply re-triggered the Job — no code change, no PR. And if the root cause wasn't something the agent could safely fix in-repo (expired credentials, state drift, a defect in a referenced module), it would degrade to a diagnosis with a recommended manual action.&lt;/p&gt;

&lt;p&gt;Now contrast this with the same Agent on the &lt;strong&gt;prod Stack&lt;/strong&gt;, where it is only configured with the  &lt;code&gt;AutoDiagnose&lt;/code&gt; mission (not &lt;code&gt;AutoFix&lt;/code&gt;). The same failure would produce a diagnosis but stop there — no fix attempt, no PR, no retry. The agent reports what went wrong and a human takes it from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bring your own AI
&lt;/h2&gt;

&lt;p&gt;The Missions framework is the canonical way to let AI participate in your deployment lifecycle. But Snap CD doesn't force you into it. If you prefer to use your own AI agent — or a different orchestration framework — you can have it interact with Snap CD's REST API directly using a plain Service Principal with &lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;Role Assignments&lt;/a&gt;. The same RBAC governs what the API caller can do, regardless of whether it's a human, a CI bot, or an LLM.&lt;/p&gt;

&lt;p&gt;There are two authentication approaches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Plain Service Principal&lt;/strong&gt; — register a Service Principal, assign it the appropriate roles, and have your agent authenticate with the Client ID / Client Secret pair via the standard OAuth token endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&amp;amp;client_id=&amp;lt;org_id&amp;gt;:&amp;lt;client_id&amp;gt;&amp;amp;client_secret=&amp;lt;client_secret&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The returned bearer token is then attached to subsequent API requests as an &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header, letting your agent call any endpoint its roles permit — trigger plans, read job logs, post approvals, etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Service Principal with Agent identity&lt;/strong&gt; — if you want Snap CD to recognize that API calls are coming from a specific Agent (for audit trail purposes), attach the Service Principal to an Agent resource and pass the &lt;code&gt;agent_id&lt;/code&gt; parameter when requesting the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&amp;amp;client_id=&amp;lt;org_id&amp;gt;:&amp;lt;client_id&amp;gt;&amp;amp;client_secret=&amp;lt;client_secret&amp;gt;&amp;amp;agent_id=&amp;lt;agent_guid&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The returned token carries an &lt;code&gt;agent_id&lt;/code&gt; claim. Snap CD uses this to attribute API calls to the named Agent in the audit log, making it clear which actions were taken by AI versus humans or other service accounts.&lt;/p&gt;

&lt;p&gt;A reasonable pattern: give the agent broad permissions in test, narrow permissions in prod.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test/          → Contributor (full deploy access)
staging/       → JobManager (can manage jobs, scoped to specific namespaces)
prod/          → Reader (observe only)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In test, the agent can deploy freely — run plans, approve them, apply them. It iterates fast, catches issues early, and doesn't need human intervention for routine changes. In staging, it participates in the approval process but can't act unilaterally on sensitive namespaces. In prod, it can diagnose and report but never modify.&lt;/p&gt;

&lt;p&gt;This isn't a rigid hierarchy. You can adjust per Namespace or per Module. Maybe the agent gets &lt;code&gt;Contributor&lt;/code&gt; on &lt;code&gt;prod/monitoring&lt;/code&gt; because deploying a new dashboard is low-risk, while &lt;code&gt;prod/database&lt;/code&gt; stays human-only. The permission system is granular enough to express whatever trust model you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/introducing-snapcd" rel="noopener noreferrer"&gt;Introducing Snap CD&lt;/a&gt; — full architecture overview including Agent setup and both authentication approaches&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/permission-system" rel="noopener noreferrer"&gt;A Permission System Built for Infrastructure&lt;/a&gt; — the RBAC model that governs both human and agent access&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — scoping cloud credentials independently of Snap CD permissions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — how Modules, Namespaces, and Stacks provide the scoping hierarchy&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/event-driven-cd" rel="noopener noreferrer"&gt;Event-driven Continuous Deployment&lt;/a&gt; — how cascading changes and approval gates work together&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>cicd</category>
      <category>terraform</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Why Snap CD: A Permission System Built for Infrastructure</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Fri, 03 Jul 2026 07:12:48 +0000</pubDate>
      <link>https://dev.to/karlschriek/why-snap-cd-a-permission-system-built-for-infrastructure-1602</link>
      <guid>https://dev.to/karlschriek/why-snap-cd-a-permission-system-built-for-infrastructure-1602</guid>
      <description>&lt;p&gt;Most infrastructure teams handle access control in one of two places: the CI/CD layer or the cloud provider's IAM layer. Neither maps well to how infrastructure is actually structured.&lt;/p&gt;

&lt;p&gt;CI permissions are usually binary — you can trigger a pipeline or you can't. There's no concept of "this person can deploy networking but not databases." Cloud IAM is more granular, but it governs what &lt;em&gt;credentials&lt;/em&gt; can do, not what &lt;em&gt;people&lt;/em&gt; can do within your deployment workflow. You end up with a gap: the system that understands your infrastructure topology has no permission model, and the system that has a permission model doesn't understand your infrastructure topology.&lt;/p&gt;

&lt;p&gt;Snap CD sits in that gap. It provides a hierarchical &lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;role-based access control&lt;/a&gt; system that maps directly to the way you organise your infrastructure — &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Stacks, Namespaces, and Modules&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/runner/" rel="noopener noreferrer"&gt;Runners&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/agent/" rel="noopener noreferrer"&gt;Agents&lt;/a&gt;, and &lt;a href="https://docs.snapcd.io/resources/integration/" rel="noopener noreferrer"&gt;Integrations&lt;/a&gt; — and enforces it uniformly whether actions come through the web dashboard, the API, or the &lt;a href="https://registry.terraform.io/providers/schrieksoft/snapcd/latest/docs" rel="noopener noreferrer"&gt;Terraform Provider&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two common approaches and where they break down
&lt;/h2&gt;

&lt;h3&gt;
  
  
  CI/CD gating
&lt;/h3&gt;

&lt;p&gt;The simplest form of infrastructure access control: who can trigger the pipeline?&lt;/p&gt;

&lt;p&gt;Most CI systems give you repository-level permissions. If you have write access to the repo, you can trigger the workflow. Some offer environment-level protection rules — require approval from a specific team before deploying to &lt;code&gt;prod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This works until your infrastructure spans multiple repositories, or until you need more granularity than "can deploy to this environment." Can this person create new Modules but not delete existing ones? Can they approve a plan but not trigger an apply? CI systems don't model these distinctions.&lt;/p&gt;

&lt;p&gt;There's also the backdoor problem. Protection rules only apply to CI-triggered runs. Anyone with the right credentials can run &lt;code&gt;terraform apply&lt;/code&gt; from their laptop and bypass every gate you've set up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud IAM
&lt;/h3&gt;

&lt;p&gt;Cloud providers have sophisticated permission systems — Azure RBAC, AWS IAM, GCP IAM. These control what API calls a principal can make against cloud resources. But they operate at the wrong abstraction level for deployment workflows.&lt;/p&gt;

&lt;p&gt;Cloud IAM doesn't know that your VPC, subnets, and route tables form a logical "networking" group that one team owns. It doesn't know that &lt;code&gt;module-compute&lt;/code&gt; depends on &lt;code&gt;module-networking&lt;/code&gt; and should only be deployable after networking is stable. It can tell you whether a service principal can create an EC2 instance, but it can't tell you whether a human should be allowed to approve the plan that creates it.&lt;/p&gt;

&lt;p&gt;You end up encoding deployment permissions across multiple systems — repo access in GitHub, environment protection rules in Actions, IAM policies in AWS — with no single place to answer "who can do what to which part of my infrastructure?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Snap CD's permission model
&lt;/h2&gt;

&lt;p&gt;Snap CD's permission system is built around two ideas: roles describe what you can do, and scope determines where you can do it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Principals
&lt;/h3&gt;

&lt;p&gt;Three types of identity can hold role assignments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Users&lt;/strong&gt; — human operators, authenticated via the identity provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service principals&lt;/strong&gt; — machine identities for automation, CI pipelines, and API integrations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groups&lt;/strong&gt; — collections of users or service principals, for managing permissions at team scale.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Roles
&lt;/h3&gt;

&lt;p&gt;Roles define a set of allowed operations. The same role names appear across different scope levels, with context-appropriate permissions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Owner&lt;/strong&gt; — full control, including the ability to delete the resource and manage role assignments on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor&lt;/strong&gt; — create, update, and manage child resources, but cannot delete the resource itself or manage role assignments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reader&lt;/strong&gt; — read-only access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IdentityAccessManager&lt;/strong&gt; — can manage role assignments on this resource without having full Owner control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Additional roles exist at specific scope levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;StackCreator&lt;/strong&gt; (organization) — can create new Stacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NamespaceCreator&lt;/strong&gt; (Stack) — can create new Namespaces within the Stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ModuleCreator&lt;/strong&gt; (Namespace) — can create new Modules within the Namespace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approver&lt;/strong&gt; — can approve deployment plans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JobManager&lt;/strong&gt; — can manage deployment jobs (cancel, retry).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SourceChangeNotifier&lt;/strong&gt; — can notify the system of source changes (used by webhooks and CI integrations).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Scope hierarchy
&lt;/h3&gt;

&lt;p&gt;Role assignments are scoped to a specific level in the hierarchy. Permissions granted at a higher level flow down to all children:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Organization
  └── Stack (e.g. "prod", "test")
        └── Namespace (e.g. "prod/networking", "prod/application")
              └── Module (e.g. "prod/networking/vpc")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Runners, Agents, and Integrations sit outside this hierarchy — they each have their own scope. A Runner's Owner controls which Modules are allowed to execute on it. An Agent's Owner controls which scopes it can serve &lt;a href="https://docs.snapcd.io/resources/mission/" rel="noopener noreferrer"&gt;Missions&lt;/a&gt; in.&lt;/p&gt;

&lt;p&gt;A role assigned at the Organization level applies everywhere. A role assigned at a specific Module applies only to that Module. This means you can express both broad policies ("the platform team is Reader on the entire organization") and narrow exceptions ("except they're Owner on the networking Namespace").&lt;/p&gt;

&lt;h2&gt;
  
  
  Concrete examples
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Platform team owns networking, reads everything else
&lt;/h3&gt;

&lt;p&gt;The platform team manages all networking infrastructure but should only observe application deployments:&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;"snapcd_stack_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"platform_reader"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;production&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform_team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Group"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Reader"&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;"snapcd_namespace_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"platform_owns_networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform_team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Group"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Owner"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The platform team gets Reader at the Stack level (they can see everything in production) and Owner on the networking Namespace (they can deploy, approve, and manage Modules within it). They cannot modify or deploy anything in other Namespaces.&lt;/p&gt;

&lt;h3&gt;
  
  
  Junior engineer approves test but not prod
&lt;/h3&gt;

&lt;p&gt;A junior team member should be able to approve deployment plans in the test environment but only observe production:&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;"snapcd_stack_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"junior_test_contributor"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;junior_engineer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"User"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Contributor"&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;"snapcd_stack_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"junior_prod_reader"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;junior_engineer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"User"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Reader"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They can trigger plans, approve, and deploy in test. In prod, they can see what's happening but can't change anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  CI Service Principal scoped to a single Module
&lt;/h3&gt;

&lt;p&gt;An automated deployment pipeline that should only be able to deploy one specific Module:&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;"snapcd_module_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"ci_deploys_api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_service_principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ci_pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ServicePrincipal"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Contributor"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service principal can trigger plans and applies on the API gateway Module, but has no access to anything else in the organization. If the pipeline is compromised, the blast radius is limited to a single Module.&lt;/p&gt;

&lt;h3&gt;
  
  
  Runner access control
&lt;/h3&gt;

&lt;p&gt;Controlling which Modules can execute on which Runners is a security boundary — a Runner deployed in your production Azure subscription should only execute production Modules. This is handled by &lt;a href="https://docs.snapcd.io/resources/runner/#allowing-a-module-to-use-a-runner" rel="noopener noreferrer"&gt;Runner Supply&lt;/a&gt;, not by role assignments. A Runner Supply declares that a Runner is available to a Stack, Namespace, or individual Module:&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;"snapcd_runner_stack_supply"&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azure_prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;production&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Module in the &lt;code&gt;production&lt;/code&gt; Stack can execute on &lt;code&gt;azure_prod&lt;/code&gt;. Modules in other Stacks cannot, regardless of what credentials exist elsewhere. Without a matching Supply, a Module will not execute.&lt;/p&gt;

&lt;p&gt;Runner role assignments (&lt;code&gt;snapcd_runner_role_assignment&lt;/code&gt;) serve a different purpose — they control what a principal can do &lt;em&gt;to&lt;/em&gt; the Runner itself (manage, view, etc.), not which Modules execute on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  AI Agent access control
&lt;/h3&gt;

&lt;p&gt;Agents follow the same supply-and-RBAC model as Runners. An Agent is backed by a &lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;Service Principal&lt;/a&gt; — its permissions are whatever roles that Service Principal holds. You supply the Agent to specific scopes, and declare which &lt;a href="https://docs.snapcd.io/resources/mission/" rel="noopener noreferrer"&gt;Missions&lt;/a&gt; it can run at each scope:&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;"snapcd_agent"&lt;/span&gt; &lt;span class="s2"&gt;"ai"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ai-agent"&lt;/span&gt;
  &lt;span class="nx"&gt;service_principal_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_service_principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;is_supplied_to_all_modules&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;"snapcd_agent_stack_supply"&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;agent_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_stack_mission"&lt;/span&gt; &lt;span class="s2"&gt;"diagnose_test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;agent_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;mission_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AutoDiagnose"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This Agent can auto-diagnose failed Jobs in the test Stack. Without a supply covering prod, it won't receive Missions there — even if someone accidentally creates a prod-scoped Mission for it. The Agent's Service Principal still needs the appropriate RBAC role to perform the action (e.g. &lt;code&gt;Contributor&lt;/code&gt; to attempt an auto-fix, &lt;code&gt;Reader&lt;/code&gt; to diagnose). Every action is logged and attributed to the Agent's Service Principal, giving you the same audit trail as human operators.&lt;/p&gt;

&lt;h2&gt;
  
  
  No backdoors
&lt;/h2&gt;

&lt;p&gt;A common failure mode with CI-based access control is that the gates only apply to one path. Someone with the right cloud credentials can bypass CI entirely and run &lt;code&gt;terraform apply&lt;/code&gt; from their laptop.&lt;/p&gt;

&lt;p&gt;Snap CD's permission model applies to every interaction path. Whether you click "Approve" in the web dashboard, call the REST API from a script, or manage resources through the Terraform Provider, the same role assignments are evaluated. There is no unenforced path.&lt;/p&gt;

&lt;p&gt;This also means your access control configuration is auditable in one place. Instead of piecing together GitHub team permissions, CI environment protection rules, and cloud IAM policies to understand who can deploy what, you query Snap CD's role assignments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managing permissions as code
&lt;/h2&gt;

&lt;p&gt;Because every role assignment is a Terraform resource, your permission model lives in version control alongside the rest of your infrastructure configuration. Changes go through the same review process as any other infrastructure change — pull request, review, approve, apply.&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;"snapcd_stack"&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt;
  &lt;span class="nx"&gt;organization_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_organization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_namespace"&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_namespace"&lt;/span&gt; &lt;span class="s2"&gt;"application"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"application"&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_stack_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"sre_owns_prod"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Group"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Owner"&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;"snapcd_namespace_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"appdev_contributes_app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_developers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;principal_discriminator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Group"&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Contributor"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SRE team owns the entire prod Stack. Application developers can deploy within the application Namespace but cannot touch networking. Both constraints are declared, version-controlled, and enforced at every interaction point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start broad, narrow later.&lt;/strong&gt; Give your team Contributor at the organization level to start. As you identify boundaries — different teams, different environments, different risk levels — add scoped assignments and remove the broad one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use groups, not individual users.&lt;/strong&gt; Assigning roles to groups means onboarding a new team member is a single group membership change, not a dozen role assignments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope Runners to environments.&lt;/strong&gt; A Runner with production credentials should only accept jobs from production Modules. Use &lt;a href="https://docs.snapcd.io/resources/runner/#allowing-a-module-to-use-a-runner" rel="noopener noreferrer"&gt;Runner Supply&lt;/a&gt; to enforce this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat permissions as infrastructure.&lt;/strong&gt; Define all role assignments in Terraform. If a role assignment isn't in code, it shouldn't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit regularly.&lt;/strong&gt; Because all role assignments are Terraform resources, &lt;code&gt;terraform plan&lt;/code&gt; will show you any drift between your intended permissions and the actual state.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — scoping Runner credentials per environment&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/scaling-beyond-a-single-team" rel="noopener noreferrer"&gt;Scaling Terraform Infrastructure Beyond a Single Team&lt;/a&gt; — multi-team patterns that depend on scoped permissions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — the Stack, Namespace, and Module hierarchy that permissions are scoped to&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/secrets-management-terraform" rel="noopener noreferrer"&gt;Managing Secrets in Terraform&lt;/a&gt; — how scoped secrets complement scoped permissions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/event-driven-cd" rel="noopener noreferrer"&gt;Event-driven Continuous Deployment&lt;/a&gt; — approval gates in the context of automated deployments&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/ai-on-a-leash" rel="noopener noreferrer"&gt;AI on a Leash&lt;/a&gt; — scoping AI Agent permissions with the same RBAC model&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>terraform</category>
      <category>cicd</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Why Snap CD: Event-driven Continuous Deployment</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Fri, 03 Jul 2026 07:12:19 +0000</pubDate>
      <link>https://dev.to/karlschriek/why-snap-cd-event-driven-continuous-deployment-2in2</link>
      <guid>https://dev.to/karlschriek/why-snap-cd-event-driven-continuous-deployment-2in2</guid>
      <description>&lt;p&gt;Infrastructure deployment usually starts simple: someone runs &lt;code&gt;terraform apply&lt;/code&gt; on their laptop, eyeballs the plan, and hits yes. That works fine with a small team and a handful of resources. But as the infrastructure grows — more states, more teams, more environments — the question shifts from "how do I apply this" to "how do I make sure the right things deploy at the right time, in the right order, without someone babysitting the process."&lt;/p&gt;

&lt;p&gt;This guide walks through the common approaches to automating Terraform deployments, where each one breaks down, and how Snap CD's event-driven model addresses the gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual era
&lt;/h2&gt;

&lt;p&gt;Every Terraform project starts here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infra/networking
terraform plan &lt;span class="nt"&gt;-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plan.tfplan
&lt;span class="c"&gt;# read the output carefully...&lt;/span&gt;
terraform apply plan.tfplan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is fine until it isn't. The problems are well-known:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No audit trail.&lt;/strong&gt; Who applied what, when? You'd need to grep shell history or hope someone wrote it down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No ordering guarantee.&lt;/strong&gt; If networking needs to be applied before compute, that lives in someone's head. A new team member doesn't know.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift between environments.&lt;/strong&gt; Dev gets the latest change; prod doesn't, because someone forgot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale plans.&lt;/strong&gt; You run &lt;code&gt;plan&lt;/code&gt; at 2pm, get distracted, &lt;code&gt;apply&lt;/code&gt; at 5pm. The world may have changed in between.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most teams move away from manual applies within months of going to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scheduled CI pipelines
&lt;/h2&gt;

&lt;p&gt;The natural next step is to put &lt;code&gt;terraform apply&lt;/code&gt; in CI. A pipeline runs on every merge to main, or on a cron schedule:&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="c1"&gt;# Typical CI approach&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;infra/networking/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform init&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform apply -auto-approve&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This solves the audit trail (CI logs everything) and drift (cron catches config drift eventually). But it introduces new problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No dependency awareness.&lt;/strong&gt; You can trigger networking's pipeline on a path filter, but compute doesn't know to re-run when networking's outputs change. You end up writing brittle pipeline glue: "after networking finishes, trigger compute, then trigger DNS."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wasted runs.&lt;/strong&gt; A cron-based pipeline runs every 15 minutes whether anything changed or not. Most runs produce empty plans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blast radius of &lt;code&gt;-auto-approve&lt;/code&gt;.&lt;/strong&gt; If the pipeline auto-applies, a bad commit deploys immediately. If it doesn't, someone still has to watch it and click approve — you've automated the &lt;code&gt;init&lt;/code&gt; and &lt;code&gt;plan&lt;/code&gt; but not the decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-repo coordination.&lt;/strong&gt; If networking and compute live in different repos, the path filter approach doesn't help. You need webhook chains or a shared orchestration layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Teams at this stage typically spend significant time maintaining CI configuration that is, in effect, a hand-rolled deployment orchestrator.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitOps-style operators
&lt;/h2&gt;

&lt;p&gt;Tools like Atlantis and similar Terraform GitOps operators move the trigger model closer to what you want: watch a repo, run plan on PR, apply on merge. This is a genuine improvement over raw CI — the plan is visible in the PR, approvals happen in the code review flow.&lt;/p&gt;

&lt;p&gt;But the model has limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single-state focus.&lt;/strong&gt; Most GitOps operators work within one repository or one state. They don't model the relationship between your networking state and your compute state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No cascading.&lt;/strong&gt; When networking outputs change, nothing tells the compute operator to re-plan. You're back to manual coordination or webhook scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approval is binary.&lt;/strong&gt; You can approve a PR, but you can't say "this plan needs two approvals before apply" or "destroy plans need a different approval threshold than regular changes."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Event-driven deployment with Snap CD
&lt;/h2&gt;

&lt;p&gt;Snap CD's approach is different: instead of triggering on CI events and bolting on dependency management after the fact, it models the dependency graph as a first-class concept and triggers deployments based on changes to that graph.&lt;/p&gt;

&lt;p&gt;Module deployments are &lt;a href="https://docs.snapcd.io/how-it-works/orchestration/" rel="noopener noreferrer"&gt;orchestrated&lt;/a&gt; automatically based on three types of events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source changes&lt;/strong&gt;: A new commit lands on a branch, or a new semantic version tag appears. Snap CD detects this (typically via polling jobs pushed to a &lt;a href="https://docs.snapcd.io/components/runner/" rel="noopener noreferrer"&gt;Runner&lt;/a&gt;, but manual notification webhooks are also supported) and triggers a deployment job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upstream output changes&lt;/strong&gt;: When a dependency's outputs change, downstream Modules re-deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Definition changes&lt;/strong&gt;: When you modify a Module's configuration (e.g. via the &lt;a href="https://registry.terraform.io/providers/schrieksoft/snapcd/latest/docs" rel="noopener noreferrer"&gt;Terraform Provider&lt;/a&gt;, or manually via the Dashboard), it triggers a sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also require &lt;strong&gt;manual approval&lt;/strong&gt; before applies go through, with configurable approval thresholds. This lets you build workflows where plans run automatically but &lt;code&gt;apply&lt;/code&gt; waits for human sign-off.&lt;/p&gt;

&lt;p&gt;The following sections walk through each of these in detail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Source changes
&lt;/h3&gt;

&lt;p&gt;Every Snap CD &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Module&lt;/a&gt; points at a source — a Git repository at a specific revision:&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;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-networking.git"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Snap CD periodically checks the source for new commits. When it finds one, it triggers a plan. No CI pipeline configuration, no webhooks, no path filters.&lt;/p&gt;

&lt;p&gt;If you prefer version-based releases over branch tracking, use semantic version ranges:&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;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-networking.git"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"v2.*"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SemanticVersionRange"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Snap CD to resolve the latest &lt;code&gt;v2.x.y&lt;/code&gt; tag. When you push &lt;code&gt;v2.4.0&lt;/code&gt;, Snap CD picks it up and triggers a plan. Tags outside the range (like &lt;code&gt;v3.0.0&lt;/code&gt;) are ignored.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upstream output changes (dependency cascading)
&lt;/h3&gt;

&lt;p&gt;The real power shows up when you wire Modules together. Suppose your compute Module needs the VPC ID and subnet IDs from networking:&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;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"compute"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"compute"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-compute.git"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&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;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"private_subnet_ids"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"private_subnet_ids"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"private_subnet_ids"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Snap CD knows: compute depends on networking. When networking applies and its &lt;a href="https://docs.snapcd.io/resources/module-inputs/" rel="noopener noreferrer"&gt;outputs&lt;/a&gt; change — say you added a new subnet — compute automatically re-plans with the updated values. No webhook. No CI trigger. No glue script.&lt;/p&gt;

&lt;p&gt;This cascading is transitive. If DNS depends on compute, and compute depends on networking, a change to networking ripples through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;networking outputs change
    → compute re-plans and applies
        → compute outputs change
            → dns re-plans and applies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Independent Modules run in parallel. If both compute and database depend on networking but not on each other, they re-plan simultaneously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Definition changes
&lt;/h3&gt;

&lt;p&gt;Source changes aren't the only trigger. If you update a Module's definition — change an input value, reassign it to a different &lt;a href="https://docs.snapcd.io/resources/runner/" rel="noopener noreferrer"&gt;Runner&lt;/a&gt;, modify a hook — Snap CD detects the configuration change and triggers a re-plan.&lt;/p&gt;

&lt;p&gt;This means your Terraform provider code is the single source of truth. Changing a variable in your Snap CD configuration:&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;"snapcd_module_input_from_literal"&lt;/span&gt; &lt;span class="s2"&gt;"cluster_version"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes_version"&lt;/span&gt;
  &lt;span class="nx"&gt;literal_value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.30"&lt;/span&gt;   &lt;span class="c1"&gt;# was "1.28"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"String"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…triggers a re-plan of the compute Module with the new value. The same way a commit to the source repo would.&lt;/p&gt;

&lt;p&gt;Any change to the &lt;code&gt;snapcd_module&lt;/code&gt; resource itself or to child resources — &lt;code&gt;snapcd_module_input_from_output&lt;/code&gt;, &lt;code&gt;snapcd_module_input_from_literal&lt;/code&gt;, &lt;code&gt;snapcd_module_input_from_secret&lt;/code&gt;, &lt;code&gt;snapcd_extra_file&lt;/code&gt;, &lt;code&gt;snapcd_backend_config&lt;/code&gt;, and others — triggers a re-plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approval gates
&lt;/h3&gt;

&lt;p&gt;Not every plan should auto-apply. Snap CD lets you set &lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;approval thresholds&lt;/a&gt; at the Module or &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Namespace&lt;/a&gt; level:&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;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"database"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"database"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-database.git"&lt;/span&gt;
  &lt;span class="nx"&gt;source_revision&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;apply_approval_threshold&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;destroy_approval_threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;apply_approval_threshold = 1&lt;/code&gt;, Snap CD pauses after planning and waits for at least one principal to approve before applying. Destroy operations require two separate approvals.&lt;/p&gt;

&lt;p&gt;You can set defaults at the Namespace level so all Modules within it inherit the same policy. Namespaces live inside &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Stacks&lt;/a&gt;, which represent hard boundaries like "prod" and "dev":&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;"snapcd_namespace"&lt;/span&gt; &lt;span class="s2"&gt;"production"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"production"&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;                            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;default_apply_approval_threshold&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;default_destroy_approval_threshold&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="nx"&gt;default_approval_timeout_minutes&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Individual Modules can override the Namespace defaults. A low-risk monitoring Module might not need approval; a database Module might need two.&lt;/p&gt;

&lt;h3&gt;
  
  
  Publishing events externally
&lt;/h3&gt;

&lt;p&gt;Deployment lifecycle events aren't limited to internal orchestration. &lt;a href="https://docs.snapcd.io/resources/integration/" rel="noopener noreferrer"&gt;Integrations&lt;/a&gt; let you push events to external systems. Slack is the first supported sink, with others to follow. You define which events fire on which scope using &lt;a href="https://docs.snapcd.io/resources/integration-event/" rel="noopener noreferrer"&gt;Integration Events&lt;/a&gt;, and which scopes the Integration serves via the same supply model used by Runners:&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;data&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_integration"&lt;/span&gt; &lt;span class="s2"&gt;"alerts"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"alerts"&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;"snapcd_integration_stack_supply"&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;integration_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;production&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_stack_integration_event"&lt;/span&gt; &lt;span class="s2"&gt;"failed"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;production&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;integration_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;trigger&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"JobFailed"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sends a Slack notification whenever any Module in the production Stack has a failed deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;Consider an infrastructure setup with four states: networking, compute, database, and DNS. In a traditional CI setup, you'd maintain four separate pipelines with webhook triggers, shell scripts to pass outputs between them, and manual ordering logic scattered across CI configuration files.&lt;/p&gt;

&lt;p&gt;With Snap CD, the same setup is four Modules with explicit dependency wiring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;networking (watches main branch)
    ├── compute (takes vpc_id, subnet_ids from networking)
    │       └── dns (takes load_balancer_ip from compute)
    └── database (takes subnet_ids, security_group_id from networking)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A commit to &lt;code&gt;infra-networking&lt;/code&gt; that changes a subnet:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Networking re-plans and applies.&lt;/li&gt;
&lt;li&gt;Snap CD detects networking's outputs changed.&lt;/li&gt;
&lt;li&gt;Compute and database re-plan in parallel (independent of each other).&lt;/li&gt;
&lt;li&gt;Compute applies. Its outputs change (new load balancer IP).&lt;/li&gt;
&lt;li&gt;DNS re-plans and applies with the new IP.&lt;/li&gt;
&lt;li&gt;Database applies. No downstream dependents, cascade stops.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All of this happens without any CI configuration. The dependency graph lives in Terraform code (the &lt;code&gt;snapcd_module_input_from_output&lt;/code&gt; resources), not in CI pipeline YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use what
&lt;/h2&gt;

&lt;p&gt;Event-driven deployment isn't always necessary. Here's a rough guide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single state, single team&lt;/strong&gt;: manual applies or a simple CI pipeline are fine. You don't need an orchestrator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple states, one team&lt;/strong&gt;: a CI pipeline with some output-passing glue works, but starts to get brittle. Snap CD simplifies the wiring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple states, multiple teams&lt;/strong&gt;: this is where event-driven deployment pays for itself. The dependency graph is explicit, ordering is automatic, and approval gates let each team control their own blast radius.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version-based releases&lt;/strong&gt;: if you tag infrastructure Modules with semantic versions and want controlled rollouts, Snap CD's version range tracking is built for this.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with one Namespace.&lt;/strong&gt; Put your first few Modules in a single Namespace to learn the trigger model. Split into multiple Namespaces later when you need different default approval policies or Runner assignments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;source_revision_type = "SemanticVersionRange"&lt;/code&gt; for production.&lt;/strong&gt; Tracking &lt;code&gt;main&lt;/code&gt; is fine for dev, but production Modules should pin to a version range so you control exactly when changes roll out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set approval thresholds on destructive Modules first.&lt;/strong&gt; Databases and DNS are the obvious candidates — the resources where a bad apply is hardest to undo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't over-wire dependencies.&lt;/strong&gt; Only create &lt;code&gt;snapcd_module_input_from_output&lt;/code&gt; resources for values that actually flow between Modules. Not every Module needs to depend on every other Module.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the cascade.&lt;/strong&gt; When networking changes, the cascade might touch five downstream Modules. That's the point — but make sure those Modules have appropriate approval thresholds if you want a human in the loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — how the Module, Namespace, and Stack hierarchy works in detail&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — how Runners execute plans triggered by events&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/permission-system" rel="noopener noreferrer"&gt;A Permission System Built for Infrastructure&lt;/a&gt; — approval gates and RBAC for event-driven workflows&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/drift-detection-terraform" rel="noopener noreferrer"&gt;Detecting and Managing Terraform Drift&lt;/a&gt; — scheduled drift checks as another event trigger&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/non-invasive-orchestration" rel="noopener noreferrer"&gt;Non-invasive Orchestration&lt;/a&gt; — how Snap CD runs standard Terraform without wrappers&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>cloud</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Detecting and Managing Terraform Drift</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Fri, 03 Jul 2026 06:55:32 +0000</pubDate>
      <link>https://dev.to/karlschriek/detecting-and-managing-terraform-drift-1kd</link>
      <guid>https://dev.to/karlschriek/detecting-and-managing-terraform-drift-1kd</guid>
      <description>&lt;p&gt;Terraform assumes it's the only thing managing your infrastructure. The moment something changes outside of Terraform — a manual console edit, an auto-scaler adjusting capacity, another tool modifying a resource, an emergency hotfix applied directly in the cloud — Terraform's state file no longer reflects reality.&lt;/p&gt;

&lt;p&gt;That gap between what Terraform thinks exists and what actually exists is drift. Every team experiences it. Few have a reliable way to detect it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What drift looks like
&lt;/h2&gt;

&lt;p&gt;Drift isn't always obvious. Some common scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Emergency console changes.&lt;/strong&gt; Production is down. An engineer opens the AWS console and widens a security group to restore traffic. The fix works. Nobody updates the Terraform code. Two weeks later, someone runs &lt;code&gt;terraform apply&lt;/code&gt; on an unrelated change, and the plan silently reverts the security group — taking production down again.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auto-scaling and managed services.&lt;/strong&gt; AWS auto-scaling changes the desired count on an ASG. Azure adjusts throughput on a Cosmos DB instance. GCP resizes a managed instance group. These are expected changes made by the cloud provider, but Terraform's state doesn't know about them. The next plan shows phantom diffs that confuse reviewers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-tool modifications.&lt;/strong&gt; A Kubernetes operator creates a load balancer that Terraform also manages. A CI pipeline updates an IAM policy outside of Terraform. A different team uses Pulumi for their resources but shares a VPC that Terraform created. Any of these can modify resources that Terraform considers under its control.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Provider upgrades.&lt;/strong&gt; A new version of the AWS provider reads a resource differently — normalising JSON policies, reordering security group rules, or adding new default attributes. The resource hasn't changed, but the plan shows a diff. This is &lt;a href="https://github.com/hashicorp/terraform/issues/28803" rel="noopener noreferrer"&gt;one of the most common sources of noisy drift&lt;/a&gt; — expected changes from the refresh report that aren't real drift but look like it.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why drift is dangerous
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Silent overwrites
&lt;/h3&gt;

&lt;p&gt;The most immediate danger: &lt;code&gt;terraform apply&lt;/code&gt; will converge the real infrastructure to match the declared state. If someone made a manual fix that isn't reflected in the code, the next apply reverts it. There's no warning — the plan just shows a diff, and if the reviewer doesn't recognise it as "that emergency fix from last Tuesday," it gets applied.&lt;/p&gt;

&lt;h3&gt;
  
  
  Misleading plans
&lt;/h3&gt;

&lt;p&gt;When state and reality diverge, &lt;code&gt;terraform plan&lt;/code&gt; output becomes unreliable. A plan that shows "3 to change" might actually represent 1 intentional change and 2 drift reversions. Reviewers can't tell which is which. Over time, teams stop trusting the plan output — which defeats the entire purpose of plan review.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compliance drift
&lt;/h3&gt;

&lt;p&gt;Security-sensitive resources are the highest-risk category. A security group opened to &lt;code&gt;0.0.0.0/0&lt;/code&gt; during an incident, an IAM policy with overly broad permissions added manually, a database encryption setting changed in the console — all of these are compliance violations that persist silently until someone runs a plan and either catches the diff or blindly applies over it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cascading across states
&lt;/h3&gt;

&lt;p&gt;When infrastructure is split across multiple states, drift in one state can cascade. If the networking state's actual VPC configuration has drifted from what Terraform believes, every downstream state that depends on networking outputs is making decisions based on stale data. The compute state thinks the VPC has three subnets; it actually has four. Nothing breaks until it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  How teams detect drift today
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Manual &lt;code&gt;terraform plan&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The simplest approach: someone runs &lt;code&gt;terraform plan&lt;/code&gt; and looks for unexpected diffs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan &lt;span class="nt"&gt;-detailed-exitcode&lt;/span&gt;
&lt;span class="c"&gt;# Exit code 0: no changes&lt;/span&gt;
&lt;span class="c"&gt;# Exit code 1: error&lt;/span&gt;
&lt;span class="c"&gt;# Exit code 2: changes detected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it doesn't scale:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It requires someone to remember to run it. Under deadline pressure, drift checks are the first thing skipped.&lt;/li&gt;
&lt;li&gt;It holds the state lock for the entire plan duration. On a large state, that's minutes of blocking other operations.&lt;/li&gt;
&lt;li&gt;The output mixes intentional changes with drift. If someone has uncommitted code changes locally, the plan shows both — and distinguishing them takes expertise.&lt;/li&gt;
&lt;li&gt;There's no structured output. You're reading terminal text, not querying a system that knows "this resource drifted."&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Scheduled CI plans
&lt;/h3&gt;

&lt;p&gt;A step up: a cron-triggered CI pipeline that runs &lt;code&gt;terraform plan&lt;/code&gt; on a schedule and alerts on non-zero exit codes.&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="c1"&gt;# .github/workflows/drift-check.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;  &lt;span class="c1"&gt;# Daily at 6 AM&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;drift-check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hashicorp/setup-terraform@v3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform init&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform plan -detailed-exitcode&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Problems:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lock contention.&lt;/strong&gt; The drift-check plan holds the state lock. If an engineer tries to run &lt;code&gt;terraform plan&lt;/code&gt; at the same time, they're blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No structured alerting.&lt;/strong&gt; The pipeline either passes or fails. There's no "these 3 resources drifted" — just a wall of plan text in a CI log. Drift detection is &lt;a href="https://github.com/runatlantis/atlantis/issues/3245" rel="noopener noreferrer"&gt;the most requested Atlantis feature&lt;/a&gt;, and &lt;a href="https://github.com/runatlantis/atlantis/issues/1035" rel="noopener noreferrer"&gt;how to even trigger it&lt;/a&gt; is a recurring question — because CI-based approaches are fundamentally awkward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;False positives.&lt;/strong&gt; Provider version differences between CI and local, or &lt;a href="https://github.com/hashicorp/terraform/issues/28803" rel="noopener noreferrer"&gt;expected changes from auto-managed attributes&lt;/a&gt;, generate noise that drowns out real drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI costs.&lt;/strong&gt; Running a full plan across 20 states daily burns CI minutes. Running it hourly burns more. Most of those runs find nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single state at a time.&lt;/strong&gt; Each pipeline job checks one state. Cross-state drift — where one state's actual outputs don't match what a dependent state consumed — isn't detected at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;terraform plan -refresh-only&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Terraform 1.1 added the &lt;code&gt;-refresh-only&lt;/code&gt; flag, which separates the refresh phase from the planning phase. It shows you what changed in the real world without proposing any configuration changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan &lt;span class="nt"&gt;-refresh-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is better for drift detection than a full plan because it doesn't conflate drift with intentional code changes. But it still requires manual execution, still holds the state lock, and still produces unstructured text output. It's also had reliability issues — OpenTofu's implementation &lt;a href="https://github.com/opentofu/opentofu/issues/3015" rel="noopener noreferrer"&gt;returned false positives&lt;/a&gt; where &lt;code&gt;--refresh-only --detailed-exitcode&lt;/code&gt; exited with code 2 even when there were no actual changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud-native tools
&lt;/h3&gt;

&lt;p&gt;AWS Config, Azure Policy, and GCP Security Command Center can detect configuration changes at the cloud level. They're good at what they do — but they don't understand Terraform.&lt;/p&gt;

&lt;p&gt;AWS Config can tell you that a security group rule changed. It can't tell you which Terraform resource manages that security group, which state file it lives in, or whether the change is intentional. Correlating a Config finding back to Terraform code is manual detective work.&lt;/p&gt;

&lt;p&gt;These tools complement Terraform drift detection. They don't replace it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Third-party tools
&lt;/h3&gt;

&lt;p&gt;Tools like driftctl (now part of Snyk) scan cloud resources and compare them against Terraform state. They can find resources that exist in the cloud but aren't in any state file (unmanaged resources) — something &lt;code&gt;terraform plan&lt;/code&gt; can't do.&lt;/p&gt;

&lt;p&gt;The trade-off is another tool to maintain, another set of credentials to manage, and another source of truth to reconcile. Most of these tools work at a point-in-time snapshot level, not continuously.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Snap CD handles drift
&lt;/h2&gt;

&lt;p&gt;Snap CD treats drift detection as a first-class operation, not a bolted-on CI job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Smaller Modules, smaller drift surface
&lt;/h3&gt;

&lt;p&gt;Snap CD's core design philosophy is breaking infrastructure into small, focused &lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modules&lt;/a&gt; — each with its own state, credentials, and lifecycle. This directly reduces the damage drift can cause.&lt;/p&gt;

&lt;p&gt;In a monolithic state with 500 resources, a single drifted security group hides among hundreds of resources in the plan output. The drift check is slow because the refresh has to query every resource. And if you decide to correct the drift, the apply touches a state that contains everything — networking, compute, databases, DNS — so the blast radius of a mistake is the entire infrastructure.&lt;/p&gt;

&lt;p&gt;When that same infrastructure is split into Modules, the security group lives in a networking Module with 30 resources. The drift check runs in seconds, the plan output is short enough to actually read, and a corrective apply only touches networking. The database and application Modules are untouched.&lt;/p&gt;

&lt;p&gt;Smaller states also mean faster refresh cycles, which means you can check for drift more frequently without the lock contention and API throttling that make frequent checks impractical on large states. See &lt;a href="https://snapcd.io/Blog/large-terraform-states" rel="noopener noreferrer"&gt;The Problem with Large Terraform States&lt;/a&gt; for why state size matters, and &lt;a href="https://snapcd.io/Blog/splitting-terraform-monolith" rel="noopener noreferrer"&gt;Splitting a Terraform Monolith&lt;/a&gt; for how to get there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scheduled drift checks per Module
&lt;/h3&gt;

&lt;p&gt;Each Snap CD &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Module&lt;/a&gt; can run periodic plans on a schedule, independent of code changes. The Server triggers a plan, the &lt;a href="https://docs.snapcd.io/resources/runner/" rel="noopener noreferrer"&gt;Runner&lt;/a&gt; executes it, and the result is stored with the same structured metadata as any other deployment.&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;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-networking.git"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drift checks run as normal plans — they refresh state against reality and show any differences. The key distinction is that they're triggered by the scheduler, not by a code change, so the plan output represents pure drift: changes made outside of Terraform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured results in the dashboard
&lt;/h3&gt;

&lt;p&gt;When drift is detected, it's visible in the Snap CD dashboard as a plan with changes. Reviewers can see exactly which resources drifted and what changed — not a wall of CI log text, but a structured plan output with the same review interface used for normal deployments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approval before correction
&lt;/h3&gt;

&lt;p&gt;A drift detection plan that shows changes doesn't automatically apply. It enters the same approval workflow as any other plan. If the drift is intentional (an emergency fix that needs to stay), someone can dismiss the plan. If it's unintentional (someone accidentally changed a setting in the console), the team can approve the corrective apply to bring reality back in line with code.&lt;/p&gt;

&lt;p&gt;This is the critical difference from continuous reconciliation tools like Crossplane, which would silently revert the change. Snap CD surfaces drift and lets humans decide what to do about it.&lt;/p&gt;

&lt;h3&gt;
  
  
  RBAC for drift visibility
&lt;/h3&gt;

&lt;p&gt;Not everyone needs to see drift in every Module. Snap CD's &lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;permission system&lt;/a&gt; controls who can view plans (including drift check results) and who can approve corrective applies. The security team can have &lt;code&gt;Reader&lt;/code&gt; access to see drift across all Modules without the ability to approve changes. The networking team can approve corrections to their own Modules without needing access to application infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cross-state drift awareness
&lt;/h3&gt;

&lt;p&gt;When drift is detected in a Module that produces outputs consumed by other Modules, Snap CD understands the dependency graph. If the networking Module's actual &lt;code&gt;vpc_id&lt;/code&gt; has drifted, Snap CD knows that the compute and database Modules depend on that output. Correcting the drift in networking can trigger re-plans in dependent Modules, catching cascading effects that CI-based drift detection misses entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical setup
&lt;/h2&gt;

&lt;p&gt;A team with five Modules across networking, compute, database, application, and DNS:&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;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-networking.git"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;apply_approval_threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&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;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"compute"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"compute"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-compute.git"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;apply_approval_threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&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;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"vpc_to_compute"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the scheduled drift check runs on the networking module and detects that a subnet was added manually in the console:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The drift appears in the dashboard as a plan showing the unexpected subnet.&lt;/li&gt;
&lt;li&gt;Two approvers review the plan (networking requires 2 approvals).&lt;/li&gt;
&lt;li&gt;If they approve the corrective apply, Terraform removes the manually-added subnet (or, if the team wants to keep it, they update the code first and the next plan shows no changes).&lt;/li&gt;
&lt;li&gt;If the corrective apply changes networking's outputs, compute automatically re-plans.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No CI cron job. No Slack message asking "did anyone change the VPC?" No &lt;code&gt;terraform plan&lt;/code&gt; holding the lock while an engineer reads the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Manual plan&lt;/th&gt;
&lt;th&gt;CI cron&lt;/th&gt;
&lt;th&gt;Cloud-native tools&lt;/th&gt;
&lt;th&gt;Snap CD&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Automation&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Schedule-based&lt;/td&gt;
&lt;td&gt;Continuous&lt;/td&gt;
&lt;td&gt;Schedule-based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock contention&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Managed per-Module&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured results&lt;/td&gt;
&lt;td&gt;No (terminal text)&lt;/td&gt;
&lt;td&gt;No (CI logs)&lt;/td&gt;
&lt;td&gt;Yes (but not Terraform-aware)&lt;/td&gt;
&lt;td&gt;Yes (dashboard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval before fix&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-state awareness&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Dependency graph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drift vs. code change&lt;/td&gt;
&lt;td&gt;Mixed&lt;/td&gt;
&lt;td&gt;Mixed&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Separated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Engineer time&lt;/td&gt;
&lt;td&gt;CI minutes&lt;/td&gt;
&lt;td&gt;Cloud service pricing&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check drift more often on high-risk resources.&lt;/strong&gt; Security groups, IAM policies, and database configurations are the most common targets for manual changes. Schedule drift checks for modules containing these resources more frequently than stable infrastructure like DNS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't auto-apply drift corrections.&lt;/strong&gt; The whole point is to surface drift for human review. Automatic correction is just continuous reconciliation with extra steps — and it defeats the safety of the plan-then-approve workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Investigate before correcting.&lt;/strong&gt; When drift is detected, the first question is "why?" If someone made an emergency change, the fix is to update the Terraform code to match, not to revert the change. If the drift is from a provider bug or an auto-managed attribute, the fix might be &lt;code&gt;ignore_changes&lt;/code&gt;, not a corrective apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track your drift rate.&lt;/strong&gt; If the same module drifts repeatedly, that's a signal — either the manual change is actually needed (and should be in code) or the team doesn't trust the Terraform workflow enough to use it for urgent changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate drift-prone resources.&lt;/strong&gt; Resources that are frequently modified outside Terraform (auto-scaled groups, resources managed by operators) should be in their own Module, so their expected drift doesn't create noise in Modules that should never drift. Smaller, focused states also reduce the blast radius of any single drift correction — see &lt;a href="https://snapcd.io/Blog/large-terraform-states" rel="noopener noreferrer"&gt;The Problem with Large Terraform States&lt;/a&gt; and &lt;a href="https://snapcd.io/Blog/splitting-terraform-monolith" rel="noopener noreferrer"&gt;Splitting a Terraform Monolith&lt;/a&gt; for how to get there.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — how Snap CD's Module and dependency system enables per-Module drift checks&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — how Runners execute drift check plans with scoped credentials&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/event-driven-cd" rel="noopener noreferrer"&gt;Event-driven Continuous Deployment&lt;/a&gt; — how drift corrections cascade through the dependency graph&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/permission-system" rel="noopener noreferrer"&gt;A Permission System Built for Infrastructure&lt;/a&gt; — RBAC for controlling who can view and approve drift corrections&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/large-terraform-states" rel="noopener noreferrer"&gt;The Problem with Large Terraform States&lt;/a&gt; — why smaller states make drift detection practical&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>terraform</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Why Snap CD: Modular Deployments</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:22:06 +0000</pubDate>
      <link>https://dev.to/karlschriek/why-snap-cd-modular-deployments-4k4j</link>
      <guid>https://dev.to/karlschriek/why-snap-cd-modular-deployments-4k4j</guid>
      <description>&lt;p&gt;Terraform manages dependencies between resources within a single state. The moment your infrastructure outgrows one state file — &lt;a href="https://snapcd.io/Blog/large-terraform-states" rel="noopener noreferrer"&gt;slow plans, wide blast radius, team contention&lt;/a&gt; — you need to split. But the pieces still depend on each other: compute needs the VPC ID from networking, application infrastructure needs the cluster endpoint from compute.&lt;/p&gt;

&lt;p&gt;The usual approaches — &lt;a href="https://developer.hashicorp.com/terraform/language/state/remote-state-data" rel="noopener noreferrer"&gt;&lt;code&gt;terraform_remote_state&lt;/code&gt;&lt;/a&gt;, parameter stores, wrapper scripts, Terragrunt — each solve part of the problem but leave gaps in change detection, ordering enforcement, and visibility. For a walkthrough of these approaches and how to perform the split itself, see &lt;a href="https://snapcd.io/Blog/splitting-terraform-monolith" rel="noopener noreferrer"&gt;Splitting a Terraform Monolith&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Snap CD's module system was built specifically for what comes after the split: declaring the dependency graph as code, enforcing apply ordering, and cascading changes automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managing Snap CD with Terraform
&lt;/h2&gt;

&lt;p&gt;The HCL examples throughout this guide use the &lt;a href="https://registry.terraform.io/providers/schrieksoft/snapcd/latest/docs" rel="noopener noreferrer"&gt;Snap CD Terraform provider&lt;/a&gt;. This is the canonical way to configure Snap CD — you use Terraform to manage the system that manages your Terraform modules. Everything you see in the examples below — the hierarchy, the dependency wiring, the secret bindings — is declared as standard Terraform resources via this provider.&lt;/p&gt;

&lt;p&gt;This means your Snap CD configuration is version-controlled, reviewable, and reproducible — the same properties you expect from the infrastructure it orchestrates. For more on the provider and the broader toolset, see &lt;a href="https://snapcd.io/Blog/supporting-toolset" rel="noopener noreferrer"&gt;An Extensive Supporting Toolset&lt;/a&gt;. For how runners provide credential isolation between modules, see &lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Stacks, Namespaces, and Modules&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Snap CD organises infrastructure in a three-level hierarchy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stack&lt;/strong&gt; — a top-level grouping, typically an environment or a product. Examples: &lt;code&gt;production&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, &lt;code&gt;platform-services&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Namespace&lt;/strong&gt; — a logical grouping within a Stack, typically a team or an infrastructure layer. Examples: &lt;code&gt;networking&lt;/code&gt;, &lt;code&gt;data-platform&lt;/code&gt;, &lt;code&gt;frontend&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Module&lt;/strong&gt; — a single Terraform root within a Namespace. This is the unit of deployment — each Module has its own state, its own &lt;a href="https://docs.snapcd.io/resources/runner/" rel="noopener noreferrer"&gt;Runner&lt;/a&gt;, and its own lifecycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Permissions, secrets, and default inputs can be set at any level and inherited downward. A secret defined at the namespace level is available to all modules in that namespace. A permission granted at the stack level applies to all namespaces and modules within it.&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;"snapcd_stack"&lt;/span&gt; &lt;span class="s2"&gt;"production"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"production"&lt;/span&gt;
  &lt;span class="nx"&gt;organization_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_organization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_namespace"&lt;/span&gt; &lt;span class="s2"&gt;"platform"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"platform"&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;production&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Modules
&lt;/h2&gt;

&lt;p&gt;A module is an independent Terraform root — its own source repository, its own state, its own runner, its own credentials. Modules are defined within a namespace:&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;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-networking.git"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"compute"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"compute"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-compute.git"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module"&lt;/span&gt; &lt;span class="s2"&gt;"database"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"database"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_url&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/infra-database.git"&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each module is fully independent — its own state, its own credentials (scoped via the &lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;runner&lt;/a&gt;), its own lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://docs.snapcd.io/resources/module-inputs/" rel="noopener noreferrer"&gt;Inputs&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Modules receive values through inputs. Snap CD supports several input types, each suited to a different use case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Outputs from other modules
&lt;/h3&gt;

&lt;p&gt;The most common input type. A module consumes an output from another module, and Snap CD builds the dependency graph from these declarations:&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;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&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;"snapcd_module_input_from_output"&lt;/span&gt; &lt;span class="s2"&gt;"private_subnet_ids"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"private_subnet_ids"&lt;/span&gt;
  &lt;span class="nx"&gt;output_module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;output_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"private_subnet_ids"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;input_kind&lt;/code&gt; controls how the value is delivered to Terraform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Param&lt;/code&gt;&lt;/strong&gt; — injected as a Terraform variable (written to a &lt;code&gt;.tfvars&lt;/code&gt; file). Use this when your Terraform code declares a matching &lt;code&gt;variable&lt;/code&gt; block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;EnvVar&lt;/code&gt;&lt;/strong&gt; — injected as an environment variable. Use this for values that configure provider authentication or backend settings (e.g., &lt;code&gt;ARM_SUBSCRIPTION_ID&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This replaces &lt;code&gt;terraform_remote_state&lt;/code&gt; entirely. Modules don't need to know each other's backend configuration. They declare what they produce and what they consume — Snap CD handles the wiring.&lt;/p&gt;

&lt;p&gt;When two modules share many outputs, &lt;code&gt;snapcd_module_input_from_output_set&lt;/code&gt; wires all outputs from the producer in a single resource — no need to declare each one individually.&lt;/p&gt;

&lt;h3&gt;
  
  
  Literal values
&lt;/h3&gt;

&lt;p&gt;Static configuration values that don't come from another module or a secret:&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;"snapcd_module_input_from_literal"&lt;/span&gt; &lt;span class="s2"&gt;"environment"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"environment"&lt;/span&gt;
  &lt;span class="nx"&gt;literal_value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"production"&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;"snapcd_module_input_from_literal"&lt;/span&gt; &lt;span class="s2"&gt;"instance_count"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"instance_count"&lt;/span&gt;
  &lt;span class="nx"&gt;literal_value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"3"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NotString"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; attribute defaults to &lt;code&gt;"String"&lt;/code&gt;. Set it to &lt;code&gt;"NotString"&lt;/code&gt; for numbers, booleans, lists, or maps — this tells Snap CD to pass the value unquoted so Terraform interprets it as the correct type.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secrets
&lt;/h3&gt;

&lt;p&gt;Secrets are stored encrypted in Snap CD's secret store and injected at runtime. See &lt;a href="https://snapcd.io/Blog/secrets-management-terraform" rel="noopener noreferrer"&gt;Managing Secrets in Terraform&lt;/a&gt; for the full picture. The binding looks like:&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;"snapcd_module_input_from_secret"&lt;/span&gt; &lt;span class="s2"&gt;"db_password"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"db_password"&lt;/span&gt;
  &lt;span class="nx"&gt;secret_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_module_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_password&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Secrets are scoped — a secret bound to the database module is never visible to the networking or compute modules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Namespace-level inputs
&lt;/h3&gt;

&lt;p&gt;When multiple modules in a namespace share common inputs — a subscription ID, a region, a shared tag set — you can define inputs at the namespace level:&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;"snapcd_namespace_input_from_secret"&lt;/span&gt; &lt;span class="s2"&gt;"arm_subscription_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ARM_SUBSCRIPTION_ID"&lt;/span&gt;
  &lt;span class="nx"&gt;secret_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapcd_namespace_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscription_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"EnvVar"&lt;/span&gt;
  &lt;span class="nx"&gt;usage_mode&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"UseByDefault"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;usage_mode&lt;/code&gt; controls inheritance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UseByDefault&lt;/code&gt;&lt;/strong&gt; — every module in the namespace receives this input automatically unless it declares its own override.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UseIfSelected&lt;/code&gt;&lt;/strong&gt; — the input is available but only applied to modules that explicitly opt in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This eliminates the need to repeat the same credential or configuration binding on every module in a namespace. Namespace inputs also support literals (&lt;code&gt;snapcd_namespace_input_from_literal&lt;/code&gt;) and definition values (&lt;code&gt;snapcd_namespace_input_from_definition&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Definition values
&lt;/h3&gt;

&lt;p&gt;Snap CD can inject its own metadata — module IDs, namespace names, source revisions — as inputs to your Terraform code:&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;"snapcd_module_input_from_definition"&lt;/span&gt; &lt;span class="s2"&gt;"module_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;input_kind&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Param"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_module_name"&lt;/span&gt;
  &lt;span class="nx"&gt;definition_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ModuleName"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Available definition values include &lt;code&gt;ModuleId&lt;/code&gt;, &lt;code&gt;ModuleName&lt;/code&gt;, &lt;code&gt;NamespaceId&lt;/code&gt;, &lt;code&gt;NamespaceName&lt;/code&gt;, &lt;code&gt;StackId&lt;/code&gt;, &lt;code&gt;StackName&lt;/code&gt;, &lt;code&gt;SourceUrl&lt;/code&gt;, &lt;code&gt;SourceRevision&lt;/code&gt;, and &lt;code&gt;SourceSubdirectory&lt;/code&gt;. This is useful for tagging resources with their Snap CD provenance or for conditional logic based on which module is being deployed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change propagation
&lt;/h2&gt;

&lt;p&gt;Once modules and their inputs are defined, Snap CD handles the deployment lifecycle automatically:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic ordering.&lt;/strong&gt; Snap CD knows that compute and database depend on networking. It will never apply compute before networking has successfully completed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallel execution.&lt;/strong&gt; Compute and database both depend on networking but not on each other. Snap CD runs them in parallel once networking completes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cascading changes.&lt;/strong&gt; When networking's outputs change — say you add a subnet — Snap CD automatically queues compute and database for re-planning. If the new plan has changes, it either auto-applies (if configured) or waits for approval. If compute's outputs are unchanged, downstream modules that depend on compute are skipped entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source-triggered plans.&lt;/strong&gt; When a commit is pushed to a module's source repository, Snap CD detects the change and triggers a new plan. If the plan produces output changes, dependents cascade as above.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;networking
    ├──► compute ──► application
    └──► database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A commit to &lt;code&gt;infra-networking&lt;/code&gt; that changes a subnet triggers this cascade:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Networking re-plans and applies.&lt;/li&gt;
&lt;li&gt;Compute and database re-plan in parallel.&lt;/li&gt;
&lt;li&gt;If compute's outputs change, application re-plans after compute finishes.&lt;/li&gt;
&lt;li&gt;If compute's outputs are unchanged, application is skipped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No scripts. No parameter stores. No &lt;code&gt;terraform_remote_state&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compared to the alternatives
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;terraform_remote_state&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Parameter store&lt;/th&gt;
&lt;th&gt;Wrapper scripts&lt;/th&gt;
&lt;th&gt;Terragrunt&lt;/th&gt;
&lt;th&gt;Snap CD&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend coupling&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change detection&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ordering enforcement&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parallelism&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval gates&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;DIY&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scoped permissions&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent visibility&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;CI logs&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;If you have a monolithic Terraform state today, the path to modular deployments is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify boundaries&lt;/strong&gt; — group resources by team, lifecycle, and credential scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split the state&lt;/strong&gt; — use &lt;code&gt;terraform state mv&lt;/code&gt; to migrate resources to new roots (see &lt;a href="https://snapcd.io/Blog/splitting-terraform-monolith" rel="noopener noreferrer"&gt;Splitting a Terraform Monolith&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define modules in Snap CD&lt;/strong&gt; — one &lt;code&gt;snapcd_module&lt;/code&gt; per root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire the dependencies&lt;/strong&gt; — &lt;code&gt;snapcd_module_input_from_output&lt;/code&gt; for cross-module values, &lt;code&gt;snapcd_module_input_from_secret&lt;/code&gt; for credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove the glue&lt;/strong&gt; — delete &lt;code&gt;terraform_remote_state&lt;/code&gt; blocks, wrapper scripts, and CI pipeline steps.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From that point on, Snap CD manages the dependency graph, propagates changes, and keeps your infrastructure in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/large-terraform-states" rel="noopener noreferrer"&gt;The Problem with Large Terraform States&lt;/a&gt; — diagnosing when it's time to split&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/splitting-terraform-monolith" rel="noopener noreferrer"&gt;Splitting a Terraform Monolith&lt;/a&gt; — approaches to breaking a monolith into smaller states&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — how Runners provide per-Module credential isolation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/secrets-management-terraform" rel="noopener noreferrer"&gt;Managing Secrets in Terraform&lt;/a&gt; — scoping and injecting secrets per Module&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/permission-system" rel="noopener noreferrer"&gt;A Permission System Built for Infrastructure&lt;/a&gt; — granular RBAC across the Stack/Namespace/Module hierarchy&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/supporting-toolset" rel="noopener noreferrer"&gt;An Extensive Supporting Toolset&lt;/a&gt; — the Terraform provider and broader tooling&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>terraform</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Why Snap CD: Self-Hosted Terraform Runners with Credential Isolation</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Thu, 02 Jul 2026 19:18:00 +0000</pubDate>
      <link>https://dev.to/karlschriek/why-snap-cd-self-hosted-terraform-runners-with-credential-isolation-407e</link>
      <guid>https://dev.to/karlschriek/why-snap-cd-self-hosted-terraform-runners-with-credential-isolation-407e</guid>
      <description>&lt;p&gt;Most infrastructure teams run Terraform from a CI pipeline. That pipeline has credentials — cloud provider keys, state backend tokens, maybe a vault token to fetch more secrets. Early on, one pipeline with one set of credentials works fine. But as the infrastructure grows and more environments come online, the shared-runner model starts creating problems that are hard to fix without rethinking the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shared-runner problem
&lt;/h2&gt;

&lt;p&gt;When a single CI runner (or pool of identical runners) handles all Terraform work, several things go wrong at the same time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Credential sprawl
&lt;/h3&gt;

&lt;p&gt;Your CI runner needs to deploy networking in production, spin up a dev Kubernetes cluster, manage DNS records, and provision a staging database. That means it holds credentials for all of those things — often across multiple cloud providers and accounts.&lt;/p&gt;

&lt;p&gt;Every credential on the runner is accessible to every job that runs on it. A misconfigured pipeline step for the dev environment can reach production AWS keys. The blast radius of a compromised runner is everything it has access to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blast radius
&lt;/h3&gt;

&lt;p&gt;A bad Terraform run is supposed to be scoped to the infrastructure it manages. But when the runner has broad access, a bug in one pipeline — or a malicious commit — can reach resources it was never intended to touch. The runner doesn't know that a dev pipeline shouldn't be able to destroy production resources. It just runs whatever Terraform tells it to, with whatever credentials it has.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compliance and auditability
&lt;/h3&gt;

&lt;p&gt;Auditors want to know who (or what) can access production, and they want that list to be short and verifiable. "Our CI runner can access everything" is not a satisfying answer. Showing that only a specific, dedicated runner with a specific identity can reach production — and that it can only be invoked by specific modules with specific approval gates — is a much stronger story.&lt;/p&gt;

&lt;h3&gt;
  
  
  Team boundaries
&lt;/h3&gt;

&lt;p&gt;Different teams own different parts of the infrastructure. The networking team shouldn't need to care about the application team's deployment pipeline, and vice versa. But when they share a runner, they share the pipeline configuration, the credential setup, the job queue, and the failure modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How teams typically cope
&lt;/h2&gt;

&lt;p&gt;These are real patterns that work, up to a point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Separate CI projects
&lt;/h3&gt;

&lt;p&gt;Create one CI project per environment or per team. The prod project has prod credentials; the dev project has dev credentials. This solves credential scoping but multiplies the number of CI configurations you maintain. Pipeline logic gets duplicated or abstracted into shared templates that become their own maintenance burden.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vault-based credential injection
&lt;/h3&gt;

&lt;p&gt;Use HashiCorp Vault (or a cloud-native equivalent) to issue short-lived credentials at job time. The runner itself has minimal standing access — it authenticates to Vault, gets scoped credentials, and uses them for one job.&lt;/p&gt;

&lt;p&gt;This is architecturally sound but adds operational complexity: you need a Vault cluster (or managed service), policies for every credential path, rotation logic, and monitoring for lease expiry. The runner still executes all jobs — you've scoped the credentials, but the execution environment is shared.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment-specific pipelines
&lt;/h3&gt;

&lt;p&gt;Separate pipeline definitions for each environment, each with their own credential configuration. Similar to separate CI projects but within a single CI system. You get some isolation but the runner infrastructure is still shared, and the pipeline definitions tend to diverge over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-hosted runner groups
&lt;/h3&gt;

&lt;p&gt;CI systems like GitHub Actions and GitLab CI support runner groups or tags. You deploy dedicated runner machines for production and different ones for development, then use labels to route jobs to the right group.&lt;/p&gt;

&lt;p&gt;This works well for compute isolation but you're now managing runner infrastructure yourself — provisioning machines, keeping them patched, scaling them, and managing the credential distribution to each group. The CI system orchestrates which job goes where, but the operational burden is on you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snap CD's approach: separate orchestration from execution
&lt;/h2&gt;

&lt;p&gt;Snap CD organises infrastructure in a three-level hierarchy: &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;&lt;strong&gt;Stacks&lt;/strong&gt;, &lt;strong&gt;Namespaces&lt;/strong&gt;, and &lt;strong&gt;Modules&lt;/strong&gt;&lt;/a&gt;. Stacks typically represent environments (production, staging), Namespaces group by team or infrastructure layer (networking, data-platform), and Modules are individual Terraform roots. &lt;a href="https://docs.snapcd.io/resources/identity-access-management/" rel="noopener noreferrer"&gt;Permissions&lt;/a&gt;, &lt;a href="https://docs.snapcd.io/resources/secrets/" rel="noopener noreferrer"&gt;secrets&lt;/a&gt;, and &lt;a href="https://docs.snapcd.io/resources/runner/" rel="noopener noreferrer"&gt;Runner&lt;/a&gt; access can be scoped at any level and inherited downward. For a full walkthrough of this hierarchy and the input system, see &lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt;, and for the permission system that supports it, see &lt;a href="https://snapcd.io/Blog/permission-system" rel="noopener noreferrer"&gt;A Permission System Built for Infrastructure&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Snap CD was designed around the idea that the system coordinating deployments should not be the same system executing them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two distinct roles
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The Snap CD Server&lt;/strong&gt; (hosted at snapcd.io, or self-hosted) handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Module definitions — what to deploy, from which source, with which inputs&lt;/li&gt;
&lt;li&gt;Dependency tracking — which modules depend on which outputs&lt;/li&gt;
&lt;li&gt;Change detection — watching Git repos and upstream outputs for changes&lt;/li&gt;
&lt;li&gt;Plan review and approval gates&lt;/li&gt;
&lt;li&gt;Logging and audit trails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The server never touches your cloud provider. It never holds your AWS keys or Azure credentials. It doesn't run &lt;code&gt;terraform plan&lt;/code&gt; or &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runners&lt;/strong&gt; handle execution. A Runner is a lightweight, self-hosted worker that you deploy wherever makes sense — a Kubernetes pod in your cluster, a VM in your cloud account, a container on a developer machine. The Runner:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connects to the Snap CD server via a long-lived, authenticated, bi-directional WebSocket connection.&lt;/li&gt;
&lt;li&gt;Picks up jobs assigned to it (plan, apply).&lt;/li&gt;
&lt;li&gt;Downloads the module source code.&lt;/li&gt;
&lt;li&gt;Executes standard Terraform/OpenTofu commands in a local shell session.&lt;/li&gt;
&lt;li&gt;Reports results (plan output, apply output, state changes) back to the server.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Runner only has the credentials you give it. A Runner deployed into your production Azure subscription with a managed identity has access to production Azure — and nothing else. A Runner on a dev machine with dev AWS keys can only reach dev AWS.&lt;/p&gt;

&lt;h3&gt;
  
  
  No credential forwarding
&lt;/h3&gt;

&lt;p&gt;The Snap CD server never sees, stores, or forwards cloud credentials. Credentials live on the Runner, configured the same way you'd configure them for any local Terraform run — environment variables, cloud provider metadata services, credential files. The server tells the Runner what to do; the Runner uses its own credentials to do it.&lt;/p&gt;

&lt;p&gt;This means a compromise of the Snap CD server does not expose your cloud credentials. The server knows your module definitions and deployment history, but it cannot execute infrastructure changes on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Permission-controlled runner access
&lt;/h3&gt;

&lt;p&gt;Snap CD's permission system extends to Runners. You can control which Modules are allowed to use which Runners through supply resources:&lt;/p&gt;

&lt;p&gt;The HCL examples below use the &lt;a href="https://registry.terraform.io/providers/schrieksoft/snapcd/latest/docs" rel="noopener noreferrer"&gt;Snap CD Terraform provider&lt;/a&gt; — the canonical way to configure Snap CD with Terraform. For more on the provider, see &lt;a href="https://snapcd.io/Blog/supporting-toolset" rel="noopener noreferrer"&gt;An Extensive Supporting Toolset&lt;/a&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="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_runner"&lt;/span&gt; &lt;span class="s2"&gt;"prod_azure"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod-azure"&lt;/span&gt;
  &lt;span class="nx"&gt;organization_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_organization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_runner"&lt;/span&gt; &lt;span class="s2"&gt;"dev_azure"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dev-azure"&lt;/span&gt;
  &lt;span class="nx"&gt;organization_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_organization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then use supply resources to declare which Runners are available to which scopes. A Runner "supplies" itself to a Stack, Namespace, or individual Module. The most common pattern is supplying a Runner to a Stack — since Stacks typically represent environments (production, staging, dev), this gives you per-environment credential isolation:&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;"snapcd_runner_stack_supply"&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod_azure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;production&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_runner_stack_supply"&lt;/span&gt; &lt;span class="s2"&gt;"dev"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dev_azure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;stack_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dev&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Module in the &lt;code&gt;production&lt;/code&gt; Stack — across all its Namespaces (networking, compute, data, etc.) — can only execute on &lt;code&gt;prod-azure&lt;/code&gt;. Every Module in &lt;code&gt;dev&lt;/code&gt; can only execute on &lt;code&gt;dev-azure&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Supply resources also work at the Namespace and Module level, so you can drill down when a team or a specific piece of infrastructure needs its own isolated Runner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Namespace-level supply.&lt;/strong&gt; Useful when a team manages critical resources that require dedicated credentials — for example, a data-platform Namespace where only a Runner with access to production databases should execute:&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;"snapcd_runner"&lt;/span&gt; &lt;span class="s2"&gt;"prod_data"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod-data-platform"&lt;/span&gt;
  &lt;span class="nx"&gt;organization_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_organization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"snapcd_runner_namespace_supply"&lt;/span&gt; &lt;span class="s2"&gt;"data_platform"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;namespace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_namespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data_platform&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modules in the &lt;code&gt;data_platform&lt;/code&gt; Namespace execute on &lt;code&gt;prod-data-platform&lt;/code&gt; instead of the Stack-level Runner. Other Namespaces in the same Stack continue using the Stack-level Runner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Module-level supply.&lt;/strong&gt; Rarely needed, but available for cases where a single module requires its own isolated runner — for example, a module that manages a key vault or certificate authority with uniquely sensitive credentials:&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;"snapcd_runner_module_supply"&lt;/span&gt; &lt;span class="s2"&gt;"key_vault"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;runner_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod_keyvault&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;module_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapcd_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod_key_vault&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boundary is enforced by the server before a job is dispatched — a module without a matching supply will not execute, regardless of what credentials are available elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment patterns
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One Runner per environment
&lt;/h3&gt;

&lt;p&gt;The most common pattern. Deploy a Runner into each environment (dev, staging, production), each with credentials scoped to that environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;snapcd.io
   │
   ├── Runner-dev      (dev AWS credentials)
   ├── Runner-staging   (staging AWS credentials)
   └── Runner-prod      (prod AWS credentials, approval gates required)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean credential boundaries. A compromised dev Runner cannot reach production. Simple to reason about.&lt;/p&gt;

&lt;h3&gt;
  
  
  One Runner per cloud provider
&lt;/h3&gt;

&lt;p&gt;When your infrastructure spans multiple clouds, deploy Runners with provider-specific credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;snapcd.io
   │
   ├── Runner-azure     (Azure managed identity)
   ├── Runner-aws       (AWS IAM role)
   └── Runner-gcp       (GCP service account)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful when environment boundaries are less important than provider boundaries — for example, if your Azure and AWS infrastructure are managed by different teams with different credential policies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Combined: environment × provider
&lt;/h3&gt;

&lt;p&gt;For larger organizations, combine both dimensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;snapcd.io
   │
   ├── Runner-azure-dev
   ├── Runner-azure-prod
   ├── Runner-aws-dev
   └── Runner-aws-prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each Runner has exactly the credentials it needs — nothing more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared Runner with scoped permissions
&lt;/h3&gt;

&lt;p&gt;For smaller teams that don't need strict environment isolation, a single Runner with broad credentials can work. Use Snap CD's permission system to control which users and service principals can trigger deployments on that Runner, and rely on approval gates for production changes.&lt;/p&gt;

&lt;p&gt;This trades some isolation for operational simplicity. It's a reasonable starting point that you can tighten as the team and infrastructure grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fully source-available
&lt;/h2&gt;

&lt;p&gt;Snap CD is source-available — the entire codebase, including the Server, Runner, and Terraform provider, is maintained in a single &lt;a href="https://github.com/schrieksoft/snapcd" rel="noopener noreferrer"&gt;monorepo&lt;/a&gt;. You can inspect every component, understand exactly what runs in your environment, and modify it if you need to.&lt;/p&gt;

&lt;p&gt;The Runner is designed to be stateless between jobs. It downloads Module source code, runs Terraform, reports results, and cleans up. No job data persists on the Runner after completion, which simplifies security reviews and makes Runners easy to replace or scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compared to the alternatives
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Separate CI projects&lt;/th&gt;
&lt;th&gt;Vault + shared runner&lt;/th&gt;
&lt;th&gt;Self-hosted runner groups&lt;/th&gt;
&lt;th&gt;Snap CD Runners&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Credential scoping&lt;/td&gt;
&lt;td&gt;Per-project&lt;/td&gt;
&lt;td&gt;Per-job (dynamic)&lt;/td&gt;
&lt;td&gt;Per-group&lt;/td&gt;
&lt;td&gt;Per-Runner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execution isolation&lt;/td&gt;
&lt;td&gt;Separate machines&lt;/td&gt;
&lt;td&gt;Shared machine&lt;/td&gt;
&lt;td&gt;Separate machines&lt;/td&gt;
&lt;td&gt;Separate machines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orchestration burden&lt;/td&gt;
&lt;td&gt;Duplicated pipelines&lt;/td&gt;
&lt;td&gt;Single pipeline + Vault&lt;/td&gt;
&lt;td&gt;Labels/tags in CI&lt;/td&gt;
&lt;td&gt;Managed by Snap CD Server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency awareness&lt;/td&gt;
&lt;td&gt;None (manual ordering)&lt;/td&gt;
&lt;td&gt;None (manual ordering)&lt;/td&gt;
&lt;td&gt;None (manual ordering)&lt;/td&gt;
&lt;td&gt;Built-in (Module dependencies)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit trail&lt;/td&gt;
&lt;td&gt;CI logs per project&lt;/td&gt;
&lt;td&gt;CI logs + Vault audit&lt;/td&gt;
&lt;td&gt;CI logs per group&lt;/td&gt;
&lt;td&gt;Centralized in Snap CD&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key difference is that Snap CD Runners are not general-purpose CI machines repurposed for Terraform. They're purpose-built for infrastructure deployment, integrated with a system that understands Module dependencies, approval gates, and deployment ordering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with one Runner and split later.&lt;/strong&gt; You don't need per-environment Runners on day one. Start with a single Runner and add more as your isolation requirements become clear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use managed identities where possible.&lt;/strong&gt; A Runner in Azure with a managed identity, or in AWS with an IAM role attached to its instance profile, avoids storing long-lived credentials entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep Runners stateless.&lt;/strong&gt; Don't store Terraform state on the Runner. Use remote backends (which Snap CD manages) so Runners can be replaced without losing state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor Runner connectivity.&lt;/strong&gt; The WebSocket connection is long-lived but not immortal. The Runner reconnects automatically, but monitoring connection status helps you catch network issues before they block deployments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope permissions early.&lt;/strong&gt; It's easier to set up Runner permissions correctly from the start than to tighten them later when teams are already used to a permissive setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — how Runner isolation fits into the broader Module and dependency system&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/secrets-management-terraform" rel="noopener noreferrer"&gt;Managing Secrets in Terraform&lt;/a&gt; — how secrets are scoped and injected per Module&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/permission-system" rel="noopener noreferrer"&gt;A Permission System Built for Infrastructure&lt;/a&gt; — granular RBAC for Stacks, Namespaces, Modules, and Runners&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/large-terraform-states" rel="noopener noreferrer"&gt;The Problem with Large Terraform States&lt;/a&gt; — why splitting states matters, and how Runner isolation supports it&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>terraform</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Splitting a Terraform Monolith into Smaller States</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:39:06 +0000</pubDate>
      <link>https://dev.to/karlschriek/splitting-a-terraform-monolith-into-smaller-states-4b5a</link>
      <guid>https://dev.to/karlschriek/splitting-a-terraform-monolith-into-smaller-states-4b5a</guid>
      <description>&lt;p&gt;If your Terraform plans are slow, your blast radius is too wide, or multiple teams are stepping on each other's changes, it's time to split your monolith. See &lt;a href="https://snapcd.io/Blog/large-terraform-states" rel="noopener noreferrer"&gt;The Problem with Large Terraform States&lt;/a&gt; for how to diagnose whether you've reached that point.&lt;/p&gt;

&lt;p&gt;This guide walks through the process of breaking a monolithic Terraform root into smaller, independent roots — each with its own state — and how to wire the dependencies between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The steps
&lt;/h2&gt;

&lt;p&gt;Splitting a monolith is a seven-step process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parse&lt;/strong&gt; the root into a resource-level reference graph — understand what references what.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Place&lt;/strong&gt; every resource into a target module based on lifecycle and ownership.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute boundaries&lt;/strong&gt; — references crossing module boundaries become &lt;code&gt;variable&lt;/code&gt;/&lt;code&gt;output&lt;/code&gt; pairs; &lt;code&gt;depends_on&lt;/code&gt;-only references become ordering edges without spurious value wiring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for cycles&lt;/strong&gt; — if module A needs an output of module B and B needs an output of A, no valid apply order exists. Catch this before writing any files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emit&lt;/strong&gt; per-module roots — rewrite cross-module references to &lt;code&gt;var.&amp;lt;input&amp;gt;&lt;/code&gt;, generate &lt;code&gt;variables.tf&lt;/code&gt; and &lt;code&gt;outputs.tf&lt;/code&gt;, propagate providers and locals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carve state&lt;/strong&gt; — &lt;code&gt;terraform state mv&lt;/code&gt; over local copies. The monolith state after all moves becomes the remainder module's state. Never touch the live backend during migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove the split&lt;/strong&gt; — walk modules in topological order, thread each producer's extracted outputs into its consumers' inputs, and plan each against its carved state. Zero creates and zero destroys = the split is operationally inert.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The following sections walk through each step in detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating the steps with Demonolith
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/schrieksoft/demonolith" rel="noopener noreferrer"&gt;Demonolith&lt;/a&gt; is a Go CLI that automates all seven steps. You annotate resources with decorator comments indicating which module each belongs to:&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;# @demono:move networking&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;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# @demono:move networking&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_subnet"&lt;/span&gt; &lt;span class="s2"&gt;"private"&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;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.1.0/24"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# @demono:move compute&lt;/span&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;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subnet_ids&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&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="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;Resources without a decorator fall to a configurable remainder module (default: &lt;code&gt;monolith&lt;/code&gt;). Data sources can be decorated with multiple targets — they're stateless reads and get duplicated into each.&lt;/p&gt;

&lt;p&gt;Demonolith handles parsing (via HCL AST traversal), boundary computation, code emission (with &lt;code&gt;hclwrite&lt;/code&gt;-preserved formatting), state carving, and the topologically-threaded proof — in a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Emit carved roots only (code, no state):&lt;/span&gt;
demonolith &lt;span class="nb"&gt;split&lt;/span&gt; ./infra

&lt;span class="c"&gt;# Also carve state into per-module local files:&lt;/span&gt;
demonolith &lt;span class="nb"&gt;split&lt;/span&gt; ./infra &lt;span class="nt"&gt;--state&lt;/span&gt;

&lt;span class="c"&gt;# Carve + prove every module plans to zero create/destroy:&lt;/span&gt;
demonolith &lt;span class="nb"&gt;split&lt;/span&gt; ./infra &lt;span class="nt"&gt;--state&lt;/span&gt; &lt;span class="nt"&gt;--proof&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The carved roots are plain Terraform — valid standalone. The cross-module edges Demonolith computes are exactly the wiring you'd configure in Snap CD via &lt;code&gt;snapcd_module_input_from_output&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going through the steps manually
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Identify natural boundaries
&lt;/h3&gt;

&lt;p&gt;Look at your resources and group them by lifecycle and ownership. Common boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Networking&lt;/strong&gt; — VPCs, subnets, route tables, NAT gateways. Changes rarely, underpins everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS&lt;/strong&gt; — Zones, records. Usually owned by a platform team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute&lt;/strong&gt; — Kubernetes clusters, VM scale sets, container services. Changes more often, depends on networking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application infrastructure&lt;/strong&gt; — Databases, caches, queues, storage accounts. Owned by application teams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring&lt;/strong&gt; — Dashboards, alerts, log sinks. Changes frequently, depends on everything but nothing depends on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful test: if two resources would never be changed in the same PR by the same person, they probably belong in different states.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Map the dependency graph
&lt;/h3&gt;

&lt;p&gt;Before you move anything, build a resource-level reference graph. For every resource, identify what it references — and trace those references across the boundaries you drew in step 1. References that cross a boundary become the &lt;code&gt;variable&lt;/code&gt;/&lt;code&gt;output&lt;/code&gt; pairs you'll need to create.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;networking          dns
    │                 ▲
    ▼                 │
  compute ──────────►─┘
    │
    ▼
application
    │
    ▼
monitoring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The values that cross these boundaries are the wiring surface of the split. Typical examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Networking → Compute: &lt;code&gt;vpc_id&lt;/code&gt;, &lt;code&gt;private_subnet_ids&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Compute → DNS: &lt;code&gt;load_balancer_ip&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Compute → Application: &lt;code&gt;cluster_endpoint&lt;/code&gt;, &lt;code&gt;cluster_ca_certificate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Application → Monitoring: &lt;code&gt;database_id&lt;/code&gt;, &lt;code&gt;cache_name&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check for cycles: if module A needs an output of module B and B needs an output of A, no valid apply order exists. You'll need to break the cycle before proceeding — move one of the cross-referencing resources to the other side, or extract the shared resource into a third module.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Carve the code
&lt;/h3&gt;

&lt;p&gt;For each new root, create a directory and move the assigned resources into it. Three things happen at the boundary:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the producer side&lt;/strong&gt;, expose cross-boundary values as &lt;code&gt;output&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;# networking/outputs.tf&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"private_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="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;On the consumer side&lt;/strong&gt;, declare those values as &lt;code&gt;variable&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;# compute/variables.tf&lt;/span&gt;
&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"private_subnet_ids"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&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;string&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;&lt;strong&gt;In the consumer's resource definitions&lt;/strong&gt;, rewrite the hard references to use the new variable:&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;# Before (monolith) — direct reference&lt;/span&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;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subnet_ids&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&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# After (split) — variable reference&lt;/span&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;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subnet_ids&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;private_subnet_ids&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;Don't forget structural blocks: &lt;code&gt;provider&lt;/code&gt; configurations, &lt;code&gt;locals&lt;/code&gt;, and root &lt;code&gt;variable&lt;/code&gt; declarations need to be carried into every module that uses them. A &lt;code&gt;depends_on&lt;/code&gt; that pointed at a resource now in another root should be removed — the ordering dependency is carried by the input/output wiring instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Carve the state
&lt;/h3&gt;

&lt;p&gt;Terraform's &lt;code&gt;state mv&lt;/code&gt; command lets you move resources from one state to another without destroying and recreating them. Work on local copies of the state — never against the live backend during the migration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pull the monolith state to a local file&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;monolith
terraform state pull &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; terraform.tfstate

&lt;span class="c"&gt;# Move resources to the new root's state&lt;/span&gt;
terraform state &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;../monolith/terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-state-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  aws_vpc.main aws_vpc.main

terraform state &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;../monolith/terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-state-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./terraform.tfstate &lt;span class="se"&gt;\&lt;/span&gt;
  aws_subnet.private aws_subnet.private
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The monolith state file, after all moves are complete, becomes the remainder module's state — it contains exactly the resources that weren't moved out.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Verify the split
&lt;/h3&gt;

&lt;p&gt;After carving code and state, every new root must plan to zero changes. This is the proof that the split is operationally inert — nothing will be destroyed or recreated.&lt;/p&gt;

&lt;p&gt;The catch: a carved module planned in isolation has its upstream-sourced variables unset, because the input/output wiring doesn't exist yet at the Terraform level. You need to supply those values manually for the verification plan. Walk the modules in topological order: plan each producer first, extract its output values, and feed them as &lt;code&gt;-var&lt;/code&gt; arguments into the consumer's plan.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Plan the producer (no upstream dependencies)&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;networking
terraform plan

&lt;span class="c"&gt;# Extract outputs&lt;/span&gt;
terraform output &lt;span class="nt"&gt;-json&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ../outputs/networking.json

&lt;span class="c"&gt;# Plan the consumer with the producer's outputs&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../compute
terraform plan &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"vpc_id=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.vpc_id.value'&lt;/span&gt; ../outputs/networking.json&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"private_subnet_ids=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'.private_subnet_ids.value'&lt;/span&gt; ../outputs/networking.json&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any module shows creates or destroys, something went wrong — a resource was missed in the state move, a reference was rewritten incorrectly, or a variable type doesn't match.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Wire up the cross-state dependencies
&lt;/h3&gt;

&lt;p&gt;Once the split is verified, you need a runtime mechanism to pass outputs from producers to consumers on every deploy. There are several options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: &lt;code&gt;terraform_remote_state&lt;/code&gt; data sources&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The built-in approach. Each consuming module reads the producer's state directly:&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;data&lt;/span&gt; &lt;span class="s2"&gt;"terraform_remote_state"&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-terraform-state"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-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_eks_cluster"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subnet_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;terraform_remote_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&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;This works but has significant drawbacks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every consumer needs to know the backend configuration of every producer.&lt;/li&gt;
&lt;li&gt;There's no enforcement of the dependency order — you have to manually ensure networking is applied before compute.&lt;/li&gt;
&lt;li&gt;Changes to networking outputs don't automatically trigger a re-plan of compute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Option B: Wrapper scripts and CI glue&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You write shell scripts or CI pipeline steps that run &lt;code&gt;terraform output&lt;/code&gt; on one state and feed the values into &lt;code&gt;terraform apply -var&lt;/code&gt; on the next. This is what most teams end up doing, and it's fragile — the dependency graph lives in CI config rather than in code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C: Terragrunt&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Terragrunt adds a dependency layer on top of Terraform:&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;# compute/terragrunt.hcl&lt;/span&gt;
&lt;span class="nx"&gt;dependency&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;config_path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../networking"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;inputs&lt;/span&gt; &lt;span class="err"&gt;=&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;dependency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
  &lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dependency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a genuine improvement — dependencies are declared in code, ordering is enforced, and &lt;code&gt;terragrunt run-all apply&lt;/code&gt; handles the graph. But Terragrunt is a local CLI tool. It doesn't provide a persistent view of deployment status, approval gates, automatic re-deployment when upstream outputs change, or scoped permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option D: Snap CD&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://snapcd.io" rel="noopener noreferrer"&gt;Snap CD&lt;/a&gt; was built for this problem. Each split becomes a Snap CD &lt;a href="https://docs.snapcd.io/resources/stack-namespace-module/" rel="noopener noreferrer"&gt;Module&lt;/a&gt;, and cross-state dependencies are declared as code using the &lt;a href="https://registry.terraform.io/providers/schrieksoft/snapcd/latest/docs" rel="noopener noreferrer"&gt;Terraform Provider for Snap CD&lt;/a&gt;. Snap CD enforces apply ordering, runs independent Modules in parallel, and automatically cascades changes when upstream Outputs change. The cross-module edges from the split — every &lt;code&gt;output&lt;/code&gt;/&lt;code&gt;variable&lt;/code&gt; pair — map directly to &lt;code&gt;snapcd_module_input_from_output&lt;/code&gt; resources. See &lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; for a detailed walkthrough of how the Module and &lt;a href="https://docs.snapcd.io/resources/module-inputs/" rel="noopener noreferrer"&gt;Input&lt;/a&gt; system works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split incrementally.&lt;/strong&gt; Move one logical group at a time. Don't try to split everything in one go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with the layer that changes least.&lt;/strong&gt; Networking is usually the best first candidate — it has many dependents but few dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep shared modules small.&lt;/strong&gt; If a Terraform module (in the &lt;code&gt;module {}&lt;/code&gt; sense) is used by multiple states, keep it focused. A module that provisions "everything for an app" is just a monolith in disguise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with &lt;code&gt;terraform plan&lt;/code&gt; after every move.&lt;/strong&gt; A clean plan (no changes) on both the source and destination states confirms the migration was correct.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/large-terraform-states" rel="noopener noreferrer"&gt;The Problem with Large Terraform States&lt;/a&gt; — diagnosing when it's time to split&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — how Snap CD manages cross-state dependencies after the split&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/supporting-toolset" rel="noopener noreferrer"&gt;An Extensive Supporting Toolset&lt;/a&gt; — Demonolith and other tools in the Snap CD ecosystem&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — scoping credentials per environment with dedicated Runners&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/secrets-management-terraform" rel="noopener noreferrer"&gt;Managing Secrets in Terraform&lt;/a&gt; — keeping secrets scoped when splitting states&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>terraform</category>
      <category>cloud</category>
      <category>cicd</category>
      <category>infrastructureascode</category>
    </item>
    <item>
      <title>The Problem with Large Terraform States</title>
      <dc:creator>Karl Schriek</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:35:47 +0000</pubDate>
      <link>https://dev.to/karlschriek/the-problem-with-large-terraform-states-a2o</link>
      <guid>https://dev.to/karlschriek/the-problem-with-large-terraform-states-a2o</guid>
      <description>&lt;p&gt;At some point every growing Terraform project hits a wall. Plans that used to finish in seconds now take minutes. Applies feel risky because hundreds of resources share a single blast radius. Colleagues avoid running &lt;code&gt;terraform plan&lt;/code&gt; because it hammers cloud APIs hard enough to trigger throttling. The state file itself becomes a liability — large, slow to lock, and one bad write away from corruption.&lt;/p&gt;

&lt;p&gt;This guide covers the symptoms of an oversized state, the band-aids teams reach for, and the structural fix that actually works.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Terraform state works under the hood
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;terraform plan&lt;/code&gt; does two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Refresh&lt;/strong&gt; — for every resource in state, Terraform calls the provider's API to read the current real-world status. A state with 500 resources means 500+ API calls, often more when resources have nested data sources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff&lt;/strong&gt; — compare the refreshed state against the desired configuration and produce a change set.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The refresh phase is the bottleneck. It's sequential per provider (parallelism helps across providers, not within one), and every resource pays the cost whether you changed it or not. Adding ten resources to a 500-resource state doesn't make plans 2% slower — it makes the refresh 2% slower on every single plan, for every engineer, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptoms of a state that's too large
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Slow plans
&lt;/h3&gt;

&lt;p&gt;The most visible symptom. Plan time scales with resource count because every resource is refreshed on every plan, regardless of whether its configuration changed. The exact speed depends on provider — AWS resources with complex nested structures (IAM policies, security group rules) are slower to refresh than simple ones, and Azure resources that require multiple API calls per refresh are worse still. These aren't edge cases — users regularly report &lt;a href="https://github.com/hashicorp/terraform/issues/18981" rel="noopener noreferrer"&gt;2,900-resource states taking 20–25 minutes to plan&lt;/a&gt; and &lt;a href="https://github.com/hashicorp/terraform/issues/16375" rel="noopener noreferrer"&gt;1,600-resource states taking 8+ minutes&lt;/a&gt;. Even starting Terraform with a large state &lt;a href="https://github.com/hashicorp/terraform/issues/35822" rel="noopener noreferrer"&gt;can take minutes before a single API call is made&lt;/a&gt;. There's a &lt;a href="https://github.com/hashicorp/terraform/issues/35290" rel="noopener noreferrer"&gt;long-standing proposal for &lt;code&gt;terraform plan -light&lt;/code&gt;&lt;/a&gt; that would only refresh resources whose configuration changed, but it remains unimplemented. OpenTofu has a similar request to &lt;a href="https://github.com/opentofu/opentofu/issues/1703" rel="noopener noreferrer"&gt;skip refreshing unchanged resources&lt;/a&gt; and a proposal for &lt;a href="https://github.com/opentofu/opentofu/issues/2083" rel="noopener noreferrer"&gt;state compression&lt;/a&gt; to reduce the overhead of large state files.&lt;/p&gt;

&lt;h3&gt;
  
  
  API rate limiting
&lt;/h3&gt;

&lt;p&gt;Cloud providers throttle API calls. When Terraform refreshes hundreds of resources, it can exhaust rate limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS&lt;/strong&gt;: &lt;code&gt;ThrottlingException&lt;/code&gt; or &lt;code&gt;Rate exceeded&lt;/code&gt; errors, especially on IAM, EC2 describe calls, and CloudFormation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure&lt;/strong&gt;: &lt;code&gt;429 Too Many Requests&lt;/code&gt;, particularly on Resource Manager and Key Vault APIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GCP&lt;/strong&gt;: &lt;code&gt;rateLimitExceeded&lt;/code&gt; on Compute Engine and IAM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Terraform retries on throttling, which makes plans even slower. In severe cases, retries exhaust their budget and the plan fails entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blast radius
&lt;/h3&gt;

&lt;p&gt;Every resource in a state shares a blast radius. A typo in a DNS record can, in the same plan, sit alongside a database resize. One bad &lt;code&gt;terraform apply&lt;/code&gt; can damage resources the operator didn't intend to touch.&lt;/p&gt;

&lt;p&gt;This isn't theoretical. Common incidents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;for_each&lt;/code&gt; key change causes Terraform to destroy and recreate resources it shouldn't.&lt;/li&gt;
&lt;li&gt;A provider upgrade changes how a resource is read, causing phantom diffs on dozens of resources.&lt;/li&gt;
&lt;li&gt;An engineer runs &lt;code&gt;terraform apply&lt;/code&gt; on a plan that's stale — someone else merged a change to a different resource in the same state, and the apply picks up both.&lt;/li&gt;
&lt;li&gt;A third-party API is down or throttling, so the refresh fails for a resource you weren't even changing — blocking the entire plan. With a smaller state, that resource would be in a different state file and wouldn't affect your work at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With smaller states, each of these incidents affects only the resources in that state. With a monolith, everything is in play.&lt;/p&gt;

&lt;h3&gt;
  
  
  Locking contention
&lt;/h3&gt;

&lt;p&gt;Remote state backends use locking to prevent concurrent writes. The longer a plan or apply takes, the longer the lock is held. With a 10-minute plan, other engineers are blocked for 10 minutes. If an apply follows, that's another stretch of locked state.&lt;/p&gt;

&lt;p&gt;Teams start working around locks — using &lt;code&gt;-lock=false&lt;/code&gt; (dangerous), splitting work by time of day (inefficient), or simply waiting. &lt;a href="https://github.com/hashicorp/terraform/issues/33032" rel="noopener noreferrer"&gt;Concurrent updates to large state files are also significantly slower&lt;/a&gt; because each write serialises the entire state. None of these are real solutions.&lt;/p&gt;

&lt;h3&gt;
  
  
  State file size and corruption risk
&lt;/h3&gt;

&lt;p&gt;State files grow linearly with resource count. A 1,000-resource state file can be several megabytes of JSON. Every plan downloads the full state, and every apply uploads a new version. On slow connections or with large states, this adds latency.&lt;/p&gt;

&lt;p&gt;More critically, large state files are harder to recover from corruption. If a write is interrupted (network failure during apply, process killed), the state can become inconsistent. With a small state, recovery is straightforward — reimport a handful of resources. With a monolith, you're reimporting hundreds. Large state files also compound the secrets problem — Terraform &lt;a href="https://github.com/hashicorp/terraform/issues/9556" rel="noopener noreferrer"&gt;stores sensitive values in plaintext in state&lt;/a&gt;, so a bigger state means more secrets exposed in a single file. OpenTofu &lt;a href="https://github.com/opentofu/opentofu/issues/297" rel="noopener noreferrer"&gt;implemented state encryption&lt;/a&gt;, but Terraform's proposal has been open since 2016.&lt;/p&gt;

&lt;h2&gt;
  
  
  Band-aids that don't fix the problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;terraform plan -target&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;-target&lt;/code&gt; flag tells Terraform to only refresh and plan specific resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_instance.web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes individual plans fast, but it's a trap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You must know which resources to target. Miss a dependency and the plan is incomplete.&lt;/li&gt;
&lt;li&gt;Targeted plans skip dependency checking. You can apply a change that breaks a resource you didn't target.&lt;/li&gt;
&lt;li&gt;It's manual and error-prone. There's no guardrail preventing someone from running a full plan and waiting 15 minutes.&lt;/li&gt;
&lt;li&gt;Terraform itself warns: "Resource targeting is intended for exceptional use and should not be part of normal workflow."&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;terraform plan -refresh=false&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Skipping refresh makes plans fast because Terraform uses the last-known state instead of querying APIs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan &lt;span class="nt"&gt;-refresh&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is obvious: if the real world has drifted from state, the plan is wrong. An engineer deleted a resource manually, someone changed a security group in the console, a colleague applied from a different branch — none of this shows up. You're planning against fiction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workspaces
&lt;/h3&gt;

&lt;p&gt;Terraform workspaces let you maintain multiple state files from the same configuration. They're designed for deploying the same infrastructure to different environments (dev, staging, prod), not for splitting a large state into smaller pieces.&lt;/p&gt;

&lt;p&gt;Workspaces don't reduce the number of resources per state. If your monolith has 500 resources, each workspace still has 500 resources. They solve a different problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;terraform state rm&lt;/code&gt; and manual state surgery
&lt;/h3&gt;

&lt;p&gt;When a single resource is causing problems, engineers sometimes remove it from state and reimport it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform state &lt;span class="nb"&gt;rm &lt;/span&gt;aws_instance.problematic
terraform import aws_instance.problematic i-0123456789abcdef0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a valid recovery technique but not a scaling strategy. It's manual, risky (removing the wrong resource is destructive), and doesn't address the underlying size problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real fix: smaller states
&lt;/h2&gt;

&lt;p&gt;The only way to permanently fix a large state is to break it into smaller ones. Each state contains a logical group of resources — networking, compute, databases, monitoring — with its own lifecycle, credentials, and blast radius. If your state spans multiple cloud providers, splitting along provider boundaries is one of the most effective first moves. Each provider has its own API rate limits, its own authentication, and its own failure modes — an Azure outage shouldn't block a plan that only touches AWS resources. Separate states per provider also let you scope credentials more tightly and parallelise plans that would otherwise run sequentially through a single refresh cycle.&lt;/p&gt;

&lt;p&gt;The hard part isn't the split itself — it's managing the dependencies between the resulting states. Networking outputs need to flow into compute. Compute outputs need to flow into application infrastructure. Changes to one state need to trigger re-plans in dependent states. &lt;a href="https://snapcd.io" rel="noopener noreferrer"&gt;Snap CD&lt;/a&gt; was built for exactly this workflow — it tracks cross-state dependencies declaratively and cascades changes automatically, so you get the benefits of smaller states without the coordination overhead. For a discussion of approaches to breaking a monolith into smaller states, see &lt;a href="https://snapcd.io/Blog/splitting-terraform-monolith" rel="noopener noreferrer"&gt;Splitting a Terraform Monolith&lt;/a&gt;. To learn more about how Snap CD approaches modular deployments, see &lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell when it's time
&lt;/h2&gt;

&lt;p&gt;There's no universal threshold, but if any of these are true, you should start planning a split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;terraform plan&lt;/code&gt; consistently takes more than a few minutes.&lt;/li&gt;
&lt;li&gt;More than one team commits to the same Terraform root module.&lt;/li&gt;
&lt;li&gt;You've had an incident where an apply affected resources the operator didn't intend to change.&lt;/li&gt;
&lt;li&gt;Applies are failing due to issues with unrelated resources in the same state.&lt;/li&gt;
&lt;li&gt;Your state spans multiple cloud providers, and an outage or rate limit on one provider blocks plans for resources on another.&lt;/li&gt;
&lt;li&gt;Engineers routinely use &lt;code&gt;-target&lt;/code&gt; or &lt;code&gt;-refresh=false&lt;/code&gt; to work around slowness.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with the layer that changes least (usually networking) and work outward. The &lt;a href="https://snapcd.io/Blog/splitting-terraform-monolith" rel="noopener noreferrer"&gt;Splitting a Terraform Monolith&lt;/a&gt; guide has the step-by-step process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split by cloud provider early.&lt;/strong&gt; If your state has resources across AWS, Azure, and GCP, separating them into per-provider states is one of the highest-value splits. Each provider has independent rate limits, authentication, and failure modes — keeping them together means a slow Azure API refresh delays your AWS plan for no reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for provider-specific bottlenecks.&lt;/strong&gt; Even within a single cloud, some resource types are slower than others. If most of your plan time is AWS IAM resources, splitting out IAM alone might cut plan time dramatically. This is also a prerequisite if you are serious about not mixing credentials on the workers that are responsible for the deployments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't over-split.&lt;/strong&gt; Five resources that always change together, owned by the same team, with the same credentials, should stay in one state. The goal is fast plans and small blast radius, not one resource per state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;-parallelism&lt;/code&gt; wisely.&lt;/strong&gt; Terraform's &lt;code&gt;-parallelism&lt;/code&gt; flag (default 10) controls concurrent provider operations. Increasing it can speed up plans but also increases the risk of hitting API rate limits. With smaller states, the default is usually fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/splitting-terraform-monolith" rel="noopener noreferrer"&gt;Splitting a Terraform Monolith&lt;/a&gt; — approaches to breaking a monolith into smaller states&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/modular-deployments" rel="noopener noreferrer"&gt;Modular Deployments&lt;/a&gt; — how Snap CD manages cross-state dependencies after the split&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/runner-isolation" rel="noopener noreferrer"&gt;Self-Hosted Terraform Runners with Credential Isolation&lt;/a&gt; — scoping credentials per environment with dedicated Runners&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snapcd.io/Blog/secrets-management-terraform" rel="noopener noreferrer"&gt;Managing Secrets in Terraform&lt;/a&gt; — why smaller states reduce secret exposure&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>terraform</category>
      <category>cicd</category>
      <category>infrastructureascode</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
