Skip to content

microsoftexpert/tf-mod-aws-iot-core

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

3 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

🟧 AWS IoT Core Terraform Module

Provisions the AWS IoT Core device fleet β€” registry (Things/Types/Groups), security credentials (policies/certificates/authorizers/role aliases), message routing (topic rules), and fleet provisioning β€” as one aggregation module with no single keystone. Built for the AWS provider v6.x.

Terraform aws module type resources


🧩 Overview

  • πŸ“‘ Device registry. aws_iot_thing, aws_iot_thing_type, aws_iot_thing_group, aws_iot_thing_group_membership, and aws_iot_billing_group model the fleet and its hierarchy/cost allocation.
  • πŸ” Security credentials. aws_iot_policy (+ attachment), aws_iot_certificate, aws_iot_ca_certificate, aws_iot_thing_principal_attachment, aws_iot_authorizer, and aws_iot_role_alias cover device auth end to end.
  • πŸ“¬ Message routing. aws_iot_topic_rule (SQL + up to 11 action types) and aws_iot_topic_rule_destination (private VPC HTTP endpoints) route device telemetry to the rest of the estate.
  • 🏭 Fleet provisioning. aws_iot_provisioning_template (JITP/bulk) and aws_iot_domain_configuration (custom data-plane domain + TLS policy).
  • βš™οΈ Account-wide settings. aws_iot_logging_options, aws_iot_event_configurations, and aws_iot_indexing_configuration β€” modeled as singletons, not maps, because there is only ever one per account/Region.
  • 🧩 No keystone. Things, Policies, Certificates, and Topic Rules are independent AWS resources with no inherent Terraform parent/child edge β€” every resource is named by role and rendered via for_each, per this module suite's aggregation-module exception.
  • 🚨 Rules never fail silently. error_action is a required, validated field on every topic rule β€” a rule with no error destination would otherwise drop failed device telemetry with zero observability.

πŸ’‘ Why it matters: device fleets are the widest, least-trusted edge of the network β€” a compromised or misconfigured certificate/policy/rule can touch production ingestion pipelines. This module makes the secure posture (signed authorizers, required error actions, no-private-key-by-default certificates) the path of least resistance.


❀️ 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

tf-mod-aws-iot-core sits downstream of the identity, compute, storage, and messaging foundations β€” it wires device credentials to IAM roles and routes telemetry into the app-integration/storage/database layer.

flowchart LR
 IAMROLE["tf-mod-aws-iam-role"] --> IOTCORE["tf-mod-aws-iot-core"]
 LAMBDA["tf-mod-aws-lambda"] --> IOTCORE
 S3["tf-mod-aws-s3-bucket"] --> IOTCORE
 SNS["tf-mod-aws-sns"] --> IOTCORE
 SQS["tf-mod-aws-sqs"] --> IOTCORE
 KINESIS["tf-mod-aws-kinesis-stream"] --> IOTCORE
 FIREHOSE["tf-mod-aws-kinesis-firehose"] --> IOTCORE
 DYNAMODB["tf-mod-aws-dynamodb"] --> IOTCORE
 CWLOGS["tf-mod-aws-cloudwatch-log-group"] --> IOTCORE
 VPC["tf-mod-aws-vpc"] --> IOTCORE
 SG["tf-mod-aws-security-group"] --> IOTCORE
 IOTCORE --> CWALARM["tf-mod-aws-cloudwatch-alarm"]

 style IOTCORE fill:#FF9900,color:#fff
Loading

Rendered and syntax-validated via the Mermaid Chart MCP (validate_and_render_mermaid_diagram) this session β€” valid: true.


🧬 What this module builds

