Terraform for IoT Infrastructure: Provisioning AWS IoT Core, Lambda, and InfluxDB as Code
The first time you manually provision an IoT system in AWS, you click through 15 console screens, create 6 IAM policies, upload 3 certificates, and configure a Rules Engine rule. Three months later, when you need to replicate the environment for staging, you discover that nobody documented the exact steps. Two IAM policies are missing, the certificate chain is wrong, and the Lambda environment variable pointing to the right DynamoDB table is absent.
Infrastructure as Code with Terraform is not optional for production IoT systems. It is the difference between a reproducible environment and a snowflake that only one engineer understands.
IoT Infrastructure Components Worth Codifying
Before writing HCL, audit what you need:
Project Structure
terraform/
├── main.tf # Root module — calls child modules
├── variables.tf
├── outputs.tf
├── backend.tf # S3 remote state
├── modules/
│ ├── iot-core/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── lambda-processor/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── storage/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── environments/
├── staging/
│ └── terraform.tfvars
└── production/
└── terraform.tfvars
S3 Remote State Backend
Always use remote state. Local terraform.tfstate files get deleted, corrupted, or committed to git with secrets inside.
backend.tf
terraform {
required_version = ">= 1.6.0" required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "your-company-terraform-state"
key = "iot-platform/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
AWS IoT Core Module
modules/iot-core/main.tf
resource "aws_iot_thing_type" "sensor_node" {
name = "${var.environment}-sensor-node"
properties {
description = "Environmental sensor node (temperature, humidity, pressure)"
searchable_attributes = ["firmwareVersion", "hardwareRevision", "location"]
}
}
resource "aws_iot_policy" "device_policy" {
name = "${var.environment}-device-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["iot:Connect"]
Resource = "arn:aws:iot:${var.aws_region}:${var.account_id}:client/${iot:ClientId}"
Condition = {
Bool = { "iot:Connection.Thing.IsAttached" = "true" }
}
},
{
Effect = "Allow"
Action = ["iot:Publish"]
Resource = [
"arn:aws:iot:${var.aws_region}:${var.account_id}:topic/devices/${iot:ClientId}/telemetry",
"arn:aws:iot:${var.aws_region}:${var.account_id}:topic/devices/${iot:ClientId}/status",
]
},
{
Effect = "Allow"
Action = ["iot:Subscribe", "iot:Receive"]
Resource = [
"arn:aws:iot:${var.aws_region}:${var.account_id}:topicfilter/devices/${iot:ClientId}/commands",
"arn:aws:iot:${var.aws_region}:${var.account_id}:topic/devices/${iot:ClientId}/commands",
]
},
{
Effect = "Allow"
Action = ["iot:GetThingShadow", "iot:UpdateThingShadow", "iot:DeleteThingShadow"]
Resource = "arn:aws:iot:${var.aws_region}:${var.account_id}:thing/${iot:ClientId}"
}
]
})
}
resource "aws_iot_topic_rule" "telemetry_to_lambda" {
name = "${var.environment}_telemetry_processor"
enabled = true
sql = "SELECT *, topic(3) as deviceId, timestamp() as serverTs FROM 'devices/+/telemetry'"
sql_version = "2016-03-23"
lambda {
function_arn = var.telemetry_lambda_arn
}
error_action {
sqs {
queue_url = var.dlq_url
use_base64 = false
role_arn = aws_iam_role.iot_rule_role.arn
}
}
}
Lambda + DynamoDB Module
modules/lambda-processor/main.tf
resource "aws_dynamodb_table" "device_telemetry" {
name = "${var.environment}-device-telemetry"
billing_mode = "PAY_PER_REQUEST"
hash_key = "pk"
range_key = "sk"
attribute {
name = "pk"
type = "S"
}
attribute {
name = "sk"
type = "S"
}
ttl {
attribute_name = "ttl"
enabled = true
}
point_in_time_recovery {
enabled = var.environment == "production"
}
tags = var.common_tags
}
resource "aws_lambda_function" "telemetry_processor" {
function_name = "${var.environment}-telemetry-processor"
role = aws_iam_role.lambda_exec.arn
handler = "index.handler"
runtime = "nodejs20.x"
timeout = 30
memory_size = 256
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.device_telemetry.name
ENVIRONMENT = var.environment
ALERT_TOPIC = aws_sns_topic.device_alerts.arn
}
}
reserved_concurrent_executions = var.environment == "production" ? 100 : 10
tags = var.common_tags
}
resource "aws_lambda_permission" "allow_iot" {
statement_id = "AllowIoTCoreInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.telemetry_processor.function_name
principal = "iot.amazonaws.com"
source_arn = var.iot_rule_arn
}
CI/CD with Terraform Cloud
Store your Terraform plan and apply steps in GitHub Actions, gated by environment:
.github/workflows/terraform.yml
name: Terraformon:
push:
branches: [main]
paths: ['terraform/**']
pull_request:
paths: ['terraform/**']
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.6.6
- name: Terraform Init
run: terraform init
working-directory: terraform/environments/staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Terraform Plan
run: terraform plan -out=tfplan
working-directory: terraform/environments/staging
- name: Upload Plan
uses: actions/upload-artifact@v4
with:
name: tfplan
path: terraform/environments/staging/tfplan
apply:
needs: plan
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: staging # requires manual approval in GitHub Environments
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Download Plan
uses: actions/download-artifact@v4
with:
name: tfplan
path: terraform/environments/staging
- name: Terraform Apply
run: terraform apply tfplan
working-directory: terraform/environments/staging
The key discipline: never run terraform apply manually against production. Every change goes through a pull request, generates a plan for review, and applies only after approval. One accidental terraform destroy on a 10,000-device fleet is a career-defining event for the wrong reasons.
State Management Tips
---
Need help structuring your IoT infrastructure as code from day one? [Reach out to Code Caracal](/contact) — we deliver Terraform-managed IoT infrastructure as part of every backend engagement.