Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🟧 AWS CloudFormation Terraform Module

Nested-IaC escape hatch for Terraform-driven AWS CloudFormation — wraps a single-account aws_cloudformation_stack, or a multi-account/multi-Region aws_cloudformation_stack_set fanned out via for_each-friendly per-instance or single-resource bulk instances, with a secure-by-default stack policy and a mandatory, auditable execution role. Built for the AWS provider v6.x.

Terraform aws module type resources


🧩 Overview

  • 🧱 One module, two mutually exclusive deployment pathsvar.deployment_scope selects a single-account stack (aws_cloudformation_stack.this) or a multi-account/multi-Region StackSet (aws_cloudformation_stack_set.this); one module call renders exactly one path, never both.
  • 🌐 Two StackSet fan-out styles, caller's choicevar.stack_instance_mode picks per_instance (aws_cloudformation_stack_set_instance, for_each-friendly, one resource per account/OU + Region target) or bulk (aws_cloudformation_stack_instances, a single resource covering every target).
  • 🔒 Secure-by-default stack policy — when the caller supplies no policy_body/policy_url, the module renders a restrictive default denying Update:Delete/Update:Replace on stateful/critical resource types (RDS, DynamoDB, S3, KMS), leaving everything else updatable.
  • 🪪 iam_role_arn is required, with no null fallback — every single stack runs under an explicit, auditable execution role rather than the caller's own ambient session credentials.
  • 🚧 Capabilities are empty ([]) by default — a template that creates IAM resources or uses macros/transforms fails to apply until the caller explicitly acknowledges CAPABILITY_IAM / CAPABILITY_NAMED_IAM / CAPABILITY_AUTO_EXPAND.
  • 🔁 Rollback-on-failure by defaulton_failure = "ROLLBACK"; disable_rollback and on_failure are rendered mutually exclusively so the generated configuration never presents both to the provider.
  • 🏷️ Tags flow to both keystonesvar.tags reaches aws_cloudformation_stack and aws_cloudformation_stack_set (which also propagates tags into the stacks it deploys); the two instance resources are not taggable.
  • ⚠️ This is an explicit exception pattern, not the default authoring path. Every other module in this library wraps a native aws_* resource; this module exists only for genuine gaps — an AWS launch not yet in the provider, a vendor/Quick Start CloudFormation template, or an AWS Service Catalog product.

💡 Why it matters: Some AWS capabilities only ship as a CloudFormation template — a vendor Quick Start, a Service Catalog product, or a brand-new launch the hashicorp/aws provider hasn't caught up to yet. This module lets that template stay inside the same Terraform plan/apply/state workflow as everything else in the estate, instead of forcing a manual aws cloudformation deploy side-channel with no plan diff and no state tracking.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

terraform-aws-cloudformation is an intentional dead end in the dependency graph. It consumes an execution/administration role by reference from terraform-aws-iam-role and, optionally, a template object from terraform-aws-s3-bucket or a notification topic from terraform-aws-sns (Phase 2) — but no other module in this library wires into CloudFormation's outputs. That is deliberate: this module is a documented escape hatch (see SCOPE.md "Design decisions"), not a foundation other modules are expected to build on. If a future native aws_* resource covers what a wrapped template does today, retire the CloudFormation call site in favor of that resource rather than growing more consumers of this module's outputs.

flowchart LR
 iam["terraform-aws-iam-role"]
 s3["terraform-aws-s3-bucket"]
 sns["terraform-aws-sns (Phase 2)"]
 cfn["terraform-aws-cloudformation"]

 iam -- "iam_role_arn (single stack)" --> cfn
 iam -- "administration_role_arn (StackSet, self-managed)" --> cfn
 iam -. "execution_role_name: name only, deployed out-of-band per target account".-> cfn
 s3 -- "template_url" --> cfn
 sns -. "notification_arns (single stack)".-> cfn

 style cfn fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
Loading

