Back to Blog
Cloud & DevOps

Terraform for IoT Infrastructure: Provisioning AWS IoT Core, Lambda, and InfluxDB as Code

Clicking through the AWS console to provision IoT resources is a trap. One missing policy, one wrong certificate rotation, one undocumented resource — and your next engineer cannot reproduce the environment. Here is how we codify everything with Terraform.

September 5, 2024
14 min read
TerraformAWS IoT CoreInfrastructure as CodeLambda

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:

  • AWS IoT Core: Thing types, thing groups, certificate authorities, policies, topic rules
  • Compute: Lambda functions, ECS task definitions, ECR repositories
  • Storage: DynamoDB tables (device state, telemetry), S3 buckets (firmware binaries, device certificates)
  • Networking: VPC, subnets, security groups for private backends
  • Monitoring: CloudWatch log groups, alarms, SNS topics for alerts
  • Secrets: AWS Secrets Manager entries for database passwords and API keys
  • 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: Terraform

    on: 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

  • Use separate state files per environment (staging, production) — never share state
  • Enable S3 versioning on your state bucket so you can roll back accidental state corruption
  • Use DynamoDB for state locking — this prevents two engineers from applying simultaneously
  • Rotate your Terraform Cloud API tokens quarterly
  • ---

    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.

    ⚡
    Written by CodeCaracal Engineering

    We write from production experience — every technique in our articles has been deployed to real clients. No academic theory.

    Let's Build Together

    Got an IoT challenge?
    We've shipped it.

    Whether you need a fleet to track, a factory to monitor, or a farm to automate — our team has done it before and we'd love to build it with you. Typical response time: under 24 hours.

    No upfront commitment99.9% uptime SLANDA on requestFixed-price options