flowchart TB
 subgraph Registry["Device registry"]
 Thing["aws_iot_thing"]
 ThingType["aws_iot_thing_type"]
 ThingGroup["aws_iot_thing_group"]
 Membership["aws_iot_thing_group_membership"]
 BillingGroup["aws_iot_billing_group"]
 end

 subgraph Security["Security credentials"]
 Policy["aws_iot_policy"]
 PolicyAttach["aws_iot_policy_attachment"]
 Cert["aws_iot_certificate"]
 CACert["aws_iot_ca_certificate"]
 PrincipalAttach["aws_iot_thing_principal_attachment"]
 Authorizer["aws_iot_authorizer"]
 RoleAlias["aws_iot_role_alias"]
 end

 subgraph Routing["Message routing"]
 TopicRule["aws_iot_topic_rule"]
 RuleDest["aws_iot_topic_rule_destination"]
 end

 subgraph Provisioning["Fleet provisioning"]
 ProvTemplate["aws_iot_provisioning_template"]
 DomainConfig["aws_iot_domain_configuration"]
 end

 subgraph Account["Account-wide singletons"]
 LoggingOpts["aws_iot_logging_options"]
 EventConfig["aws_iot_event_configurations"]
 IndexConfig["aws_iot_indexing_configuration"]
 end

 Thing --> Membership
 ThingGroup --> Membership
 Thing --> PrincipalAttach
 Cert --> PrincipalAttach
 Policy --> PolicyAttach
 Cert --> PolicyAttach
 ThingType -.-> Thing
 CACert -.-> ProvTemplate
 Authorizer -.-> DomainConfig
 TopicRule --> RuleDest

 style Thing fill:#FF9900,color:#fff
 style ThingType fill:#FF9900,color:#fff
 style ThingGroup fill:#FF9900,color:#fff
 style Membership fill:#FF9900,color:#fff
 style BillingGroup fill:#FF9900,color:#fff
 style Policy fill:#FF9900,color:#fff
 style PolicyAttach fill:#FF9900,color:#fff
 style Cert fill:#FF9900,color:#fff
 style CACert fill:#FF9900,color:#fff
 style PrincipalAttach fill:#FF9900,color:#fff
 style Authorizer fill:#FF9900,color:#fff
 style RoleAlias fill:#FF9900,color:#fff
 style TopicRule fill:#FF9900,color:#fff
 style RuleDest fill:#FF9900,color:#fff
 style ProvTemplate fill:#FF9900,color:#fff
 style DomainConfig fill:#FF9900,color:#fff
 style LoggingOpts fill:#FF9900,color:#fff
 style EventConfig fill:#FF9900,color:#fff
 style IndexConfig fill:#FF9900,color:#fff
Loading

Rendered and syntax-validated via the Mermaid Chart MCP this session β€” valid: true. Solid edges are in-module cross-references resolved by *_key fields; dotted edges are optional cross-references.

Resource Count Created when
aws_iot_thing 0..N one per things entry
aws_iot_thing_type 0..N one per thing_types entry
aws_iot_thing_group 0..N one per thing_groups entry
aws_iot_thing_group_membership 0..N one per thing_group_memberships entry
aws_iot_thing_principal_attachment 0..N one per thing_principal_attachments entry
aws_iot_billing_group 0..N one per billing_groups entry
aws_iot_policy 0..N one per policies entry
aws_iot_policy_attachment 0..N one per policy_attachments entry
aws_iot_certificate 0..N one per certificates entry
aws_iot_ca_certificate 0..N one per ca_certificates entry
aws_iot_authorizer 0..N one per authorizers entry
aws_iot_role_alias 0..N one per role_aliases entry
aws_iot_topic_rule 0..N one per topic_rules entry
aws_iot_topic_rule_destination 0..N one per topic_rule_destinations entry
aws_iot_provisioning_template 0..N one per provisioning_templates entry
aws_iot_domain_configuration 0..N one per domain_configurations entry
aws_iot_logging_options 0 or 1 logging_options != null (account-wide singleton)
aws_iot_event_configurations 0 or 1 event_configurations != null (account-wide singleton)
aws_iot_indexing_configuration 0 or 1 indexing_configuration != null (account-wide singleton)

βœ… Provider / Versions

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

The module declares only a required_providers block (providers.tf) and inherits the configured provider. No provider {} block, no credential variable, no region variable β€” IoT Core has no us-east-1 global-service constraint (custom domain configurations use ACM certs in their own Region).


πŸ”‘ Required IAM Permissions

Least-privilege actions the Terraform execution identity needs, grouped by resource. No service-linked role is auto-created by any in-scope IoT Core resource.