ℹ️ No arrow leaves terraform-aws-cloudformation toward any sibling module. Its outputs map (the wrapped template's CloudFormation Outputs section) is a valid integration point for a downstream consumer in principle, but as of this module's v1.0.0 nothing in the Phase 1–7 catalog is wired to consume it — every genuine cross-service dependency in this library is expected to be a native aws_* module instead.


🧬 What this module builds

flowchart TB
 start["var.deployment_scope"]
 stack["aws_cloudformation_stack.this
(keystone — single account/Region)"]
 ss["aws_cloudformation_stack_set.this
(keystone — StackSet definition)"]
 mode["var.stack_instance_mode"]
 per["aws_cloudformation_stack_set_instance.this
for_each var.stack_instances
one resource per account/OU + Region"]
 bulk["aws_cloudformation_stack_instances.this
single resource
var.stack_instances_bulk"]

 start -- "stack (default)" --> stack
 start -- "stack_set" --> ss
 ss --> mode
 mode -- "per_instance (default)" --> per
 mode -- "bulk" --> bulk

 style stack fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
 style ss fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
Loading
Resource Role Cardinality
aws_cloudformation_stack.this Keystone — single-account, single-Region stack 0–1, gated by for_each on deployment_scope == "stack"
aws_cloudformation_stack_set.this Keystone — StackSet definition (template + permission model) 0–1, gated by for_each on deployment_scope == "stack_set"
aws_cloudformation_stack_set_instance.this Fine-grained stack instance, one per target 0–N, gated by stack_instance_mode == "per_instance", one per stack_instances entry
aws_cloudformation_stack_instances.this Bulk stack instances, all targets in one resource 0–1, gated by stack_instance_mode == "bulk"

Every resource is rendered via for_each over a conditional map (a { this = true } / {} pattern for the two keystones, consistent with terraform-aws-vpc's aws_internet_gateway.this) rather than count — no two full copies of unrelated logic, and main.tf stays a thin renderer over var.deployment_scope / var.stack_instance_mode.


✅ Provider / Versions

Requirement Version
Terraform >= 1.12.0
hashicorp/aws >= 6.0, < 7.0

No provider {} block is declared inside the module — the caller's configured provider (region/credentials) is inherited. No region variable either: CloudFormation is a regional service with no us-east-1 global-resource coupling of its own (any such coupling — e.g. a wrapped template that itself provisions CloudFront/ACM — lives inside the template, outside this module's visibility).


🔑 Required IAM Permissions

Split deliberately into three parts — call these out separately in code review, because the second and third are the ones most commonly under-scoped.

(A) CloudFormation control plane — actions the Terraform-executing identity needs:

Action Required for
cloudformation:CreateStack, cloudformation:UpdateStack, cloudformation:DeleteStack, cloudformation:DescribeStacks, cloudformation:DescribeStackEvents, cloudformation:GetTemplate, cloudformation:ValidateTemplate Single-stack lifecycle (aws_cloudformation_stack)
cloudformation:SetStackPolicy, cloudformation:GetStackPolicy Secure-by-default stack policy rendering and drift detection
cloudformation:CreateStackSet, cloudformation:UpdateStackSet, cloudformation:DeleteStackSet, cloudformation:DescribeStackSet, cloudformation:ListStackSets StackSet lifecycle
cloudformation:CreateStackInstances, cloudformation:UpdateStackInstances, cloudformation:DeleteStackInstances, cloudformation:DescribeStackInstance, cloudformation:ListStackInstances Both aws_cloudformation_stack_set_instance and aws_cloudformation_stack_instances
cloudformation:DescribeStackSetOperation, cloudformation:ListStackSetOperations, cloudformation:StopStackSetOperation Polling/managing long-running StackSet operations
cloudformation:TagResource, cloudformation:UntagResource Tag propagation to the stack/StackSet
organizations:ListDelegatedAdministrators Only when call_as = "DELEGATED_ADMIN" — omitting this produces ValidationError: Account used is not a delegated administrator
organizations:DescribeOrganization, organizations:ListRoots, organizations:ListAccountsForParent Only when targeting deployment_targets.organizational_unit_ids (service-managed)

(B) iam:PassRole — mandatory, and the single most commonly missed grant:

Action Required for Notes
iam:PassRole scoped to var.iam_role_arn aws_cloudformation_stack.iam_role_arn — lets CloudFormation assume the role to create/update/delete the stack's resources Without this the stack silently falls back to the caller's own (often over-privileged, always un-auditable) session credentials — always set iam_role_arn explicitly.
iam:PassRole scoped to var.administration_role_arn aws_cloudformation_stack_set.administration_role_arn (self-managed only) The Terraform identity must be able to pass the administration role to cloudformation.amazonaws.com; that role in turn must have sts:AssumeRole on arn:aws:iam::*:role/<execution_role_name> in every target account
(no PassRole) permission_model = "SERVICE_MANAGED" Uses AWS-Organizations-owned service-linked roles, not caller-supplied roles — see the comparison table below

⚠️ PassRole must be scoped to the specific role ARN(s), never resource = "*". An unscoped iam:PassRole on the Terraform identity lets any caller with stack-create rights pass an arbitrary, potentially highly-privileged role to CloudFormation — a well-known CloudFormation privilege-escalation path. Condition the grant on iam:PassedToService = "cloudformation.amazonaws.com" where the pipeline's policy language supports it.

(C) The transitive-permissions gotcha — read before granting anything.

The permissions in (A) and (B) belong to the Terraform-executing identity. They are not the permissions that actually provision the resources inside the wrapped template — those are exercised by whichever role executes the stack:

  • Single stack: the named iam_role_arn execution role needs every Create/Update/Delete/Describe permission for every resource type the template declares (a template creating AWS::RDS::DBInstance needs rds:CreateDBInstance etc. on that role, not on the Terraform identity).
  • StackSet execution role (self-managed): AWS's own sample minimum execution-role policy is cloudformation:*, s3:*, sns:*, Resource: * — AWS does not attempt to scope this down, because the role must handle any template ever fanned out through the StackSet.

🚫 Do not copy that AWS sample execution-role policy verbatim in a regulated environment. Scope the execution role to the actual resource types your templates create, least-privilege, and review the policy every time a new template is onboarded through this module.

  • This module cannot statically know the wrapped template's resource footprinttemplate_body/template_url are opaque strings to it — so it cannot enforce least-privilege on the execution role for you. Treat that scoping review as a mandatory human step before every new template goes through this module, and document the reviewed resource footprint next to the template source (see the finale example in the Example Library below for the pattern).

📋 AWS Prerequisites

  • No service-linked role is auto-created by these resources. Both permission models below require role infrastructure to exist before this module runs.
  • Self-managed vs. service-managed permission models differ materially — verify which one a caller intends before wiring variables:
Self-managed (permission_model = "SELF_MANAGED", default) Service-managed (permission_model = "SERVICE_MANAGED")
Trust setup Caller manually creates AWSCloudFormationStackSetAdministrationRole in the admin account and AWSCloudFormationStackSetExecutionRole (or a custom-named equivalent) in every target account, with a manual trust relationship between them AWS Organizations trusted access must be activated for CloudFormation StackSets; AWS creates and manages the roles automatically in every member account
Required module input administration_role_arn (required for this mode) + execution_role_name (defaults to AWSCloudFormationStackSetExecutionRole if unset) Neither administration_role_arn nor execution_role_name may be set — the module's own variable validation blocks reject either being set in this mode
Targeting Individual account_ids only Individual accounts or deployment_targets.organizational_unit_ids (can target an entire OU, including future accounts added to it)
auto_deployment block Not applicable (rejected by variable validation) Optional — auto-deploys to new accounts joining a targeted OU
Delegated admin (call_as = "DELEGATED_ADMIN") Not applicable Supported once the account is registered as a delegated administrator; requires organizations:ListDelegatedAdministrators on the caller or you hit ValidationError: Account used is not a delegated administrator
Best fit Small, fixed account lists outside (or predating) AWS Organizations Org-wide guardrail/baseline rollout across a whole OU, including future accounts
  • Region constraints: none unique to CloudFormation itself, but a StackSet's operation_preferences.region_order / region_concurrency_type govern multi-Region fan-out order — sequence regions deliberately for templates with cross-Region dependencies.
  • Quotas (per the AWS CloudFormation User Guide "Understand CloudFormation quotas" page, verify current per-account values via Service Quotas before relying on any of these for capacity planning):
  • 2,000 stacks per account per Region (adjustable).
  • 1,000 StackSets per administrator account per Region (adjustable).
  • 100,000 stack instances per StackSet (a materially higher ceiling than the 100–2,000 range quoted in older CloudFormation guidance — AWS has raised this over time; still confirm your account's current value before a large OU-wide rollout).
  • 10,000 concurrent stack-instance operations per Region per administrator account, and 10,000 queued StackSet operations.
  • Template size: 51,200 bytes inline (template_body, per the hashicorp/aws provider's own resource docs for both aws_cloudformation_stack and aws_cloudformation_stack_set) / 460,800 bytes via template_url per the same provider resource docs.

ℹ️ The current AWS CloudFormation quotas page separately states a 1 MB limit for "Template body size in an Amazon S3 object," which is higher than the provider-documented 460,800-byte template_url figure above. This module documents the provider-schema-validated number (460,800 bytes) since that is what Terraform will accept without a provider-side error; treat the AWS quotas page's larger number as the account-level ceiling AWS itself enforces, and verify the smaller of the two before assuming a large template will apply cleanly through this module.

  • 500 resources and 200 parameters per template — split large templates into nested stacks if you hit either.
  • CAPABILITY_IAM / CAPABILITY_NAMED_IAM / CAPABILITY_AUTO_EXPAND acknowledgement: any wrapped template that creates IAM resources or uses macros/transforms fails to apply unless the corresponding capability is explicitly listed in capabilities — CloudFormation's own guardrail against a template silently creating IAM principals; this module surfaces it as a variable rather than defaulting it broad.

📁 Module Structure

terraform-aws-cloudformation/
├── providers.tf # terraform{} + required_providers (aws >= 6.0, < 7.0); no provider block
├── variables.tf # name, deployment_scope, stack_instance_mode, template source, single-stack + StackSet config, instance targeting, tags, timeouts
├── main.tf # locals (gating + default stack policy) + the four for_each-gated resources
├── outputs.tf # id + arn (documented asymmetry), name, outputs, stack_set_id/arn, stack_instance_ids, tags_all
├── README.md # this file
└── SCOPE.md # in/out-of-scope, IAM, prerequisites, emits, gotchas, design decisions

⚙️ Quick Start

module "cfn_exec_role" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
  name   = "casey-cfn-sample-exec"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "cloudformation.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  # Scoped to the template's ACTUAL resource footprint — never the AWS sample
  # cloudformation:*/s3:*/sns:* Resource:* execution policy. See Required IAM
  # Permissions (C) above.
  inline_policies = {
    scoped-sqs = {
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Effect   = "Allow"
          Action   = ["sqs:CreateQueue", "sqs:DeleteQueue", "sqs:GetQueueAttributes", "sqs:SetQueueAttributes", "sqs:TagQueue"]
          Resource = "*"
        }]
      })
    }
  }
}

module "sample_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-sample-queue"
  iam_role_arn = module.cfn_exec_role.arn # terraform-aws-iam-role — REQUIRED, no null fallback

  template_body = jsonencode({
    AWSTemplateFormatVersion = "2010-09-09"
    Resources = {
      SampleQueue = { Type = "AWS::SQS::Queue" }
    }
  })

  tags = {
    Environment = "prod"
  }
}

⚠️ Pin the source with ?ref=v1.0.0 — never a branch. iam_role_arn has no default; the module will fail terraform plan until you wire it.


🔌 Cross-Module Contract

Consumes

Input Type Source module
iam_role_arn (single stack) string (ARN) terraform-aws-iam-role
administration_role_arn string (ARN) terraform-aws-iam-role (self-managed StackSets only)
execution_role_name (StackSet, self-managed) string (role name, not ARN — must pre-exist identically in every target account) terraform-aws-iam-role, deployed once per target account, out of band from this module
template_url string (S3 URL) terraform-aws-s3-bucket
notification_arns list(string) (ARNs) terraform-aws-sns (Phase 2)

Emits

Output Description Consumed by
id Stack name or StackSet name, whichever keystone is active Reference/import
arn Populated only for the StackSet pathaws_cloudformation_stack has no arn attribute at all Cross-account audit references, IAM policy conditions scoped to a StackSet — never available for a plain stack
outputs Map of the wrapped template's CloudFormation Outputs section (stack path only) A downstream consumer needing a value the template produced that has no native Terraform resource — none in this catalog as of v1.0.0
stack_set_id / stack_set_arn StackSet identifier and ARN (StackSet path only) Cross-account audit references
stack_instance_ids / stack_instance_ids_by_key Per-target instance identifiers Audit / per-target drift tracking
tags_all Computed tag merge incl. provider default_tags, whichever keystone is active Governance / audit

This module is a leaf — see "Where this fits in the family" above. Nothing else in the Phase 1–7 catalog currently consumes these outputs; they exist for the caller's own downstream wiring (a data source, another root module, or a manual reference), not for a sibling terraform-aws-* module.


📚 Example Library

1 · Minimal single-account stack (execution role wired from terraform-aws-iam-role)
module "cfn_exec_role" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
  name   = "casey-cfn-sqs-exec"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "cloudformation.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  inline_policies = {
    scoped-sqs = {
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Effect   = "Allow"
          Action   = ["sqs:CreateQueue", "sqs:DeleteQueue", "sqs:GetQueueAttributes", "sqs:SetQueueAttributes", "sqs:TagQueue"]
          Resource = "*"
        }]
      })
    }
  }
}

