Docs / Use Cases / Terraform Infrastructure Testing

Terraform Infrastructure Testing

Write, test, and iterate on Terraform configurations locally. Run plan/apply/destroy cycles in seconds instead of minutes. No AWS account required.

The Problem

Terraform against real AWS is slow by nature. Every terraform apply makes API calls to AWS, waits for resources to provision, and polls for stabilization. The numbers add up fast:

terraform plan

State refresh is the slowest part: every resource costs one DescribeX round-trip. The runtime scales with resource count, not with the diff you actually want to see.

terraform apply

Each resource waits for AWS to provision and stabilise. Containerised workloads (ECS services, Lambda, EKS nodegroups) and anything with a wait_for_steady_state semantic dominate the wall time.

State lock conflicts

Two engineers running plan against the same remote state serialise on the lock. Long applies turn this into a real bottleneck for small teams sharing one environment.

Cost of iteration

Every apply creates billable resources. Forget to destroy after testing and the stack quietly accumulates charges in a sandbox account.

Net effect: a 10-minute plan/apply cycle is a handful of iterations per hour. That is fine for a release. It is brutal when you are iterating on the Terraform itself.

The Solution

Point your Terraform AWS provider at LocalEmu. Resources are created locally in seconds. Plan, apply, destroy, and re-apply as many times as you want with zero cost and near-instant feedback.

Operation Real AWS (indicative) LocalEmu (measured, range across the 8 end-to-end tutorials on this site)
terraform plan seconds to many minutes with scale ~1 s
terraform apply minutes, grows with resource count 6 - 46 s (dominated by Lambda image pull and SQS queue creation)
terraform destroy minutes 3 - 30 s
Full round-trip (deploy + tests + destroy) 10+ minutes for a non-trivial stack 11 - 90 s

The LocalEmu range above is the span across our eight end-to-end tutorials (8 to 19 resources each). Smallest stacks finish a full round-trip in about 11 seconds; the largest, a Stripe-style ledger with heavy SQS usage, takes about 90 seconds because each SQS queue creation is ~25 seconds on LocalEmu. See the Use Cases index for per-tutorial measured numbers.

Provider Configuration for LocalEmu

The provider block tells Terraform to send all API calls to LocalEmu instead of real AWS. The skip_* options prevent Terraform from trying to validate credentials or query the EC2 metadata service.

provider.tf
terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
    }
    archive = {
      source = "hashicorp/archive"
    }
    local = {
      source = "hashicorp/local"
    }
  }
}

provider "aws" {
  access_key                  = "AKIAIOSFODNN7EXAMPLE"
  secret_key                  = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
  region                      = "us-east-1"
  s3_use_path_style           = true
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    s3             = "http://localhost:4566"
    dynamodb       = "http://localhost:4566"
    sqs            = "http://localhost:4566"
    sns            = "http://localhost:4566"
    lambda         = "http://localhost:4566"
    iam            = "http://localhost:4566"
    sts            = "http://localhost:4566"
    cloudwatch     = "http://localhost:4566"
    logs           = "http://localhost:4566"    # CloudWatch Logs
    secretsmanager = "http://localhost:4566"
    ssm            = "http://localhost:4566"
    stepfunctions  = "http://localhost:4566"
    kinesis        = "http://localhost:4566"
    ses            = "http://localhost:4566"
    kms            = "http://localhost:4566"
    events         = "http://localhost:4566"    # EventBridge
    apigateway     = "http://localhost:4566"    # REST APIs (v1)
    apigatewayv2   = "http://localhost:4566"    # HTTP + WebSocket APIs
    cloudtrail     = "http://localhost:4566"
    scheduler      = "http://localhost:4566"    # EventBridge Scheduler
  }
}

Every endpoint points to http://localhost:4566, which is the single gateway for all AWS services in LocalEmu. You do not need to run different ports for different services.

Multi-Resource Example

Here is a realistic Terraform configuration that creates an S3 bucket with versioning, a DynamoDB table with a GSI, an SQS queue with a dead-letter queue, and a Lambda function. This is the kind of stack that takes 10-15 minutes to apply against real AWS.

main.tf
# Uses the provider defined in provider.tf

# --- S3 bucket for file uploads ---
resource "aws_s3_bucket" "uploads" {
  bucket = "app-uploads"
}

resource "aws_s3_bucket_versioning" "uploads" {
  bucket = aws_s3_bucket.uploads.id
  versioning_configuration {
    status = "Enabled"
  }
}

# --- DynamoDB table for metadata ---
resource "aws_dynamodb_table" "metadata" {
  name         = "file-metadata"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "file_id"
  range_key    = "uploaded_at"

  attribute {
    name = "file_id"
    type = "S"
  }

  attribute {
    name = "uploaded_at"
    type = "S"
  }

  global_secondary_index {
    name            = "by-upload-date"
    hash_key        = "uploaded_at"
    projection_type = "ALL"
  }
}

# --- SQS queue for async processing ---
resource "aws_sqs_queue" "processing_dlq" {
  name = "file-processing-dlq"
}

resource "aws_sqs_queue" "processing" {
  name                       = "file-processing"
  visibility_timeout_seconds = 300
  message_retention_seconds  = 86400

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.processing_dlq.arn
    maxReceiveCount     = 3
  })
}

# --- Lambda function for processing ---
resource "aws_iam_role" "lambda_role" {
  name = "file-processor-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

# Create the Lambda handler inline
resource "local_file" "lambda_handler" {
  filename = "handler.py"
  content  = <<-EOF
import json
def process(event, context):
    print(f"Processing: {json.dumps(event)}")
    return {"statusCode": 200, "body": "processed"}
EOF
}

# Package it as a zip
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_file = local_file.lambda_handler.filename
  output_path = "lambda.zip"
}

resource "aws_lambda_function" "processor" {
  function_name    = "file-processor"
  role             = aws_iam_role.lambda_role.arn
  handler          = "handler.process"
  runtime          = "python3.12"
  filename         = data.archive_file.lambda_zip.output_path
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 60
  memory_size      = 256

  environment {
    variables = {
      TABLE_NAME = aws_dynamodb_table.metadata.name
      QUEUE_URL  = aws_sqs_queue.processing.url
    }
  }
}

# --- Outputs ---
output "bucket_name" {
  value = aws_s3_bucket.uploads.id
}

output "table_name" {
  value = aws_dynamodb_table.metadata.name
}

output "queue_url" {
  value = aws_sqs_queue.processing.url
}

output "lambda_arn" {
  value = aws_lambda_function.processor.arn
}

The init/plan/apply/destroy Cycle

The full Terraform lifecycle works against LocalEmu exactly as it does against real AWS.

# Make sure LocalEmu is running
$ localemu start

# Initialize Terraform (only needed once)
$ terraform init

# Preview what will be created
$ terraform plan
Plan: 8 to add, 0 to change, 0 to destroy.

# Apply the configuration (creates all resources locally)
$ terraform apply -auto-approve
Apply complete! Resources: 8 added, 0 changed, 0 destroyed.
Outputs:
  bucket_name = "app-uploads"
  table_name  = "file-metadata"
  queue_url   = "http://sqs.us-east-1.localhost:4566/000000000000/file-processing"

# Verify resources exist
$ awsemu s3 ls
2026-04-06 10:00:00 app-uploads

$ awsemu dynamodb list-tables
{"TableNames": ["file-metadata"]}

# Make changes to main.tf, then re-apply
$ terraform apply -auto-approve

# When done, tear everything down
$ terraform destroy -auto-approve
Destroy complete! Resources: 8 destroyed.

The entire cycle from init to destroy completes in under 10 seconds. You can iterate on your Terraform code the same way you iterate on application code: change, apply, verify, repeat.

Switching Between LocalEmu and Real AWS

Use Terraform variable files (.tfvars) to switch between LocalEmu and real AWS without modifying your Terraform code.

variables.tf
variable "endpoint_url" {
  description = "Custom endpoint URL for LocalEmu. Set to null for real AWS."
  type        = string
  default     = null
}

variable "access_key" {
  description = "AWS access key. For LocalEmu use AKIAIOSFODNN7EXAMPLE (treated as a root key by default ROOT_ACCESS_KEYS)."
  type        = string
  default     = null
}

variable "secret_key" {
  description = "AWS secret key. For LocalEmu use wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY."
  type        = string
  default     = null
}

variable "skip_credentials_validation" {
  type    = bool
  default = false
}

variable "skip_metadata_api_check" {
  type    = bool
  default = false
}

variable "skip_requesting_account_id" {
  type    = bool
  default = false
}

provider "aws" {
  region                      = "us-east-1"
  access_key                  = var.access_key
  secret_key                  = var.secret_key
  skip_credentials_validation = var.skip_credentials_validation
  skip_metadata_api_check     = var.skip_metadata_api_check
  skip_requesting_account_id  = var.skip_requesting_account_id

  dynamic "endpoints" {
    for_each = var.endpoint_url != null ? [1] : []
    content {
      s3             = var.endpoint_url
      dynamodb       = var.endpoint_url
      sqs            = var.endpoint_url
      sns            = var.endpoint_url
      lambda         = var.endpoint_url
      iam            = var.endpoint_url
    }
  }
}

Then create separate variable files for each environment:

localemu.tfvars / aws.tfvars
# --- localemu.tfvars ---
endpoint_url                = "http://localhost:4566"
access_key                  = "AKIAIOSFODNN7EXAMPLE"
secret_key                  = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
skip_credentials_validation = true
skip_metadata_api_check     = true
skip_requesting_account_id  = true

# --- aws.tfvars ---
endpoint_url                = null
access_key                  = null
secret_key                  = null
skip_credentials_validation = false
skip_metadata_api_check     = false
skip_requesting_account_id  = false
# Develop locally
$ terraform apply -var-file="localemu.tfvars" -auto-approve

# Deploy to real AWS when ready
$ terraform apply -var-file="aws.tfvars"

The same Terraform files work in both environments. The only difference is which variable file you pass. Your CI pipeline can use localemu.tfvars for validation and aws.tfvars for actual deployments.

Testing Terraform Modules Locally

If you build reusable Terraform modules, LocalEmu lets you test them rapidly. Create a test configuration that calls your module, apply it against LocalEmu, and verify the outputs.

# Test a module locally before using it in production

# Directory structure:
# modules/
#   storage/
#     main.tf
#     variables.tf
#     outputs.tf
# test/
#   main.tf        <-- uses the module with LocalEmu provider

$ cd test/
$ terraform init
$ terraform apply -auto-approve

# Verify the module created what it should
$ awsemu s3 ls
$ awsemu dynamodb list-tables

# Test with different variable combinations
$ terraform apply -auto-approve -var="environment=staging"
$ terraform destroy -auto-approve
$ terraform apply -auto-approve -var="environment=production" -var="enable_encryption=true"
$ terraform destroy -auto-approve

# Each cycle takes seconds, not minutes

This is especially powerful for testing modules with different variable combinations. Against real AWS, testing three variable combinations means three 10-minute apply cycles. Against LocalEmu, it takes under a minute total.

Tips for Terraform with LocalEmu

Use local state during development

When working against LocalEmu, you do not need a remote state backend. Local state is fine for development. Switch to S3 backend when deploying to real AWS.

Prefer destroy + apply over update

Since LocalEmu apply is so fast, it is often quicker to destroy and re-create than to update in place. This also avoids edge cases in update logic.

Validate in CI

Add a CI step that runs terraform plan and terraform apply against LocalEmu. This catches configuration errors before they reach real AWS. See the CI/CD guide.

Check the Terraform integration docs

For more details on provider configuration and supported resources, see the Terraform integration guide.