Action Required for Notes
iot:CreateThing, iot:DescribeThing, iot:UpdateThing, iot:DeleteThing things β€”
iot:CreateThingType, iot:DescribeThingType, iot:DeprecateThingType, iot:DeleteThingType thing_types β€”
iot:CreateThingGroup, iot:DescribeThingGroup, iot:UpdateThingGroup, iot:DeleteThingGroup thing_groups β€”
iot:AddThingToThingGroup, iot:RemoveThingFromThingGroup thing_group_memberships β€”
iot:AttachThingPrincipal, iot:DetachThingPrincipal thing_principal_attachments β€”
iot:CreateBillingGroup, iot:DescribeBillingGroup, iot:UpdateBillingGroup, iot:DeleteBillingGroup billing_groups β€”
iot:CreatePolicy, iot:GetPolicy, iot:CreatePolicyVersion, iot:DeletePolicyVersion, iot:DeletePolicy, iot:ListPolicyVersions policies Provider auto-prunes the oldest non-default version past 5
iot:AttachPolicy, iot:DetachPolicy policy_attachments β€”
iot:CreateCertificateFromCsr, iot:CreateKeysAndCertificate, iot:RegisterCertificate, iot:RegisterCertificateWithoutCA, iot:DescribeCertificate, iot:UpdateCertificate, iot:DeleteCertificate certificates β€”
iot:RegisterCACertificate, iot:DescribeCACertificate, iot:UpdateCACertificate, iot:DeleteCACertificate, iot:GetRegistrationCode ca_certificates Registration code fetch is a manual out-of-band step
iot:CreateAuthorizer, iot:DescribeAuthorizer, iot:UpdateAuthorizer, iot:DeleteAuthorizer authorizers β€”
iot:CreateRoleAlias, iot:DescribeRoleAlias, iot:UpdateRoleAlias, iot:DeleteRoleAlias, iam:PassRole (on the aliased role) role_aliases PassRole scoped to the specific role ARN
iot:CreateTopicRule, iot:GetTopicRule, iot:ReplaceTopicRule, iot:DeleteTopicRule, iam:PassRole (on every action role_arn) topic_rules PassRole scoped per action role
iot:CreateTopicRuleDestination, iot:GetTopicRuleDestination, iot:UpdateTopicRuleDestination, iot:DeleteTopicRuleDestination, iam:PassRole topic_rule_destinations β€”
iot:CreateProvisioningTemplate, iot:DescribeProvisioningTemplate, iot:UpdateProvisioningTemplate, iot:DeleteProvisioningTemplate, iam:PassRole provisioning_templates β€”
iot:CreateDomainConfiguration, iot:DescribeDomainConfiguration, iot:UpdateDomainConfiguration, iot:DeleteDomainConfiguration domain_configurations β€”
iot:SetV2LoggingOptions, iot:GetV2LoggingOptions, iam:PassRole (on the logging role) logging_options (account-wide) Only one caller per account/Region should manage this
iot:UpdateEventConfigurations, iot:DescribeEventConfigurations event_configurations (account-wide) Same singleton-ownership caveat
iot:UpdateIndexingConfiguration, iot:GetIndexingConfiguration indexing_configuration (account-wide) Same singleton-ownership caveat
iot:TagResource, iot:UntagResource, iot:ListTagsForResource Tagging On every taggable resource type

⚠️ iam:PassRole is scoped, not blanket. Every role_arn in this module (topic-rule actions, role aliases, provisioning templates, logging options, topic rule destinations) is consumed by reference; the Terraform execution identity needs iam:PassRole only on those specific ARNs, granted at the caller's discretion β€” this module never creates or assumes IAM roles itself.


πŸ“‹ AWS Prerequisites

  • Device certificates are metadata/lifecycle registration, not key custody. Prefer supplying csr so no private key ever transits Terraform state; letting the module generate a keypair is a lab/dev-only pattern.
  • logging_options, event_configurations, and indexing_configuration are ACCOUNT-WIDE SINGLETONS per Region. Only one module call per account/Region should manage each β€” a second caller fights the first over the same account-level setting. Document ownership in your root-module composition.
  • No service-linked role required for any in-scope IoT Core resource.
  • IoT endpoint is account/Region-specific β€” devices connect to iot:<random>.iot.<region>.amazonaws.com (or a custom domain via domain_configurations). This module does not create the endpoint (aws_iot_endpoint is a data source, out of scope); wire it via a data source in the root module.
  • Certificate/policy/thing quotas are soft account limits (e.g. 1M things, 10K thing types, 100K policies per account by default) β€” expect a quota-increase request for large fleets.
  • Custom domain configurations require ACM certificates provisioned in the resource's own Region β€” IoT Core data-plane domains are regional, unlike CloudFront. No us-east-1 requirement.
  • Registering a CA certificate requires a registration code (from the aws_iot_registration_code data source, out of scope) burned into a verification CSR when certificate_mode = "DEFAULT" β€” a manual out-of-band step before ca_certificate_pem / verification_certificate_pem can be supplied.

πŸ“ Module Structure

tf-mod-aws-iot-core/
β”œβ”€β”€ providers.tf # required_providers only β€” no provider {} block
β”œβ”€β”€ variables.tf # 19 role-named input maps + tags tail
β”œβ”€β”€ main.tf # 19 for_each resource blocks, no `this` keystone
β”œβ”€β”€ outputs.tf # role-named id/arn maps + tags_all
β”œβ”€β”€ README.md
└── SCOPE.md

βš™οΈ Quick Start