module "sample_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-sample-queue"
  iam_role_arn = module.cfn_exec_role.arn

  template_body = jsonencode({
    AWSTemplateFormatVersion = "2010-09-09"
    Resources = {
      SampleQueue = { Type = "AWS::SQS::Queue" }
    }
  })
}
2 · Customer-supplied policy_body (fully overrides the secure default)
module "network_stack_custom_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-vpn-gateway-stack"
  iam_role_arn = module.cfn_exec_role.arn
  template_url = "https://casey-templates.s3.amazonaws.com/network/vpn-gateway.yaml"

  # Setting policy_body always wins over the module's own default — the
  # caller takes full ownership of update protection for this stack.
  policy_body = jsonencode({
    Statement = [
      {
        Effect    = "Deny"
        Action    = "Update:*"
        Principal = "*"
        Resource  = "LogicalResourceId/VpnGateway"
      },
      {
        Effect    = "Allow"
        Action    = "Update:*"
        Principal = "*"
        Resource  = "*"
      },
    ]
  })
}
3 · Secure-by-default opt-out — enable_default_stack_policy = false (documented exception)
module "sandbox_ci_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-sandbox-ci-stack"
  iam_role_arn = module.cfn_exec_role.arn
  template_body = jsonencode({
    AWSTemplateFormatVersion = "2010-09-09"
    Resources                = { ScratchBucket = { Type = "AWS::S3::Bucket" } }
  })

  # Exception: throwaway CI sandbox account only, torn down every run.
  # Document the exception at the call site — this disables the module's
  # restrictive default stack policy entirely, allowing unrestricted
  # Update:Delete / Update:Replace on stateful resources.
  enable_default_stack_policy = false
}
4 · CAPABILITY_NAMED_IAM — template creates IAM resources
module "iam_bootstrap_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-service-catalog-iam-bootstrap"
  iam_role_arn = module.cfn_exec_role.arn
  template_url = "https://casey-templates.s3.amazonaws.com/service-catalog/iam-bootstrap.yaml"
  capabilities = ["CAPABILITY_NAMED_IAM"] # required whenever the template names its own IAM resources
}
5 · Parameters with a template Default, plus a NoEcho parameter (drift + lifecycle gotcha)
module "legacy_db_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-legacy-db-stack"
  iam_role_arn = module.cfn_exec_role.arn
  template_url = "https://casey-templates.s3.amazonaws.com/legacy/db.yaml"

  parameters = {
    InstanceClass  = "db.t3.medium"         # template also carries a Default — must still be set here or Terraform detects perpetual drift
    MasterPassword = var.legacy_db_password # NoEcho parameter — CloudFormation never returns it to diff against
  }

  # Required for any NoEcho parameter — the module cannot add this on the
  # caller's behalf (see SCOPE.md Provider gotchas).
  lifecycle {
    ignore_changes = [parameters]
  }
}
6 · on_failure = "DO_NOTHING" — postmortem debugging (non-default, temporary)
module "debug_investigation_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-debug-investigation"
  iam_role_arn = module.cfn_exec_role.arn
  template_body = jsonencode({
    AWSTemplateFormatVersion = "2010-09-09"
    Resources                = { FlakyResource = { Type = "AWS::SQS::Queue" } }
  })

  # Leaves a failed CREATE in place instead of rolling back, so a human can
  # inspect it before deleting. Revert to the "ROLLBACK" default once the
  # investigation is done.
  on_failure = "DO_NOTHING"
}
7 · StackSet — self-managed permission model
module "stackset_admin_role" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
  name   = "casey-cfn-stackset-admin"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "cloudformation.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  inline_policies = {
    assume-execution-role = {
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Effect   = "Allow"
          Action   = "sts:AssumeRole"
          Resource = "arn:aws:iam::*:role/casey-cfn-stackset-exec" # must match execution_role_name in every target account
        }]
      })
    }
  }
}

