Nested-IaC escape hatch for Terraform-driven AWS CloudFormation — wraps a single-account
aws_cloudformation_stack, or a multi-account/multi-Regionaws_cloudformation_stack_setfanned out viafor_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.
- 🧱 One module, two mutually exclusive deployment paths —
var.deployment_scopeselects 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 choice —
var.stack_instance_modepicksper_instance(aws_cloudformation_stack_set_instance,for_each-friendly, one resource per account/OU + Region target) orbulk(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 denyingUpdate:Delete/Update:Replaceon stateful/critical resource types (RDS, DynamoDB, S3, KMS), leaving everything else updatable. - 🪪
iam_role_arnis 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 acknowledgesCAPABILITY_IAM/CAPABILITY_NAMED_IAM/CAPABILITY_AUTO_EXPAND. - 🔁 Rollback-on-failure by default —
on_failure = "ROLLBACK";disable_rollbackandon_failureare rendered mutually exclusively so the generated configuration never presents both to the provider. - 🏷️ Tags flow to both keystones —
var.tagsreachesaws_cloudformation_stackandaws_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 nativeaws_*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/awsprovider 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 manualaws cloudformation deployside-channel with no plan diff and no state tracking.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
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
ℹ️ No arrow leaves
terraform-aws-cloudformationtoward any sibling module. Itsoutputsmap (the wrapped template's CloudFormationOutputssection) 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 nativeaws_*module instead.
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
| 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.
| 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).
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), neverresource = "*". An unscopediam:PassRoleon 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 oniam: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_arnexecution role needs everyCreate/Update/Delete/Describepermission for every resource type the template declares (a template creatingAWS::RDS::DBInstanceneedsrds:CreateDBInstanceetc. 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 footprint —
template_body/template_urlare 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).
- 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_typegovern 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 thehashicorp/awsprovider's own resource docs for bothaws_cloudformation_stackandaws_cloudformation_stack_set) / 460,800 bytes viatemplate_urlper 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_urlfigure 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_EXPANDacknowledgement: any wrapped template that creates IAM resources or uses macros/transforms fails to apply unless the corresponding capability is explicitly listed incapabilities— CloudFormation's own guardrail against a template silently creating IAM principals; this module surfaces it as a variable rather than defaulting it broad.
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
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_arnhas no default; the module will failterraform planuntil you wire it.
| 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) |
| Output | Description | Consumed by |
|---|---|---|
id |
Stack name or StackSet name, whichever keystone is active | Reference/import |
arn |
Populated only for the StackSet path — aws_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.
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-role → terraform-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)
}
}ℹ️ 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_instancemode:stack_instances—map(object({ account_id?, stack_set_instance_region?, deployment_targets?, parameter_overrides?, retain_stack? })) - Instance targeting —
bulkmode:stack_instances_bulk—object({ accounts?, regions, deployment_targets?, parameter_overrides?, retain_stacks? }),instance_operation_preferences(shared by both instance modes) - Universal:
tags,timeouts(create/update/delete— noteaws_cloudformation_stack_setitself only honorsupdate)
- Primary:
id(stack name or StackSet name, whichever keystone is active),arn(StackSet path only —nullfor a plain stack, see Architecture Notes) - Identity:
name(echoesvar.name) - Single-stack:
outputs(map of the wrapped template'sOutputssection; empty map when a StackSet is active) - StackSet:
stack_set_id,stack_set_arn(bothnullwhen a plain stack is active) - Instances:
stack_instance_ids(flat list, mode-agnostic),stack_instance_ids_by_key(map keyed bystack_instancesentry name —per_instancemode only; empty map inbulkmode) - Tags:
tags_all
ℹ️ No outputs are marked
sensitive— this module emits no secrets.outputs,stack_set_id,stack_set_arn, and bothstack_instance_ids*outputs are conditionally populated (try(..., null)/try(..., {})/try(..., [])) depending on which deployment path is active — always check fornull/empty before consuming them downstream.
- The
id+arnconvention breaks for the single-stack path — a real provider gap, not an authoring oversight.aws_cloudformation_stackexposes noarnattribute at all in thehashicorp/awsprovider schema (confirmed against both the live schema and the provider's own resource documentation) — onlyid(the stack name),outputs, andtags_all.aws_cloudformation_stack_setis the only ARN-bearing resource in this module, exposingarnandstack_set_id. Every other module in this library emitsid+arnas its primary pair; this one cannot for a plain stack. Downstream references to a plain stack must useid(the stack name) plus, if needed, theoutputsmap — there is no ARN to hand to an IAM policyResourceelement for a plain stack. - ARN / ID formats: stack
id→ the stack name you supplied (not astack-...ARN-shaped string); StackSetid/arn→arn:aws:cloudformation:<region>:<account-id>:stackset/<name>:<uuid>; aper_instancestack instanceid→ a comma-delimitedstack_set_name,account_id|ou_ids,regioncomposite key. - Force-new / immutable fields:
namehas no rename API on either keystone — CloudFormation cannot rename a stack or StackSet, so changingvar.namedestroys and recreates it (and, for a StackSet, every fanned-out instance).stack_set_nameis explicitly FORCE-NEW onaws_cloudformation_stack_instances— since this module derivesstack_set_namefromaws_cloudformation_stack_set.thisautomatically, renamingvar.nameon a"stack_set"call force-recreates the entire bulk fan-out, not just the StackSet. tags↔tags_all↔default_tags:var.tagsis written toaws_cloudformation_stack.thisandaws_cloudformation_stack_set.this;tags_allis the provider-computed merge over the caller's provider-leveldefault_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 notags_allof their own.- StackSet operations are eventually consistent.
CreateStackInstances/UpdateStackInstancesare asynchronous, multi-account/-Region operations; large fan-outs (many accounts × many Regions) can run well past Terraform's default apply expectations. Tunestack_set_operation_preferences/instance_operation_preferences(max_concurrent_percentage,failure_tolerance_percentage,region_concurrency_type) rather than only raisingtimeouts— 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_namereference from either instance resource toaws_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 blocksDeleteStackSetuntil removed. retain_stack/retain_stackstiming is a hard gotcha. The flag must already betruein 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 withretain_stack(s) = trueon 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 thehashicorp/awsprovider, (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-1constraint. 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.
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.
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/applyrequire 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, expectapplyto take materially longer than a single stack — StackSet operations are asynchronous and fan out across every target account/Region.
terraform init -backend=false && terraform validate— schema + reference integrity.terraform fmt -check— formatting.terraform planagainst a sandbox account — confirm exactly one keystone plans to be created (never bothaws_cloudformation_stackandaws_cloudformation_stack_setin the same plan), and that the rendered stack policy matches expectations (default vs. caller-supplied vs. disabled).- For a StackSet:
terraform planin a throwaway multi-account sandbox first — verifystack_instances/stack_instances_bulktargets 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_stackstiming before relying on either in a shared environment.
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" }
this object does not have an attribute named "arn"(or anullvalue where an ARN was expected): Expected for the single-stack path —aws_cloudformation_stackhas noarnattribute (see Architecture Notes). Useid(the stack name) instead, or switch todeployment_scope = "stack_set"if you genuinely need an ARN-addressable resource.- Stack silently updated by a session other than the intended role:
iam_role_arnwas left unset on an earlier version of the template, or a caller bypassed this module's required-input validation. Confirmiam_role_arnis 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: Missingiam:PassRoleon the Terraform-executing identity foriam_role_arnoradministration_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_arnor 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 broadcloudformation:*/s3:*/sns:*sample policy. ValidationError: Account used is not a delegated administrator:call_as = "DELEGATED_ADMIN"was set but the calling identity lacksorganizations:ListDelegatedAdministrators, or the account was never registered as a delegated administrator for CloudFormation StackSets in AWS Organizations.ValidationExceptiononaws_cloudformation_stack_setapply mentioningadministration_role_arnorexecution_role_name: These must be omitted entirely whenpermission_model = "SERVICE_MANAGED"— this module's own variable validation should catch it atplantime, but aterraform apply -targetor state manipulation that bypasses validation can still hit the API-level error.- Perpetual plan diff on
parameters: Every template parameter — including ones carrying a templateDefault— must appear invar.parameters, or Terraform detects drift against the template's own default every plan.NoEchoparameters must always carry a caller-addedlifecycle { ignore_changes = [parameters] }(Example 5) — CloudFormation never returns aNoEchovalue 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_stackswas 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.namerecreates everything: Expected — CloudFormation has no rename API for a stack or StackSet, andstack_set_nameis force-new onaws_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_tagsoverlap.tags_allmerges resource tags overdefault_tagswith resource tags winning — if a value differs from what you set invar.tags, adefault_tagsentry is colliding. - Credential-chain failures (
NoCredentialProviders/ExpiredToken): No valid credentials resolved. SetAWS_PROFILE, refresh SSO, or confirm OIDC role assumption in CI. The module never takes credentials as variables.
- Terraform Registry —
hashicorp/awsprovider:aws_cloudformation_stack,aws_cloudformation_stack_set,aws_cloudformation_stack_set_instance,aws_cloudformation_stack_instances, and theaws_cloudformation_stackdata 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."