module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  things = {
    "line1-sensor-001" = {}
  }

  policies = {
    "device-default" = {
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Effect   = "Allow"
          Action   = ["iot:Connect", "iot:Publish", "iot:Subscribe", "iot:Receive"]
          Resource = "*"
        }]
      })
    }
  }

  certificates = {
    "line1-sensor-001-cert" = {
      active = true
      csr    = file("${path.module}/device-csrs/line1-sensor-001.csr")
    }
  }

  thing_principal_attachments = {
    "line1-sensor-001-attach" = {
      thing_key       = "line1-sensor-001"
      certificate_key = "line1-sensor-001-cert"
    }
  }

  policy_attachments = {
    "line1-sensor-001-policy" = {
      policy_key             = "device-default"
      target_certificate_key = "line1-sensor-001-cert"
    }
  }

  topic_rules = {
    "telemetry-to-lambda" = {
      enabled = true
      sql     = "SELECT * FROM 'factory/line1/telemetry'"
      actions = {
        forward = {
          lambda = { function_arn = module.telemetry_processor.arn } # tf-mod-aws-lambda
        }
      }
      error_action = {
        sqs = {
          queue_url  = module.telemetry_dlq.queue_url # tf-mod-aws-sqs
          role_arn   = module.iot_rule_role.arn       # tf-mod-aws-iam-role
          use_base64 = false
        }
      }
    }
  }

  tags = {
    environment = "prod"
    workload    = "factory-telemetry"
  }
}

πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
authorizers[*].authorizer_function_arn string (Lambda ARN) tf-mod-aws-lambda
topic_rules[*].actions[*].lambda.function_arn string (Lambda ARN) tf-mod-aws-lambda
topic_rules[*].actions[*].s3.bucket_name string tf-mod-aws-s3-bucket
topic_rules[*].actions[*].sns.target_arn string (SNS ARN) tf-mod-aws-sns
topic_rules[*].actions[*].sqs.queue_url string (SQS URL) tf-mod-aws-sqs
topic_rules[*].actions[*].kinesis.stream_name string tf-mod-aws-kinesis-stream
topic_rules[*].actions[*].firehose.delivery_stream_name string tf-mod-aws-kinesis-firehose
topic_rules[*].actions[*].dynamodb.table_name string tf-mod-aws-dynamodb
every role_arn argument string (IAM role ARN) tf-mod-aws-iam-role
topic_rule_destinations[*].vpc_configuration.subnet_ids / vpc_id list(string) / string tf-mod-aws-vpc
topic_rule_destinations[*].vpc_configuration.security_groups list(string) tf-mod-aws-security-group

Emits

This module output Feeds into
thing_ids / thing_arns thing_group_memberships, thing_principal_attachments (in-module), device provisioning tooling
thing_default_client_ids Device SDK bootstrap config
thing_type_ids / thing_type_arns things[*].thing_type_key wiring, audit
thing_group_ids / thing_group_arns thing_group_memberships (in-module), IAM policy conditions
thing_group_membership_ids Audit
thing_principal_attachment_ids Audit
billing_group_ids / billing_group_arns Cost allocation tooling
policy_arns / policy_names / policy_default_version_ids policy_attachments (in-module), audit
policy_attachment_ids Audit
certificate_ids / certificate_arns / certificate_pems thing_principal_attachments, policy_attachments (in-module)
certificate_private_keys (sensitive) Emergency device bootstrap only
ca_certificate_ids / ca_certificate_arns Device manufacturing/CA tooling
authorizer_arns domain_configurations.default_authorizer_key wiring
role_alias_arns Device SDK configuration
topic_rule_arns tf-mod-aws-cloudwatch-alarm, audit
topic_rule_destination_arns topic_rules[*].actions[*].http wiring
provisioning_template_arns / provisioning_template_default_version_ids Fleet-provisioning device SDK config
domain_configuration_arns / domain_configuration_domain_types DNS/CNAME wiring
logging_options_configured / event_configurations_configured / indexing_configuration_configured Ownership audit for the account-wide singletons
tags_all Governance/audit

πŸ“š Example Library (copy-paste)