module "security_baseline_stackset" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name             = "casey-security-baseline"
  deployment_scope = "stack_set"
  template_url     = "https://casey-templates.s3.amazonaws.com/baseline/cloudtrail-org.yaml"

  permission_model        = "SELF_MANAGED" # default — explicit here for clarity
  administration_role_arn = module.stackset_admin_role.arn
  execution_role_name     = "casey-cfn-stackset-exec" # must exist, identically named, in every target account

  stack_instances = {
    shared-svcs-use1 = { account_id = "111122223333", stack_set_instance_region = "us-east-1" }
    security-use1    = { account_id = "222233334444", stack_set_instance_region = "us-east-1" }
  }
}
8 · StackSet — service-managed permission model + deployment_targets OU + auto_deployment
module "org_guardrail_stackset" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name             = "casey-org-guardrail"
  deployment_scope = "stack_set"
  template_url     = "https://casey-templates.s3.amazonaws.com/baseline/config-recorder.yaml"

  # administration_role_arn / execution_role_name MUST be omitted in this mode
  permission_model = "SERVICE_MANAGED"
  call_as          = "DELEGATED_ADMIN" # requires organizations:ListDelegatedAdministrators on the caller

  auto_deployment = {
    enabled                          = true # new accounts joining the OU below get the stack automatically
    retain_stacks_on_account_removal = false
  }

  stack_instances = {
    prod-ou = {
      deployment_targets = {
        organizational_unit_ids = ["ou-abcd-11112222"]
      }
      stack_set_instance_region = "us-east-1"
    }
  }
}
9 · stack_instance_mode = "per_instance"for_each over accounts/Regions
module "per_instance_rollout" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name                = "casey-tgw-attachment-rollout"
  deployment_scope    = "stack_set"
  stack_instance_mode = "per_instance" # default — explicit here for clarity

  template_url            = "https://casey-templates.s3.amazonaws.com/networking/tgw-attachment.yaml"
  administration_role_arn = module.stackset_admin_role.arn
  execution_role_name     = "casey-cfn-stackset-exec"

  stack_instances = {
    prod-use1 = { account_id = "111122223333", stack_set_instance_region = "us-east-1" }
    prod-usw2 = { account_id = "111122223333", stack_set_instance_region = "us-west-2" }
    dr-use2   = { account_id = "333344445555", stack_set_instance_region = "us-east-2", retain_stack = true }
  }
}
10 · stack_instance_mode = "bulk" — single aws_cloudformation_stack_instances resource
module "bulk_rollout" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name                = "casey-tgw-attachment-bulk"
  deployment_scope    = "stack_set"
  stack_instance_mode = "bulk"

  template_url            = "https://casey-templates.s3.amazonaws.com/networking/tgw-attachment.yaml"
  administration_role_arn = module.stackset_admin_role.arn
  execution_role_name     = "casey-cfn-stackset-exec"

  stack_instances_bulk = {
    accounts = ["111122223333", "222233334444", "333344445555"]
    regions  = ["us-east-1", "us-west-2"]
  }
}
11 · Tuning operation_preferences for a large OU-wide fan-out
module "org_wide_tag_policy_stackset" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name                = "casey-org-wide-tag-policy"
  deployment_scope    = "stack_set"
  stack_instance_mode = "bulk"
  template_url        = "https://casey-templates.s3.amazonaws.com/governance/tag-policy.yaml"
  permission_model    = "SERVICE_MANAGED"

  stack_set_operation_preferences = {
    failure_tolerance_percentage = 10
    max_concurrent_percentage    = 50
    region_concurrency_type      = "PARALLEL"
  }

  instance_operation_preferences = {
    concurrency_mode             = "SOFT_FAILURE_TOLERANCE"
    failure_tolerance_percentage = 10
    max_concurrent_percentage    = 50
    region_concurrency_type      = "PARALLEL"
  }

  stack_instances_bulk = {
    deployment_targets = {
      organizational_unit_ids = ["ou-root-1111"]
    }
    regions = ["us-east-1", "us-west-2", "eu-west-1"]
  }

  # Large multi-account/Region fan-outs run well past Terraform's default
  # apply expectations — tune operation_preferences (above) first, timeouts second.
  timeouts = {
    create = "4h"
  }
}
12 · Tags — merge with provider default_tags
# Caller's provider block owns default_tags; resource tags win on key conflict.
provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = { Owner = "platform", ManagedBy = "terraform" }
  }
}

module "tagged_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-tagged-stack"
  iam_role_arn = module.cfn_exec_role.arn
  template_body = jsonencode({
    AWSTemplateFormatVersion = "2010-09-09"
    Resources                = { SampleQueue = { Type = "AWS::SQS::Queue" } }
  })

  tags = {
    Environment = "prod"
    Owner       = "network-team" # overrides default_tags Owner on this stack
  }
}
# module.tagged_stack.tags_all => { Owner="network-team", ManagedBy="terraform", Environment="prod" }
13 · notification_arns — SNS wiring for stack events
module "notified_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-monitored-stack"
  iam_role_arn = module.cfn_exec_role.arn
  template_body = jsonencode({
    AWSTemplateFormatVersion = "2010-09-09"
    Resources                = { SampleQueue = { Type = "AWS::SQS::Queue" } }
  })

  notification_arns = [module.stack_events_topic.arn] # terraform-aws-sns (Phase 2)
}
14 · import block for a hand-created stack
import {
  to = module.imported_stack.aws_cloudformation_stack.this["this"]
  id = "casey-legacy-hand-created-stack"
}

module "imported_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-legacy-hand-created-stack"
  iam_role_arn = module.cfn_exec_role.arn
  # template_body / template_url / parameters / capabilities must match the
  # live stack's current template and inputs exactly, or the next apply
  # attempts an unwanted update. Run `terraform plan` immediately after
  # import and confirm zero changes before proceeding.
}
15 · End-to-end composition — terraform-aws-iam-roleterraform-aws-s3-bucket → this module (finale)
# 1. Secure bucket to host the vendor-supplied CloudFormation template
# (SSE-KMS + public-access-block ON by default — the terraform-aws-s3-bucket baseline)
module "template_bucket" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-s3-bucket?ref=v1.0.0"
  name   = "casey-cfn-templates"
}

