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
- You are decomposing a monolith and want domain events (“OrderPlaced”, “PaymentFailed”) routed to handlers without point-to-point coupling.
- You need to react to AWS service events — EC2 state changes, S3 object-created, CodePipeline stage transitions, GuardDuty findings, ECS task state — and trigger automation.
- You want a scheduled invocation. (Note: for new cron/rate workloads prefer EventBridge Scheduler; this module covers the classic rule-based
schedule_expressionpath which is still valid and widely used for AWS-event-driven rules.) - You are building a multi-account event mesh and need consistently-tagged, consistently-named buses and rules across many stacks.
- You want every rule to ship with a DLQ and retry policy by default, instead of relying on each team to remember.
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 config — live/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 config — live/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
- Make every event pattern as specific as you can. Filter on
source,detail-type, anddetailfields (content filtering supportsprefix,numeric,exists, andanything-but) so targets are only invoked on events you truly care about — broad patterns waste invocations and money, since EventBridge custom-bus events are billed per million published. - Always enable the DLQ and alarm on it. Without
dead_letter_config, an event that exhausts retries is dropped permanently. The DLQ plus theApproximateNumberOfMessagesVisiblealarm shown above turns silent data loss into an actionable page. - Use one bus per domain, never the
defaultbus for application events. A dedicated custom bus gives you isolated archives, replay, schema discovery, and resource policies — and keeps your domain events out of the noisy AWS-service stream ondefault. - Grant the delivery role only the target ARNs it needs. This module scopes the role policy to the exact target ARNs and actions (
states:StartExecution,kinesis:PutRecord, etc.); never reuse a broad shared EventBridge role across unrelated rules. - Name rules
<domain>-<event>and tag withTeam/CostCenter. Consistent naming makes rules discoverable in the console and CloudWatch, and tags let you attribute the per-event publishing cost back to the owning team. - Tune retries deliberately. The default
maximum_retry_attempts = 185over a 24-hour window is generous; for latency-sensitive flows lowermaximum_event_age_in_secondsso stale events fail fast to the DLQ rather than retrying against a target that is already too late to matter.