1 Β· Minimal device registration (thing + policy + certificate)
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  things = {
    "sensor-001" = {}
  }

  policies = {
    "sensor-default" = {
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Effect   = "Allow"
          Action   = ["iot:Connect", "iot:Publish", "iot:Subscribe", "iot:Receive"]
          Resource = "*"
        }]
      })
    }
  }

  certificates = {
    "sensor-001-cert" = {
      active = true
      csr    = file("${path.module}/csrs/sensor-001.csr")
    }
  }

  thing_principal_attachments = {
    "sensor-001-attach" = {
      thing_key       = "sensor-001"
      certificate_key = "sensor-001-cert"
    }
  }

  policy_attachments = {
    "sensor-001-policy" = {
      policy_key             = "sensor-default"
      target_certificate_key = "sensor-001-cert"
    }
  }
}
2 Β· Thing types, groups, and hierarchy
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  thing_types = {
    "temperature-sensor" = {
      properties = {
        description           = "Line temperature sensors"
        searchable_attributes = ["line", "zone"]
      }
    }
  }

  thing_groups = {
    "factory-line1" = {
      properties = { description = "Line 1 devices" }
    }
  }

  things = {
    "sensor-001" = {
      attributes     = { line = "1", zone = "A" }
      thing_type_key = "temperature-sensor"
    }
  }

  thing_group_memberships = {
    "sensor-001-in-line1" = {
      thing_key       = "sensor-001"
      thing_group_key = "factory-line1"
    }
  }
}
3 Β· Topic rule routing telemetry to a Lambda
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  topic_rules = {
    "telemetry-to-lambda" = {
      enabled     = true
      sql         = "SELECT *, timestamp AS ingest_ts FROM 'factory/+/telemetry'"
      sql_version = "2016-03-23"

      actions = {
        process = {
          lambda = { function_arn = module.telemetry_lambda.arn } # tf-mod-aws-lambda
        }
      }

      error_action = {
        cloudwatch_metric = {
          metric_namespace = "IoTCore/RuleErrors"
          metric_name      = "telemetry-to-lambda"
          metric_value     = "1"
          metric_unit      = "Count"
          role_arn         = module.iot_rule_role.arn # tf-mod-aws-iam-role
        }
      }
    }
  }
}
4 Β· Topic rule routing to S3 with error_action to an SQS DLQ
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  topic_rules = {
    "telemetry-to-s3" = {
      enabled = true
      sql     = "SELECT * FROM 'factory/+/telemetry'"

      actions = {
        archive = {
          s3 = {
            bucket_name = module.telemetry_bucket.id # tf-mod-aws-s3-bucket
            key         = "$${topic}/$${timestamp}.json"
            role_arn    = module.iot_rule_role.arn # tf-mod-aws-iam-role
          }
        }
      }

      error_action = {
        sqs = {
          queue_url  = module.telemetry_dlq.queue_url # tf-mod-aws-sqs
          role_arn   = module.iot_rule_role.arn
          use_base64 = false
        }
      }
    }
  }
}
5 Β· Fleet provisioning template (JITP)
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  provisioning_templates = {
    "fleet-jitp" = {
      description           = "Just-in-time provisioning for line sensors"
      enabled               = true
      provisioning_role_arn = module.provisioning_role.arn # tf-mod-aws-iam-role
      template_type         = "FLEET_PROVISIONING"
      template_body = jsonencode({
        Parameters = {
          SerialNumber = { Type = "String" }
        }
        Resources = {
          thing = {
            Type = "AWS::IoT::Thing"
            Properties = {
              ThingName = { Ref = "SerialNumber" }
            }
          }
        }
      })

      pre_provisioning_hook = {
        target_arn = module.provisioning_hook_lambda.arn # tf-mod-aws-lambda
      }
    }
  }
}
6 Β· Custom Lambda-backed authorizer
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  authorizers = {
    "custom-jwt-auth" = {
      authorizer_function_arn = module.authorizer_lambda.arn # tf-mod-aws-lambda
      signing_disabled        = false
      status                  = "ACTIVE"
      token_key_name          = "x-amz-custom-authorization-token"
      token_signing_public_keys = {
        "key1" = file("${path.module}/keys/authorizer-public-key-1.pem")
      }
    }
  }
}
7 Β· Least-privilege policy scoped by thing name / client ID
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  policies = {
    "scoped-device-policy" = {
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [
          {
            Effect   = "Allow"
            Action   = "iot:Connect"
            Resource = "arn:aws:iot:*:*:client/$${iot:Connection.Thing.ThingName}"
          },
          {
            Effect   = "Allow"
            Action   = ["iot:Publish", "iot:Receive"]
            Resource = "arn:aws:iot:*:*:topic/factory/$${iot:Connection.Thing.ThingName}/*"
          },
          {
            Effect   = "Allow"
            Action   = "iot:Subscribe"
            Resource = "arn:aws:iot:*:*:topicfilter/factory/$${iot:Connection.Thing.ThingName}/*"
          }
        ]
      })
    }
  }
}
8 Β· tags merging with provider default_tags
# Root provider block (not part of this module):
# provider "aws" {
# default_tags {
# tags = { managed-by = "terraform", cost-center = "iot-platform" }
# }
# }

module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  policies = {
    "device-default" = {
      policy = jsonencode({ Version = "2012-10-17", Statement = [] })
      tags   = { component = "policy" } # per-item tag, merges over module tags
    }
  }

  tags = {
    environment = "prod"
    workload    = "factory-telemetry"
  }
}

# Resulting tags_all["policy::device-default"] contains managed-by, cost-center
# (from provider default_tags), environment, workload (module tags), and
# component (per-item tag) β€” resource-level tags win on any key conflict.
9 Β· Certificate lifecycle β€” CSR flow (no private key in state)
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  certificates = {
    "sensor-042-cert" = {
      active = true
      csr    = file("${path.module}/csrs/sensor-042.csr") # AWS signs; no private key ever generated by Terraform
    }
  }
}

# certificate_private_keys["sensor-042-cert"] will be an empty string β€” the
# module never generated a keypair for this entry.
10 Β· Secure-by-default opt-out β€” signing-disabled authorizer behind a private mTLS boundary
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  authorizers = {
    "vpc-internal-auth" = {
      authorizer_function_arn = module.internal_authorizer_lambda.arn
      # Opt-out of signature validation β€” ONLY acceptable because this
      # authorizer sits behind a private VPC endpoint with mutual TLS already
      # authenticating the caller. Document the compensating control here.
      signing_disabled = true
      status           = "ACTIVE"
    }
  }
}
11 Β· for_each pattern β€” a fleet of things from a list
locals {
  sensor_ids = ["sensor-001", "sensor-002", "sensor-003"]
}

module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  things = {
    for id in local.sensor_ids : id => {
      attributes = { fleet = "line1" }
    }
  }

  certificates = {
    for id in local.sensor_ids : "${id}-cert" => {
      active = true
      csr    = file("${path.module}/csrs/${id}.csr")
    }
  }

  thing_principal_attachments = {
    for id in local.sensor_ids : "${id}-attach" => {
      thing_key       = id
      certificate_key = "${id}-cert"
    }
  }
}
12 Β· Import an existing Thing and Policy
import {
  to = module.iot_core.aws_iot_thing.this["sensor-001"]
  id = "sensor-001"
}

import {
  to = module.iot_core.aws_iot_policy.this["device-default"]
  id = "device-default"
}
13 Β· Account-wide logging + indexing (own this exactly once per account/Region)
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  logging_options = {
    default_log_level = "ERROR"
    disable_all_logs  = false
    role_arn          = module.iot_logging_role.arn # tf-mod-aws-iam-role
  }

  indexing_configuration = {
    thing_indexing_configuration = {
      thing_indexing_mode = "REGISTRY_AND_SHADOW"
    }
    thing_group_indexing_configuration = {
      thing_group_indexing_mode = "ON"
    }
  }
}
14 Β· Private VPC HTTP topic-rule destination
module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  topic_rule_destinations = {
    "internal-api" = {
      vpc_configuration = {
        role_arn        = module.iot_destination_role.arn # tf-mod-aws-iam-role
        subnet_ids      = module.vpc.private_subnet_ids   # tf-mod-aws-vpc
        security_groups = [module.iot_endpoint_sg.id]     # tf-mod-aws-security-group
        vpc_id          = module.vpc.id
      }
    }
  }
}
15 Β· End-to-end composition β€” device fleet routing telemetry to Lambda, S3, and a DLQ
module "iot_rule_role" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iam-role?ref=v1.0.0"

  assume_role_policy = data.aws_iam_policy_document.iot_trust.json
  inline_policies = {
    "iot-rule-actions" = { policy = data.aws_iam_policy_document.iot_rule_actions.json }
  }
}

module "telemetry_bucket" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-s3-bucket?ref=v1.0.0"

  bucket = "casey-factory-telemetry"
}

module "telemetry_lambda" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-lambda?ref=v1.0.0"

  function_name = "telemetry-processor"
  #... runtime/handler/role wiring omitted for brevity
}

module "telemetry_dlq" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-sqs?ref=v1.0.0"

  name = "iot-telemetry-dlq"
}

module "iot_core" {
  source = "git::https://github.com/microsoftexpert/tf-mod-aws-iot-core?ref=v1.0.0"

  thing_types = {
    "line-sensor" = { properties = { description = "Factory line sensors" } }
  }

  thing_groups = {
    "factory-line1" = { properties = { description = "Line 1 device fleet" } }
  }

  things = {
    for id in ["sensor-001", "sensor-002"] : id => {
      thing_type_key = "line-sensor"
    }
  }

  thing_group_memberships = {
    for id in ["sensor-001", "sensor-002"] : "${id}-membership" => {
      thing_key       = id
      thing_group_key = "factory-line1"
    }
  }

  policies = {
    "device-default" = {
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Effect   = "Allow"
          Action   = ["iot:Connect", "iot:Publish", "iot:Subscribe", "iot:Receive"]
          Resource = "arn:aws:iot:*:*:*/$${iot:Connection.Thing.ThingName}/*"
        }]
      })
    }
  }

  certificates = {
    for id in ["sensor-001", "sensor-002"] : "${id}-cert" => {
      active = true
      csr    = file("${path.module}/csrs/${id}.csr")
    }
  }

  thing_principal_attachments = {
    for id in ["sensor-001", "sensor-002"] : "${id}-attach" => {
      thing_key       = id
      certificate_key = "${id}-cert"
    }
  }

  policy_attachments = {
    for id in ["sensor-001", "sensor-002"] : "${id}-policy" => {
      policy_key             = "device-default"
      target_certificate_key = "${id}-cert"
    }
  }

  topic_rules = {
    "telemetry-fanout" = {
      enabled = true
      sql     = "SELECT * FROM 'factory/line1/+/telemetry'"

      actions = {
        process = { lambda = { function_arn = module.telemetry_lambda.arn } }
        archive = {
          s3 = {
            bucket_name = module.telemetry_bucket.id
            key         = "$${topic}/$${timestamp}.json"
            role_arn    = module.iot_rule_role.arn
          }
        }
      }

      error_action = {
        sqs = {
          queue_url  = module.telemetry_dlq.queue_url
          role_arn   = module.iot_rule_role.arn
          use_base64 = false
        }
      }
    }
  }

  tags = {
    environment = "prod"
    workload    = "factory-telemetry"
  }
}

πŸ“₯ Inputs

Registry: things, thing_types, thing_groups, thing_group_memberships, thing_principal_attachments, billing_groups

Security: policies, policy_attachments, certificates, ca_certificates, authorizers, role_aliases

Routing & provisioning: topic_rules, topic_rule_destinations, provisioning_templates, domain_configurations

Account-wide singletons: logging_options, event_configurations, indexing_configuration

Universal tail: tags (map(string), default {})

No separate global timeouts variable β€” of the 19 in-scope resources, only aws_iot_topic_rule_destination supports a timeouts block, so create/update/delete is modeled as a per-item field on topic_rule_destinations rather than a module-wide input.


🧾 Outputs

  • thing_ids, thing_arns, thing_default_client_ids
  • thing_type_ids, thing_type_arns
  • thing_group_ids, thing_group_arns
  • thing_group_membership_ids, thing_principal_attachment_ids
  • billing_group_ids, billing_group_arns
  • policy_arns, policy_names, policy_default_version_ids, policy_attachment_ids
  • certificate_ids, certificate_arns, certificate_pems
  • certificate_private_keys β€” sensitive; empty string except for entries where the module generated a keypair
  • ca_certificate_ids, ca_certificate_arns
  • authorizer_arns, role_alias_arns
  • topic_rule_arns, topic_rule_destination_arns
  • provisioning_template_arns, provisioning_template_default_version_ids
  • domain_configuration_arns, domain_configuration_domain_types
  • logging_options_configured, event_configurations_configured, indexing_configuration_configured β€” booleans flagging whether this module call owns each account-wide singleton
  • tags_all β€” map of "<resource_type>::<key>" => merged tags; untaggable resource types are not represented

🧠 Architecture Notes

  • ARN formats: arn:aws:iot:<region>:<account>:thing/<name>, .../thinggroup/<name>, .../policy/<name>, .../cert/<id>, .../authorizer/<name>, .../rolealias/<name>, .../rule/<name>, .../provisioningtemplate/<name>.
  • Immutable / force-new: things[*].name (the map key), thing_types[*] name and properties.description / properties.searchable_attributes, thing_groups[*].name, every certificates[*] field (certificates cannot be updated in place β€” active toggling is the one mutable field), and every resource's map key in general since names double as Terraform keys.
  • tags ↔ tags_all ↔ provider default_tags: taggable resources (thing_types, thing_groups, billing_groups, policies, ca_certificates, authorizers, role_aliases, topic_rules, provisioning_templates, domain_configurations) merge module tags, per-item tags, and provider default_tags β€” resource-level tags win on key conflict. things, thing_group_memberships, thing_principal_attachments, policy_attachments, certificates, topic_rule_destinations, and the three account-wide singletons are not taggable in the AWS provider.
  • Eventual consistency: newly attached certificates/policies can take a few seconds to propagate to the MQTT broker; a device connecting immediately after apply may see a transient auth failure.
  • Destroy ordering: aws_iot_policy_attachment / aws_iot_thing_principal_attachment must be destroyed before the certificate or policy they reference; using this module's *_key cross-references (rather than literal ARNs) lets Terraform's implicit dependency graph handle this automatically.
  • No us-east-1 global-resource requirement β€” IoT Core (including custom domain configurations) is fully regional.
  • Intra-module self-reference limitation: thing_groups[*].parent_group_name cannot be resolved from a sibling map key (that would create a Terraform dependency cycle on aws_iot_thing_group.this referencing itself) β€” supply a literal parent name and apply parents before children if nesting groups created by this same module call.

🧱 Design Principles

  • No keystone. Things, Policies, Certificates, and Topic Rules are independent AWS resources with no inherent parent/child Terraform relationship β€” every resource is named by role and exposed as its own for_each map, per this module suite's aggregation-module exception.
  • error_action is required, not optional. Opt-out: there is none by design β€” every topic rule must configure exactly one error destination (point it at a CloudWatch Logs group or SQS DLQ).
  • sql_version defaults to "2016-03-23" (current GA). Opt-out: set "2015-10-08" or "beta" explicitly.
  • authorizers.signing_disabled defaults to false (token signature validated). Opt-out: true, only for authorizers behind a private VPC/mTLS boundary that already authenticates the caller.
  • role_aliases.credential_duration defaults to 3600 (1h). Opt-out: raise up to 43200 (12h) only when a workload genuinely needs it.
  • domain_configurations.tls_config.security_policy defaults to IoTSecurityPolicy_TLS1_2_2018_06 (strongest available). Opt-out: relax only for documented legacy-device compatibility.
  • ca_certificates.active / allow_auto_registration have no default β€” both are required fields so a caller must consciously enable device auth and auto-registration for a CA.
  • Certificate private-key emission is empty by default. Opt-out: omit both csr and certificate_pem to let the module generate a keypair (lab/dev only) β€” certificate_private_keys then surfaces the key, marked sensitive.
  • Topic-rule action surface is intentionally scoped to 11 of ~18 possible action types (s3, sns, sqs, lambda, dynamodb, cloudwatch_metric, cloudwatch_alarm, kinesis, firehose, republish, http). Extend the shared action object type in variables.tf and the matching dynamic blocks in main.tf for cloudwatch_logs, dynamodbv2, elasticsearch, iot_analytics, iot_events, kafka, step_functions, or timestream when a caller needs one.
  • Account-wide singletons are single optional objects, not maps β€” there can only ever be one logging_options / event_configurations / indexing_configuration per account/Region.

πŸš€ Runbook

cd tf-mod-aws-iot-core
terraform init -backend=false
terraform validate
terraform fmt -check

⚠️ terraform plan/apply require valid AWS credentials (profile / SSO / OIDC) and a Region. Pin the module source with ?ref=v1.0.0 β€” never a branch β€” so a re-plan never silently picks up unreviewed changes.

terraform plan
terraform apply
terraform output

πŸ§ͺ Testing

  • terraform init -backend=false && terraform validate && terraform fmt -check β€” the offline structural gate; both pass with zero errors/diffs as of this authoring session.
  • tflint (AWS ruleset) and checkov via the shared validate_all_modules.ps1 harness for static security/policy checks.
  • No terraform apply is performed by this authoring process β€” an accountable human runs apply in a non-production account with a least-privilege role.

πŸ’¬ Example Output

$ terraform apply

Plan: 7 to add, 0 to change, 0 to destroy.

Outputs:

thing_arns = {
 "sensor-001" = "arn:aws:iot:us-east-2:123456789012:thing/sensor-001"
}
policy_arns = {
 "device-default" = "arn:aws:iot:us-east-2:123456789012:policy/device-default"
}
certificate_arns = {
 "sensor-001-cert" = "arn:aws:iot:us-east-2:123456789012:cert/ab12cd34ef56..."
}

πŸ” Troubleshooting

  • Tag drift from default_tags overlap β€” a key set both in the provider's default_tags and in this module's tags (or a per-item tags) will show perpetual plan diffs if the values differ; resource-level tags always win. Keep the same key in only one place, or make the values match.
  • Credential-chain failures at plan/apply β€” terraform validate never touches AWS, but plan needs a resolvable credential chain (AWS_PROFILE, SSO session, or OIDC role). Run aws sts get-caller-identity to confirm the chain resolves before troubleshooting the module itself.
  • AccessDenied on iot:CreateTopicRule / iot:CreateRoleAlias β€” check the iam:PassRole grant on the specific role_arn used; this module never widens IAM permissions itself.
  • Device connects but gets disconnected immediately β€” usually a certificates.active = false toggle that just propagated, or a policy_attachments entry missing for that certificate. Confirm both the principal attachment and the policy attachment exist for the certificate the device presents.
  • aws_iot_thing_group apply fails with "group not found" for a nested parent β€” this module cannot express an intra-module dependency for parent_group_name referencing a sibling created in the same call (see Architecture Notes); apply the parent group first, or reference an externally-managed parent.
  • Second apply from a different root module keeps flipping logging_options/event_configurations/indexing_configuration β€” these are account-wide singletons with no delete API; two module calls managing the same one will fight forever. Assign single ownership.
  • aws_iot_ca_certificate apply fails demanding verification_certificate_pem β€” required whenever certificate_mode = "DEFAULT" (the default); obtain the registration code via the aws_iot_registration_code data source and build the verification CSR out of band first.

πŸ”— Related Docs

  • Terraform Registry: AWS IoT Core resources (aws_iot_thing, aws_iot_policy, aws_iot_certificate, aws_iot_topic_rule, and siblings)
  • AWS IoT Core Developer Guide β€” Device provisioning, Rules Engine, IoT SQL reference
  • AWS IoT Core security best practices β€” X.509 certificate lifecycle, custom authorizers
  • SCOPE.md (this module) β€” in/out-of-scope resources, Required IAM permissions, AWS Prerequisites

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

Packages

 
 
 

Contributors

Languages