How to Fix Terraform 'Unsupported block type dynamic' Error in Resource Blocks
Threat/Impact Level: MEDIUM | Exploitability/Downtime Risk: HIGH | Time to Fix: 5–15 mins
TL;DR
- What broke: Terraform encountered a
dynamicblock inside a resource attribute or nested block position that the provider schema does not expose as a repeatable block type — or thedynamickeyword was used at the top-level resource argument level where it is syntactically illegal. - How to fix it: Move the
dynamicblock so it wraps only a legitimate repeatable nested block (e.g.,ingress,egress,tag,setting) declared in the provider schema. Replace top-level argument iteration withforexpressions orfor_eachon the resource itself. - Fast path: Use our Client-Side Sandbox above to auto-refactor this — paste your failing
.tfblock and get corrected HCL without sending your config to a third-party server.
The Incident (What Does the Error Mean?)
Raw error output:
Error: Unsupported block type
on main.tf line 14, in resource "aws_security_group" "this":
14: dynamic "description" {
Blocks of type "description" are not expected here.
Terraform's type-checker validates every block label against the provider schema at terraform plan time — before any API call is made. When it sees dynamic "description", it looks up whether description is a repeatable nested block in the aws_security_group schema. It isn't — description is a scalar string argument, not a block. The plan exits non-zero immediately. No infrastructure change occurs, but your entire pipeline is blocked.
The same error fires in three other common scenarios:
dynamicwrapping a top-level resource argument (e.g.,dynamic "bucket" {}insideaws_s3_bucket).dynamicused inside alocalsorvariableblock — neither supports it.- Provider version mismatch: a block that was repeatable in provider v3.x was flattened to a single block or argument in v4.x, breaking existing
dynamicusage.
The Attack Vector / Blast Radius
This is a pipeline-blocking syntax error, not a runtime security flaw — but the blast radius in a CI/CD context is significant:
- Deployment freeze: Every
terraform planandterraform applyin the affected workspace fails. If this lands in a shared root module used by 15 teams via Terragrunt, all 15 stacks are frozen. - Drift accumulation: While the pipeline is blocked, engineers resort to manual console changes to meet deadlines. Those changes are now outside state. When the fix lands,
terraform applymay destroy manually-created resources. - Masked cascading errors: A syntax error at line 14 prevents Terraform from evaluating lines 15–400. Other misconfigurations (open security groups, unencrypted buckets) are invisible until this is resolved — giving a false sense of a "one-bug" problem.
- Module consumers broken silently: If the broken
dynamicblock lives inside a published Terraform module and a consumer pins tosource = "git::...?ref=main"without a version tag, the breakage propagates to all consumers on nextterraform init -upgrade.
How to Fix It (The Solution)
Identify the Offending Block
Run this to get the full schema for the resource and confirm which nested blocks are actually repeatable:
terraform providers schema -json | jq '.provider_schemas[].resource_schemas["aws_security_group"].block.block_types'
Only keys returned under block_types (not attributes) are valid dynamic block labels.
Basic Fix — Wrong Target Attribute
The most common mistake: wrapping a scalar attribute with dynamic instead of a nested block_type.
resource "aws_security_group" "this" {
name = var.name
- dynamic "description" {
- for_each = var.descriptions
- content {
- description = description.value
- }
- }
+ description = var.descriptions[0] # scalar — pick one value or join them
}
If you genuinely need to iterate over ingress rules (a real block_type):
resource "aws_security_group" "this" {
name = var.name
description = var.description
+ dynamic "ingress" {
+ for_each = var.ingress_rules
+ content {
+ from_port = ingress.value.from_port
+ to_port = ingress.value.to_port
+ protocol = ingress.value.protocol
+ cidr_blocks = ingress.value.cidr_blocks
+ }
+ }
}
Enterprise Best Practice — Provider Version Pin + Schema Validation Gate
Provider schema changes between major versions silently invalidate dynamic blocks. Lock the provider and add a schema assertion:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"-
- version = ">= 3.0"
+ version = "~> 5.0" # pin to known-good major; test upgrades in isolation
}
}
}
For modules consumed by multiple teams, add an explicit lifecycle precondition to fail fast with a human-readable message instead of a cryptic schema error:
resource "aws_security_group" "this" {
name = var.name
description = var.description
+
+ lifecycle {
+ precondition {
+ condition = length(var.ingress_rules) > 0
+ error_message = "ingress_rules must contain at least one rule object. Received empty list."
+ }
+ }
+
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
}
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
Gate 1 — terraform validate as the first pipeline step (non-negotiable):
# .github/workflows/tf.yml
- name: Validate
run: terraform validate
working-directory: ./infra
This catches the Unsupported block type error in under 2 seconds, before plan wastes API quota.
Gate 2 — tflint with provider plugin for schema-aware linting:
tflint --init
tflint --plugin-dir ~/.tflint.d/plugins
tflint's AWS ruleset (terraform_aws_provider_*) flags dynamic blocks targeting scalar attributes before validate even runs.
Gate 3 — Checkov policy for module hygiene:
checkov -d . --check CKV_TF_1 # enforces module version pinning, preventing schema drift
Gate 4 — OPA/Conftest policy to block unpinned provider versions (the root cause of schema drift):
# policy/tf_provider_pin.rego
package terraform.providers
deny[msg] {
provider := input.configuration.provider_config[_]
not regex.match(`^~> \d+\.\d+$`, provider.version_constraint)
msg := sprintf("Provider '%v' must use pessimistic constraint (~> X.Y). Got: %v", [provider.name, provider.version_constraint])
}
conftest test plan.json --policy policy/
Recommended pipeline order:
tflint → terraform validate → conftest → terraform plan → checkov (on plan JSON) → terraform apply
Each gate is cheaper than the one after it. Kill bad configs at tflint, not at apply.