resource "aws_s3_object" "vendor_template" {
  bucket = module.template_bucket.id
  key    = "vendor/quickstart-landing-zone.yaml"
  source = "${path.module}/templates/quickstart-landing-zone.yaml"
  etag   = filemd5("${path.module}/templates/quickstart-landing-zone.yaml")
}

# 2. Execution role reviewed against the vendor template's ACTUAL resource
# footprint — never the AWS sample cloudformation:*/s3:*/sns:* Resource:*
# execution policy (Required IAM Permissions, part C).
module "cfn_exec_role" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
  name   = "casey-vendor-template-exec"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "cloudformation.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  inline_policies = {
    scoped-to-vendor-template = {
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Effect   = "Allow"
          Action   = ["s3:CreateBucket", "s3:PutBucketPolicy", "s3:PutEncryptionConfiguration", "cloudwatch:PutMetricAlarm"]
          Resource = "*"
        }]
      })
    }
  }
}

# 3. The wrapped template, deployed through this module
module "vendor_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-cloudformation?ref=v1.0.0"

  name         = "casey-vendor-landing-zone"
  iam_role_arn = module.cfn_exec_role.arn
  template_url = "https://${module.template_bucket.id}.s3.amazonaws.com/${aws_s3_object.vendor_template.key}"

  capabilities = ["CAPABILITY_IAM"]

  tags = {
    Environment = "prod"
    Source      = "aws-quickstart" # documents WHY this call site uses CloudFormation-via-Terraform — SCOPE.md exception (b)
  }
}

📥 Inputs

ℹ️ High-level grouping:

  • Identity: name — shared by both keystones (only one is ever active per call)
  • Deployment mode selectors: deployment_scope (stack | stack_set), stack_instance_mode (per_instance | bulk)
  • Template source (shared): template_body, template_url, parameters, capabilities
  • Single-stack config (deployment_scope = "stack"): iam_role_arn (required, no default), notification_arns, disable_rollback, on_failure, policy_body, policy_url, enable_default_stack_policy, timeout_in_minutes
  • StackSet config (deployment_scope = "stack_set"): description, permission_model (SELF_MANAGED | SERVICE_MANAGED), call_as (SELF | DELEGATED_ADMIN), administration_role_arn, execution_role_name, auto_deployment, managed_execution, stack_set_operation_preferences
  • Instance targeting — per_instance mode: stack_instancesmap(object({ account_id?, stack_set_instance_region?, deployment_targets?, parameter_overrides?, retain_stack? }))
  • Instance targeting — bulk mode: stack_instances_bulkobject({ accounts?, regions, deployment_targets?, parameter_overrides?, retain_stacks? }), instance_operation_preferences (shared by both instance modes)
  • Universal: tags, timeouts (create/update/delete — note aws_cloudformation_stack_set itself only honors update)

🧾 Outputs

  • Primary: id (stack name or StackSet name, whichever keystone is active), arn (StackSet path only — null for a plain stack, see Architecture Notes)
  • Identity: name (echoes var.name)
  • Single-stack: outputs (map of the wrapped template's Outputs section; empty map when a StackSet is active)
  • StackSet: stack_set_id, stack_set_arn (both null when a plain stack is active)
  • Instances: stack_instance_ids (flat list, mode-agnostic), stack_instance_ids_by_key (map keyed by stack_instances entry name — per_instance mode only; empty map in bulk mode)
  • Tags: tags_all

ℹ️ No outputs are marked sensitive — this module emits no secrets. outputs, stack_set_id, stack_set_arn, and both stack_instance_ids* outputs are conditionally populated (try(..., null) / try(..., {}) / try(..., [])) depending on which deployment path is active — always check for null/empty before consuming them downstream.


🧠 Architecture Notes

  • The id + arn convention breaks for the single-stack path — a real provider gap, not an authoring oversight. aws_cloudformation_stack exposes no arn attribute at all in the hashicorp/aws provider schema (confirmed against both the live schema and the provider's own resource documentation) — only id (the stack name), outputs, and tags_all. aws_cloudformation_stack_set is the only ARN-bearing resource in this module, exposing arn and stack_set_id. Every other module in this library emits id + arn as its primary pair; this one cannot for a plain stack. Downstream references to a plain stack must use id (the stack name) plus, if needed, the outputs map — there is no ARN to hand to an IAM policy Resource element for a plain stack.
  • ARN / ID formats: stack id → the stack name you supplied (not a stack-... ARN-shaped string); StackSet id/arnarn:aws:cloudformation:<region>:<account-id>:stackset/<name>:<uuid>; a per_instance stack instance id → a comma-delimited stack_set_name,account_id|ou_ids,region composite key.
  • Force-new / immutable fields: name has no rename API on either keystone — CloudFormation cannot rename a stack or StackSet, so changing var.name destroys and recreates it (and, for a StackSet, every fanned-out instance). stack_set_name is explicitly FORCE-NEW on aws_cloudformation_stack_instances — since this module derives stack_set_name from aws_cloudformation_stack_set.this automatically, renaming var.name on a "stack_set" call force-recreates the entire bulk fan-out, not just the StackSet.
  • tagstags_alldefault_tags: var.tags is written to aws_cloudformation_stack.this and aws_cloudformation_stack_set.this; tags_all is the provider-computed merge over the caller's provider-level default_tags (resource tags win on key conflict). The StackSet also propagates its tags into the stacks it deploys — "AWS CloudFormation also propagates these tags to supported resources that are created in the Stacks," per AWS's own resource documentation — but per-resource support inside the wrapped template varies and is outside this module's control. The two instance resources (aws_cloudformation_stack_set_instance, aws_cloudformation_stack_instances) are not taggable and have no tags_all of their own.
  • StackSet operations are eventually consistent. CreateStackInstances / UpdateStackInstances are asynchronous, multi-account/-Region operations; large fan-outs (many accounts × many Regions) can run well past Terraform's default apply expectations. Tune stack_set_operation_preferences / instance_operation_preferences (max_concurrent_percentage, failure_tolerance_percentage, region_concurrency_type) rather than only raising timeouts — see Example 11.
  • Destroy ordering: stack instances must be deleted before the StackSet itself. Terraform sequences this correctly on its own via the implicit stack_set_name reference from either instance resource to aws_cloudformation_stack_set.this — the real risk is manual, out-of-band instance creation (console/CLI) that Terraform's state doesn't know about, which blocks DeleteStackSet until removed.
  • retain_stack / retain_stacks timing is a hard gotcha. The flag must already be true in state, before the destroy that would otherwise delete the underlying stacks — setting it in the same apply that removes the resource block does not take effect. Sequence is: (1) apply with retain_stack(s) = true on the still-present resource, (2) confirm state shows the flag applied, (3) then remove the resource block / entry and apply again.
  • Why this module exists as an explicit exception, not the default path. our standard is a native aws_* Terraform resource per module family; this module is invoked only when (a) AWS has launched a feature not yet in the hashicorp/aws provider, (b) a vendor or AWS Quick Start ships only a CloudFormation template, or (c) an AWS Service Catalog product requires CloudFormation (see SCOPE.md "Design decisions"). It is deliberately a leaf in this library's dependency graph — see "Where this fits in the family" — because using it as a shortcut around writing a proper native module defeats the type-safety, plan-diff clarity, and state-management benefits the rest of this library is built on. Every call site should carry a comment naming which of the three conditions applies (see Example 15).
  • No us-east-1 constraint. CloudFormation itself is regional; any global-resource coupling (e.g. a wrapped template that provisions CloudFront/ACM) lives inside the template and is outside this module's static visibility.

🧱 Design Principles

Secure-by-default posture and the explicit opt-out for each:

Hardened default Behavior Opt-out / control
Stack policy A restrictive default policy body denies Update:Delete/Update:Replace on stateful/critical resource types (AWS::RDS::DBInstance, AWS::RDS::DBCluster, AWS::DynamoDB::Table, AWS::S3::Bucket, AWS::KMS::Key); everything else stays Update:* allowed. Rendered only when the caller supplies neither policy_body nor policy_url. Supply policy_body/policy_url to fully override with a caller-authored policy, or set enable_default_stack_policy = false to allow unrestricted updates (documented exception — Example 3)
Execution identity (iam_role_arn) Required, no null fallback. The module does not allow an unset iam_role_arn — every stack runs under an explicit, auditable, least-privilege role instead of the caller's ambient session credentials. None — this is a hard requirement in this module, not a togglable default
Capabilities Empty list ([]) by default — the caller must explicitly opt into CAPABILITY_IAM / CAPABILITY_NAMED_IAM / CAPABILITY_AUTO_EXPAND Set capabilities explicitly when the template requires it (Example 4); the module never defaults to acknowledging a broad capability silently
Rollback on failed create on_failure = "ROLLBACK" by default on_failure = "DO_NOTHING" for postmortem debugging (Example 6) or "DELETE"; disable_rollback stays false unless explicitly set
StackSet permission model SELF_MANAGED (matches the provider default; explicit manual trust, smallest blast radius for a first module version) Set permission_model = "SERVICE_MANAGED" for an Organizations-wide rollout (Example 8)
Notifications notification_arns = [] — no SNS wiring by default Supply topic ARNs from terraform-aws-sns for stack-event alerting (Example 13; recommended for production)

ℹ️ CloudFormation has no native "encryption at rest" or "public access" knobs of its own — those postures belong to the resources the wrapped template creates, which are outside this module's static visibility (see the transitive-permissions gotcha in Required IAM Permissions). The secure defaults this module controls are process-level: forced execution-role scoping via mandatory iam_role_arn/administration_role_arn, a restrictive stack policy against accidental replace/delete of stateful resources, and rollback-on-failure — not encryption/network postures, which the template author must handle inside the CloudFormation template itself.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan # requires valid AWS credentials (profile / SSO / OIDC) + a region
terraform apply
terraform output

⚠️ plan / apply require valid AWS credentials and a configured region (provider block / AWS_PROFILE / SSO / OIDC). Always pin the module source with ?ref=v1.0.0, never a branch. For a StackSet, expect apply to take materially longer than a single stack — StackSet operations are asynchronous and fan out across every target account/Region.


🧪 Testing

  • terraform init -backend=false && terraform validate — schema + reference integrity.
  • terraform fmt -check — formatting.
  • terraform plan against a sandbox account — confirm exactly one keystone plans to be created (never both aws_cloudformation_stack and aws_cloudformation_stack_set in the same plan), and that the rendered stack policy matches expectations (default vs. caller-supplied vs. disabled).
  • For a StackSet: terraform plan in a throwaway multi-account sandbox first — verify stack_instances/stack_instances_bulk targets resolve to the accounts/Regions you expect before applying against production accounts.
  • Validate the wrapped template independently with cfn-lint / cfn_nag (or equivalent) before handing it to this module — this module does not parse or validate template content itself.
  • Destroy test in a throwaway account/StackSet to validate teardown ordering (instances before StackSet) and retain_stack/retain_stacks timing before relying on either in a shared environment.

💬 Example Output

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

id = "casey-sample-queue"
arn = null
name = "casey-sample-queue"
outputs = {}
stack_set_id = null
stack_set_arn = null
stack_instance_ids = []
stack_instance_ids_by_key = {}
tags_all = { "Environment" = "prod" }
# StackSet path (deployment_scope = "stack_set")
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

id = "casey-security-baseline"
arn = "arn:aws:cloudformation:us-east-1:123456789012:stackset/casey-security-baseline:8f2c1a90-..."
stack_set_id = "casey-security-baseline:8f2c1a90-..."
stack_set_arn = "arn:aws:cloudformation:us-east-1:123456789012:stackset/casey-security-baseline:8f2c1a90-..."
stack_instance_ids = ["casey-security-baseline,111122223333,us-east-1", "casey-security-baseline,222233334444,us-east-1"]
tags_all = { "Environment" = "prod" }

🔍 Troubleshooting

  • this object does not have an attribute named "arn" (or a null value where an ARN was expected): Expected for the single-stack path — aws_cloudformation_stack has no arn attribute (see Architecture Notes). Use id (the stack name) instead, or switch to deployment_scope = "stack_set" if you genuinely need an ARN-addressable resource.
  • Stack silently updated by a session other than the intended role: iam_role_arn was left unset on an earlier version of the template, or a caller bypassed this module's required-input validation. Confirm iam_role_arn is set and scoped correctly; CloudFormation falls back to the caller's own session when it is null.
  • AccessDenied / stack create fails immediately with no CloudFormation-side error: Missing iam:PassRole on the Terraform-executing identity for iam_role_arn or administration_role_arn — the single most commonly under-scoped grant in this module's IAM permissions. See Required IAM Permissions (B).
  • Resources inside the stack fail to create even though the plan succeeded: The transitive-permissions gotcha — the execution role (iam_role_arn or the self-managed StackSet execution role) lacks the resource-level permissions the template needs. This is separate from the Terraform-executing identity's own CloudFormation grants. See Required IAM Permissions (C); do not "fix" this by copying AWS's broad cloudformation:*/s3:*/sns:* sample policy.
  • ValidationError: Account used is not a delegated administrator: call_as = "DELEGATED_ADMIN" was set but the calling identity lacks organizations:ListDelegatedAdministrators, or the account was never registered as a delegated administrator for CloudFormation StackSets in AWS Organizations.
  • ValidationException on aws_cloudformation_stack_set apply mentioning administration_role_arn or execution_role_name: These must be omitted entirely when permission_model = "SERVICE_MANAGED" — this module's own variable validation should catch it at plan time, but a terraform apply -target or state manipulation that bypasses validation can still hit the API-level error.
  • Perpetual plan diff on parameters: Every template parameter — including ones carrying a template Default — must appear in var.parameters, or Terraform detects drift against the template's own default every plan. NoEcho parameters must always carry a caller-added lifecycle { ignore_changes = [parameters] } (Example 5) — CloudFormation never returns a NoEcho value back to the provider to diff against.
  • Destroy hangs or fails with a dependency-style error on a StackSet: Manual, out-of-band stack-instance creation (console/CLI) that Terraform's state doesn't know about blocks DeleteStackSet. Reconcile or remove those instances outside Terraform first.
  • A stack/instance you meant to retain got deleted anyway: retain_stack/retain_stacks was set in the same apply that removed the resource. It must be applied and confirmed in state before the removal apply — see Architecture Notes.
  • Renaming var.name recreates everything: Expected — CloudFormation has no rename API for a stack or StackSet, and stack_set_name is force-new on aws_cloudformation_stack_instances. Plan a rename as a deliberate migration (create new, migrate state or re-import, retire old), not a routine edit.
  • Tag drift / unexpected tag values: Caused by provider default_tags overlap. tags_all merges resource tags over default_tags with resource tags winning — if a value differs from what you set in var.tags, a default_tags entry is colliding.
  • Credential-chain failures (NoCredentialProviders / ExpiredToken): No valid credentials resolved. Set AWS_PROFILE, refresh SSO, or confirm OIDC role assumption in CI. The module never takes credentials as variables.

🔗 Related Docs

  • Terraform Registry — hashicorp/aws provider: aws_cloudformation_stack, aws_cloudformation_stack_set, aws_cloudformation_stack_set_instance, aws_cloudformation_stack_instances, and the aws_cloudformation_stack data source
  • AWS — What is AWS CloudFormation? and StackSets concepts (administrator/target accounts, permission models, stack instances) — AWS CloudFormation User Guide
  • AWS — Grant self-managed permissions and Grant service-managed permissions for StackSets (role setup for each permission model)
  • AWS — Prevent updates to stack resources (stack policies — the pattern this module's secure default follows)
  • AWS — Understand CloudFormation quotas (stacks, StackSets, stack instances, template size limits)
  • terraform-aws-iam-role, terraform-aws-s3-bucket, terraform-aws-sns (Phase 2) — sibling modules this module consumes by reference

🧡 "Infrastructure as Code should be standardized, consistent, and secure."

About

Terraform module: terraform-aws-cloudformation

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages