IaC AWS

Terraform Module: AWS EventBridge — Event-Driven Routing as Reusable Code

Quick take — Build a reusable Terraform module for AWS EventBridge using aws_cloudwatch_event_rule — custom buses, content-filtered event patterns, dead-letter queues, and least-privilege target wiring for hashicorp/aws ~> 5.0. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "eventbridge" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-eventbridge?ref=v1.0.0"

  rule_name = "..."  # Name of the rule; unique per bus; 1-64 chars validated.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon EventBridge is AWS’s serverless event bus. Services, SaaS partners, and your own applications publish JSON events to a bus, and EventBridge matches each event against rules — declarative content filters expressed as event patterns — then fans the matching events out to one or more targets (Lambda, SQS, Step Functions, Kinesis, another bus, and dozens more). It is the connective tissue of event-driven architecture on AWS: producers and consumers never reference each other, they only agree on the shape of an event.

The catch is that a production-grade rule is never just a rule. You almost always need a dedicated custom bus (so your domain events do not collide with the shared default bus), an EventPattern precise enough to avoid invoking targets on noise, an IAM role that lets EventBridge assume permission to deliver, a dead-letter queue so a failing target does not silently drop events, and a retry policy. Hand-writing those five interlocking resources for every rule is repetitive and easy to get subtly wrong — especially the IAM trust policy and the DLQ resource policy.

This module wraps aws_cloudwatch_event_rule together with its bus, targets, delivery IAM role, and an optional SQS dead-letter queue behind a small, var-driven interface. You describe what event to match and where to send it; the module produces a correct, least-privilege, observable rule every time.

When to use it

Reach for EventBridge Pipes or Step Functions instead when you need point-to-point stream transformation with enrichment, or long-running stateful orchestration — those are different tools.

Module structure

terraform-module-aws-eventbridge/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # bus, rule, targets, delivery IAM role, DLQ
├── variables.tf     # typed, validated inputs
└── outputs.tf       # rule/bus ids + names + DLQ arn

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # When create_bus is true we own the bus; otherwise target an existing
  # bus name supplied by the caller (e.g. "default" or a shared bus).
  event_bus_name = var.create_bus ? aws_cloudwatch_event_bus.this[0].name : var.event_bus_name

  # EventBridge requires exactly one of event_pattern / schedule_expression.
  is_scheduled = var.schedule_expression != null

  # Only stand up a delivery role if at least one target needs one.
  needs_delivery_role = length([
    for t in var.targets : t if t.role_required
  ]) > 0
}

# ---------------------------------------------------------------------------
# Custom event bus (optional)
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_event_bus" "this" {
  count = var.create_bus ? 1 : 0

  name = var.bus_name
  tags = var.tags
}

# ---------------------------------------------------------------------------
# Dead-letter queue for undeliverable target invocations (optional)
# ---------------------------------------------------------------------------
resource "aws_sqs_queue" "dlq" {
  count = var.enable_dlq ? 1 : 0

  name                      = "${var.rule_name}-dlq"
  message_retention_seconds = var.dlq_retention_seconds
  sqs_managed_sse_enabled   = true
  tags                      = var.tags
}

# Allow only this rule's events to be sent to the DLQ.
data "aws_iam_policy_document" "dlq" {
  count = var.enable_dlq ? 1 : 0

  statement {
    sid       = "AllowEventBridgeDLQ"
    effect    = "Allow"
    actions   = ["sqs:SendMessage"]
    resources = [aws_sqs_queue.dlq[0].arn]

    principals {
      type        = "Service"
      identifiers = ["events.amazonaws.com"]
    }

    condition {
      test     = "ArnEquals"
      variable = "aws:SourceArn"
      values   = [aws_cloudwatch_event_rule.this.arn]
    }
  }
}

resource "aws_sqs_queue_policy" "dlq" {
  count = var.enable_dlq ? 1 : 0

  queue_url = aws_sqs_queue.dlq[0].id
  policy    = data.aws_iam_policy_document.dlq[0].json
}

# ---------------------------------------------------------------------------
# The rule itself
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_event_rule" "this" {
  name           = var.rule_name
  description    = var.description
  event_bus_name = local.event_bus_name
  state          = var.state

  # Exactly one of these is set; the variable validations enforce that.
  event_pattern       = var.event_pattern
  schedule_expression = var.schedule_expression

  tags = var.tags
}

# ---------------------------------------------------------------------------
# IAM role EventBridge assumes to deliver to targets that require one
# (e.g. Step Functions, Kinesis, ECS RunTask). Lambda/SQS use resource
# policies instead and do not need this role.
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "assume" {
  count = local.needs_delivery_role ? 1 : 0

  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["events.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "delivery" {
  count = local.needs_delivery_role ? 1 : 0

  name                 = "${var.rule_name}-delivery"
  assume_role_policy   = data.aws_iam_policy_document.assume[0].json
  permissions_boundary = var.permissions_boundary_arn
  tags                 = var.tags
}

# Grant the delivery role permission to invoke exactly the target ARNs
# that requested a role. Action set is scoped per target type by the caller.
data "aws_iam_policy_document" "delivery" {
  count = local.needs_delivery_role ? 1 : 0

  statement {
    effect = "Allow"
    actions = distinct(flatten([
      for t in var.targets : t.role_actions if t.role_required
    ]))
    resources = distinct([
      for t in var.targets : t.arn if t.role_required
    ])
  }
}

resource "aws_iam_role_policy" "delivery" {
  count = local.needs_delivery_role ? 1 : 0

  name   = "${var.rule_name}-delivery"
  role   = aws_iam_role.delivery[0].id
  policy = data.aws_iam_policy_document.delivery[0].json
}

# ---------------------------------------------------------------------------
# Targets — one aws_cloudwatch_event_target per entry in var.targets
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_event_target" "this" {
  for_each = { for t in var.targets : t.target_id => t }

  rule           = aws_cloudwatch_event_rule.this.name
  event_bus_name = local.event_bus_name
  target_id      = each.value.target_id
  arn            = each.value.arn

  # Use the shared delivery role only for targets that asked for one.
  role_arn = each.value.role_required ? aws_iam_role.delivery[0].arn : null

  # Optional static JSON sent instead of the matched event.
  input = each.value.input

  dynamic "input_transformer" {
    for_each = each.value.input_transformer == null ? [] : [each.value.input_transformer]
    content {
      input_paths    = input_transformer.value.input_paths
      input_template = input_transformer.value.input_template
    }
  }

  dynamic "dead_letter_config" {
    for_each = var.enable_dlq ? [1] : []
    content {
      arn = aws_sqs_queue.dlq[0].arn
    }
  }

  retry_policy {
    maximum_event_age_in_seconds = var.maximum_event_age_in_seconds
    maximum_retry_attempts       = var.maximum_retry_attempts
  }
}

variables.tf

variable "rule_name" {
  type        = string
  description = "Name of the EventBridge rule. Must be unique per event bus."

  validation {
    condition     = can(regex("^[A-Za-z0-9._-]{1,64}$", var.rule_name))
    error_message = "rule_name must be 1-64 chars: letters, digits, '.', '_' or '-'."
  }
}

variable "description" {
  type        = string
  description = "Human-readable description of what the rule matches."
  default     = "Managed by Terraform"
}

variable "create_bus" {
  type        = bool
  description = "Create a dedicated custom event bus. When false, use event_bus_name."
  default     = true
}

variable "bus_name" {
  type        = string
  description = "Name of the custom bus to create when create_bus is true."
  default     = null

  validation {
    condition     = var.bus_name == null || can(regex("^[A-Za-z0-9._/-]{1,256}$", coalesce(var.bus_name, "x")))
    error_message = "bus_name must be 1-256 chars of letters, digits, '.', '_', '-' or '/'."
  }
}

variable "event_bus_name" {
  type        = string
  description = "Existing bus name to attach the rule to when create_bus is false (e.g. 'default')."
  default     = "default"
}

variable "event_pattern" {
  type        = string
  description = "JSON event pattern. Mutually exclusive with schedule_expression."
  default     = null

  validation {
    condition     = var.event_pattern == null || can(jsondecode(var.event_pattern))
    error_message = "event_pattern must be valid JSON."
  }
}

variable "schedule_expression" {
  type        = string
  description = "cron() or rate() expression. Only valid on the default bus. Mutually exclusive with event_pattern."
  default     = null

  validation {
    condition     = var.schedule_expression == null || can(regex("^(cron|rate)\\(.+\\)$", var.schedule_expression))
    error_message = "schedule_expression must be a cron(...) or rate(...) expression."
  }
}

variable "state" {
  type        = string
  description = "Rule state: ENABLED, DISABLED, or ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS."
  default     = "ENABLED"

  validation {
    condition     = contains(["ENABLED", "DISABLED", "ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS"], var.state)
    error_message = "state must be ENABLED, DISABLED, or ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS."
  }
}

variable "targets" {
  description = "List of targets the rule delivers matching events to."
  type = list(object({
    target_id     = string
    arn           = string
    role_required = optional(bool, false)
    role_actions  = optional(list(string), [])
    input         = optional(string)
    input_transformer = optional(object({
      input_paths    = map(string)
      input_template = string
    }))
  }))
  default = []

  validation {
    condition     = length(var.targets) <= 5
    error_message = "EventBridge allows a maximum of 5 targets per rule."
  }

  validation {
    condition = alltrue([
      for t in var.targets : (!t.role_required) || length(t.role_actions) > 0
    ])
    error_message = "Targets with role_required = true must specify role_actions."
  }

  validation {
    condition     = length(distinct([for t in var.targets : t.target_id])) == length(var.targets)
    error_message = "Each target_id must be unique within the rule."
  }
}

variable "enable_dlq" {
  type        = bool
  description = "Create an SQS dead-letter queue for undeliverable target invocations."
  default     = true
}

variable "dlq_retention_seconds" {
  type        = number
  description = "Retention for DLQ messages, in seconds (60 to 1209600)."
  default     = 1209600 # 14 days

  validation {
    condition     = var.dlq_retention_seconds >= 60 && var.dlq_retention_seconds <= 1209600
    error_message = "dlq_retention_seconds must be between 60 and 1209600 (14 days)."
  }
}

variable "maximum_event_age_in_seconds" {
  type        = number
  description = "Max age of an event before EventBridge stops retrying (60 to 86400)."
  default     = 3600

  validation {
    condition     = var.maximum_event_age_in_seconds >= 60 && var.maximum_event_age_in_seconds <= 86400
    error_message = "maximum_event_age_in_seconds must be between 60 and 86400."
  }
}

variable "maximum_retry_attempts" {
  type        = number
  description = "Max retry attempts for a target invocation (0 to 185)."
  default     = 185

  validation {
    condition     = var.maximum_retry_attempts >= 0 && var.maximum_retry_attempts <= 185
    error_message = "maximum_retry_attempts must be between 0 and 185."
  }
}

variable "permissions_boundary_arn" {
  type        = string
  description = "Optional permissions boundary ARN applied to the delivery IAM role."
  default     = null
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to all created resources."
  default     = {}
}

outputs.tf

output "rule_id" {
  description = "The name (id) of the EventBridge rule."
  value       = aws_cloudwatch_event_rule.this.id
}

output "rule_arn" {
  description = "The ARN of the EventBridge rule (useful for source ARN conditions)."
  value       = aws_cloudwatch_event_rule.this.arn
}

output "rule_name" {
  description = "The name of the EventBridge rule."
  value       = aws_cloudwatch_event_rule.this.name
}

output "event_bus_name" {
  description = "The name of the bus the rule is attached to."
  value       = local.event_bus_name
}

output "event_bus_arn" {
  description = "ARN of the custom bus when create_bus is true, otherwise null."
  value       = var.create_bus ? aws_cloudwatch_event_bus.this[0].arn : null
}

output "delivery_role_arn" {
  description = "ARN of the EventBridge delivery role, or null if no target required one."
  value       = local.needs_delivery_role ? aws_iam_role.delivery[0].arn : null
}

output "dlq_arn" {
  description = "ARN of the dead-letter queue, or null if disabled."
  value       = var.enable_dlq ? aws_sqs_queue.dlq[0].arn : null
}

output "target_ids" {
  description = "The target_id of every wired target."
  value       = [for t in aws_cloudwatch_event_target.this : t.target_id]
}

How to use it

This example routes OrderPlaced events published by the com.kloudvin.orders service to a fulfillment Lambda, with a DLQ and a Step Functions state machine as a second target that uses the delivery role.

module "eventbridge" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-eventbridge?ref=v1.0.0"

  rule_name   = "orders-order-placed"
  description = "Route OrderPlaced events to fulfillment + audit workflow"
  create_bus  = true
  bus_name    = "kloudvin-orders"

  # Content filter: only OrderPlaced events for the EU region with a non-zero total.
  event_pattern = jsonencode({
    source        = ["com.kloudvin.orders"]
    "detail-type" = ["OrderPlaced"]
    detail = {
      region = ["eu-west-1"]
      amount = [{ numeric = [">", 0] }]
    }
  })

  targets = [
    {
      target_id = "fulfillment-lambda"
      arn       = aws_lambda_function.fulfillment.arn
      # Lambda uses a resource policy (below), so no delivery role needed.
    },
    {
      target_id     = "audit-statemachine"
      arn           = aws_sfn_state_machine.order_audit.arn
      role_required = true
      role_actions  = ["states:StartExecution"]
    },
  ]

  enable_dlq             = true
  maximum_retry_attempts = 10

  tags = {
    Team        = "orders"
    Environment = "prod"
    CostCenter  = "1042"
  }
}

# Lambda targets need a resource-based permission allowing EventBridge to invoke them,
# scoped to this specific rule via source_arn (a module output).
resource "aws_lambda_permission" "allow_eventbridge" {
  statement_id  = "AllowExecutionFromEventBridge"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.fulfillment.function_name
  principal     = "events.amazonaws.com"
  source_arn    = module.eventbridge.rule_arn
}

# Downstream: alarm on messages landing in the DLQ so failed deliveries get noticed.
resource "aws_cloudwatch_metric_alarm" "dlq_not_empty" {
  alarm_name          = "orders-order-placed-dlq-not-empty"
  namespace           = "AWS/SQS"
  metric_name         = "ApproximateNumberOfMessagesVisible"
  dimensions          = { QueueName = "${module.eventbridge.rule_name}-dlq" }
  statistic           = "Maximum"
  comparison_operator = "GreaterThanThreshold"
  threshold           = 0
  period              = 300
  evaluation_periods  = 1
  treat_missing_data  = "notBreaching"
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/eventbridge/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-eventbridge?ref=v1.0.0"
}

inputs = {
  rule_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/eventbridge && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
rule_name string Yes Name of the rule; unique per bus; 1-64 chars validated.
description string "Managed by Terraform" No Description of what the rule matches.
create_bus bool true No Create a dedicated custom bus instead of using an existing one.
bus_name string null No Name of the bus to create when create_bus is true.
event_bus_name string "default" No Existing bus to attach to when create_bus is false.
event_pattern string null No JSON event pattern; mutually exclusive with schedule_expression; validated as JSON.
schedule_expression string null No cron()/rate() expression (default bus only); mutually exclusive with event_pattern.
state string "ENABLED" No ENABLED, DISABLED, or CloudTrail-management variant.
targets list(object) [] No Up to 5 targets with optional input transformer and per-target IAM actions.
enable_dlq bool true No Create an SQS dead-letter queue for failed deliveries.
dlq_retention_seconds number 1209600 No DLQ message retention (60-1209600s).
maximum_event_age_in_seconds number 3600 No Max event age before retries stop (60-86400).
maximum_retry_attempts number 185 No Max target retry attempts (0-185).
permissions_boundary_arn string null No Permissions boundary for the delivery IAM role.
tags map(string) {} No Tags applied to all resources.

Outputs

Name Description
rule_id The name (id) of the EventBridge rule.
rule_arn ARN of the rule — use as source_arn in Lambda/SQS permissions.
rule_name The name of the rule.
event_bus_name Name of the bus the rule is attached to.
event_bus_arn ARN of the custom bus, or null when using an existing bus.
delivery_role_arn ARN of the EventBridge delivery role, or null if unused.
dlq_arn ARN of the dead-letter queue, or null if disabled.
target_ids List of target_id values for every wired target.

Enterprise scenario

A logistics platform runs each bounded context (orders, inventory, shipping, billing) in its own AWS account with a dedicated kloudvin-<domain> bus stood up by this module. Domain events are forwarded to a central account bus via cross-account rules, where platform teams attach their own rules without ever touching the producing teams’ code. Because every rule ships with a DLQ and a CloudWatch alarm by default, a malformed ShipmentDispatched event that a downstream consumer cannot process lands in its queue and pages the on-call engineer within five minutes — instead of vanishing and surfacing days later as a customer complaint about a missing tracking update.

Best practices

TerraformAWSEventBridgeModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading