IaC AWS

Terraform Module: AWS Amazon MQ — Production-Ready ActiveMQ/RabbitMQ Brokers with Multi-AZ, Encryption & Audit Logs

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for Amazon MQ that provisions ActiveMQ or RabbitMQ brokers with Active/Standby Multi-AZ, a private security group, a custom broker configuration, KMS encryption, and CloudWatch logs. 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 "mq" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-mq?ref=v1.0.0"

  name_prefix = "..."           # Short prefix for all resource names (service/app name).
  environment = "..."           # One of `dev`, `staging`, `prod`; used in naming and tag…
  vpc_id      = "..."           # VPC in which the broker security group is created.
  subnet_ids  = ["...", "..."]  # Private subnet IDs (count depends on `deployment_mode`).
  users       = ["...", "..."]  # Broker users; passwords >= 12 chars, no commas (pass vi…
}

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

What this module is

Amazon MQ is a managed message-broker service that runs the ActiveMQ and RabbitMQ engines for you, so you can keep an existing application that speaks AMQP 0-9-1, MQTT, STOMP, OpenWire, or the JMS wire protocols without re-platforming onto SQS/SNS. It is the right service precisely when you are migrating an on-premises broker or running off-the-shelf software that expects a standards-compliant broker endpoint rather than an AWS-proprietary queue API. The aws_mq_broker resource is the heart of it: it represents one broker (or a Multi-AZ pair / RabbitMQ cluster), its instance size, its engine version, its network placement, its users, and its encryption.

Wrapping it in a reusable module matters because a correct Amazon MQ deployment has several easy-to-get-wrong pieces that rarely vary between teams: it must live in private subnets with publicly_accessible = false behind a security group that opens only the engine’s ports (61617/8162 for ActiveMQ, 5671/443 for RabbitMQ) to the application tier; it should run ACTIVE_STANDBY_MULTI_AZ (ActiveMQ) or CLUSTER_MULTI_AZ (RabbitMQ) so a single AZ failure does not take messaging down; it needs encryption at rest with a KMS key, a managed broker configuration (aws_mq_configuration) that pins broker-level settings instead of relying on defaults, and general/audit log delivery to CloudWatch. This module folds all of that into one versioned unit, so every broker your organisation ships is private, HA, encrypted, and observable by default — instead of someone spinning up a single-instance, publicly-accessible broker that becomes both an outage and an audit finding.

When to use it

Module structure

terraform-module-aws-mq/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # security group, configuration, broker
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, arn, endpoints, console URL, SG id

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  name = "${var.name_prefix}-${var.environment}"

  is_activemq = var.engine_type == "ActiveMQ"
  is_rabbitmq = var.engine_type == "RabbitMQ"

  # ActiveMQ exposes OpenWire (SSL) + the web console; RabbitMQ exposes
  # AMQPS + the management console. We only ever open the SSL/TLS ports.
  broker_ports = local.is_activemq ? [61617, 8162] : [5671, 443]

  # A custom configuration is only attached when configuration_data is set.
  # RabbitMQ in CLUSTER_MULTI_AZ mode does not support broker configurations,
  # so we guard against attaching one there.
  manage_configuration = (
    var.configuration_data != null &&
    !(local.is_rabbitmq && var.deployment_mode == "CLUSTER_MULTI_AZ")
  )

  tags = merge(
    {
      Name        = local.name
      Environment = var.environment
      Engine      = var.engine_type
      ManagedBy   = "terraform"
      Module      = "terraform-module-aws-mq"
    },
    var.tags,
  )
}

# Dedicated security group; ingress is opened only to the CIDRs/SGs you pass in,
# and only on the engine's TLS ports.
resource "aws_security_group" "this" {
  name        = "${local.name}-mq-sg"
  description = "Access to ${local.name} Amazon MQ broker (${var.engine_type})"
  vpc_id      = var.vpc_id
  tags        = local.tags
}

resource "aws_security_group_rule" "ingress_cidr" {
  for_each = length(var.allowed_cidr_blocks) > 0 ? toset([for p in local.broker_ports : tostring(p)]) : toset([])

  type              = "ingress"
  description       = "Broker TLS port ${each.value} from allowed CIDRs"
  from_port         = tonumber(each.value)
  to_port           = tonumber(each.value)
  protocol          = "tcp"
  cidr_blocks       = var.allowed_cidr_blocks
  security_group_id = aws_security_group.this.id
}

resource "aws_security_group_rule" "ingress_sg" {
  for_each = {
    for pair in setproduct(var.allowed_security_group_ids, local.broker_ports) :
    "${pair[0]}-${pair[1]}" => { sg = pair[0], port = pair[1] }
  }

  type                     = "ingress"
  description              = "Broker TLS port ${each.value.port} from app security group"
  from_port                = each.value.port
  to_port                  = each.value.port
  protocol                 = "tcp"
  source_security_group_id = each.value.sg
  security_group_id        = aws_security_group.this.id
}

resource "aws_security_group_rule" "egress_all" {
  type              = "egress"
  description       = "Allow all outbound"
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.this.id
}

# Custom broker configuration (XML for ActiveMQ, Cuttlefish for RabbitMQ).
# Pins broker-level behaviour instead of relying on engine defaults.
resource "aws_mq_configuration" "this" {
  count = local.manage_configuration ? 1 : 0

  name           = "${local.name}-config"
  description    = "Managed configuration for ${local.name}"
  engine_type    = var.engine_type
  engine_version = var.engine_version
  data           = var.configuration_data

  # Apply a new configuration revision in the next maintenance window
  # rather than forcing an immediate reboot of the broker.
  lifecycle {
    create_before_destroy = true
  }

  tags = local.tags
}

resource "aws_mq_broker" "this" {
  broker_name = local.name

  engine_type        = var.engine_type
  engine_version     = var.engine_version
  host_instance_type = var.host_instance_type
  deployment_mode    = var.deployment_mode
  storage_type       = var.storage_type

  # Placement: private subnets, never public.
  subnet_ids          = var.subnet_ids
  security_groups     = concat([aws_security_group.this.id], var.extra_security_group_ids)
  publicly_accessible = var.publicly_accessible

  # Patching / change control.
  auto_minor_version_upgrade = var.auto_minor_version_upgrade
  apply_immediately          = var.apply_immediately

  authentication_strategy = var.authentication_strategy

  # Attach the managed configuration when one is created.
  dynamic "configuration" {
    for_each = local.manage_configuration ? [1] : []
    content {
      id       = aws_mq_configuration.this[0].id
      revision = aws_mq_configuration.this[0].latest_revision
    }
  }

  # Broker users. ActiveMQ supports multiple users (with optional console
  # access and group membership); RabbitMQ supports exactly one admin user.
  dynamic "user" {
    for_each = { for u in var.users : u.username => u }
    content {
      username         = user.value.username
      password         = user.value.password
      console_access   = local.is_activemq ? user.value.console_access : null
      groups           = local.is_activemq ? user.value.groups : null
      replication_user = user.value.replication_user
    }
  }

  # Encryption at rest. A customer-managed KMS key is used when supplied;
  # otherwise Amazon MQ uses an AWS-owned key.
  encryption_options {
    kms_key_id        = var.kms_key_id
    use_aws_owned_key = var.kms_key_id == null
  }

  # Stream general (and, for ActiveMQ, audit) logs to CloudWatch Logs.
  logs {
    general = var.logs_general
    audit   = local.is_activemq ? var.logs_audit : null
  }

  maintenance_window_start_time {
    day_of_week = var.maintenance_day_of_week
    time_of_day = var.maintenance_time_of_day
    time_zone   = var.maintenance_time_zone
  }

  tags = local.tags

  lifecycle {
    # Passwords are rotated out-of-band via Secrets Manager; ignore drift so
    # a rotation does not force the broker to be replaced on the next plan.
    ignore_changes = [user]
  }
}

variables.tf

variable "name_prefix" {
  description = "Short prefix for all resource names (e.g. the service or app name)."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,40}$", var.name_prefix))
    error_message = "name_prefix must be lowercase alphanumeric/hyphens, start with a letter, 2-41 chars."
  }
}

variable "environment" {
  description = "Deployment environment, used in naming and tags."
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

variable "engine_type" {
  description = "Broker engine: 'ActiveMQ' or 'RabbitMQ'."
  type        = string
  default     = "ActiveMQ"

  validation {
    condition     = contains(["ActiveMQ", "RabbitMQ"], var.engine_type)
    error_message = "engine_type must be 'ActiveMQ' or 'RabbitMQ'."
  }
}

variable "engine_version" {
  description = "Engine version (e.g. '5.18' for ActiveMQ, '3.13' for RabbitMQ). Must match a version AWS supports."
  type        = string
  default     = "5.18"
}

variable "host_instance_type" {
  description = "Broker instance class (e.g. mq.t3.micro, mq.m5.large). t3.micro is dev-only."
  type        = string
  default     = "mq.m5.large"

  validation {
    condition     = can(regex("^mq\\.", var.host_instance_type))
    error_message = "host_instance_type must be an Amazon MQ instance class starting with 'mq.'."
  }
}

variable "deployment_mode" {
  description = "Topology: SINGLE_INSTANCE, ACTIVE_STANDBY_MULTI_AZ (ActiveMQ), or CLUSTER_MULTI_AZ (RabbitMQ)."
  type        = string
  default     = "ACTIVE_STANDBY_MULTI_AZ"

  validation {
    condition     = contains(["SINGLE_INSTANCE", "ACTIVE_STANDBY_MULTI_AZ", "CLUSTER_MULTI_AZ"], var.deployment_mode)
    error_message = "deployment_mode must be SINGLE_INSTANCE, ACTIVE_STANDBY_MULTI_AZ, or CLUSTER_MULTI_AZ."
  }
}

variable "storage_type" {
  description = "Broker storage backend: 'efs' (Multi-AZ durable, ActiveMQ) or 'ebs' (single-AZ, lower latency)."
  type        = string
  default     = "efs"

  validation {
    condition     = contains(["efs", "ebs"], var.storage_type)
    error_message = "storage_type must be 'efs' or 'ebs'."
  }
}

variable "vpc_id" {
  description = "VPC in which the broker security group is created."
  type        = string
}

variable "subnet_ids" {
  description = "Private subnet IDs for the broker. Use 1 subnet for SINGLE_INSTANCE, 2 for ACTIVE_STANDBY_MULTI_AZ, and exactly 1 (RabbitMQ single) or per-AZ subnets for CLUSTER_MULTI_AZ."
  type        = list(string)

  validation {
    condition     = length(var.subnet_ids) >= 1
    error_message = "Provide at least one private subnet for the broker."
  }
}

variable "publicly_accessible" {
  description = "Whether the broker has a public endpoint. Keep false; brokers should be reachable only from within the VPC."
  type        = bool
  default     = false
}

variable "authentication_strategy" {
  description = "Authentication backend: 'simple' (broker-managed users) or 'ldap'."
  type        = string
  default     = "simple"

  validation {
    condition     = contains(["simple", "ldap"], var.authentication_strategy)
    error_message = "authentication_strategy must be 'simple' or 'ldap'."
  }
}

variable "users" {
  description = "Broker users. ActiveMQ supports multiple users (console_access/groups honoured); RabbitMQ requires exactly one. Passwords must be 12-250 chars with no commas. Pass via a secret, never hardcode."
  type = list(object({
    username         = string
    password         = string
    console_access   = optional(bool, false)
    groups           = optional(list(string), [])
    replication_user = optional(bool, false)
  }))
  sensitive = true

  validation {
    condition     = length(var.users) >= 1
    error_message = "At least one broker user is required."
  }

  validation {
    condition     = alltrue([for u in var.users : length(u.password) >= 12 && !can(regex(",", u.password))])
    error_message = "Each user password must be at least 12 characters and contain no commas."
  }
}

variable "configuration_data" {
  description = "Broker configuration body: ActiveMQ XML (<broker>...) or RabbitMQ Cuttlefish. Null to use engine defaults. Not supported for RabbitMQ CLUSTER_MULTI_AZ."
  type        = string
  default     = null
}

variable "kms_key_id" {
  description = "Optional customer-managed KMS key ARN for encryption at rest. Null = AWS-owned key."
  type        = string
  default     = null
}

variable "allowed_cidr_blocks" {
  description = "CIDR blocks permitted to reach the broker's TLS ports."
  type        = list(string)
  default     = []
}

variable "allowed_security_group_ids" {
  description = "Source security group IDs (app tier) permitted to reach the broker's TLS ports."
  type        = list(string)
  default     = []
}

variable "extra_security_group_ids" {
  description = "Additional pre-existing SG IDs to attach to the broker."
  type        = list(string)
  default     = []
}

variable "logs_general" {
  description = "Stream general broker logs to CloudWatch Logs."
  type        = bool
  default     = true
}

variable "logs_audit" {
  description = "Stream audit logs to CloudWatch Logs. ActiveMQ only; ignored for RabbitMQ."
  type        = bool
  default     = true
}

variable "auto_minor_version_upgrade" {
  description = "Allow Amazon MQ to apply minor engine upgrades during the maintenance window."
  type        = bool
  default     = true
}

variable "apply_immediately" {
  description = "Apply modifications (and reboots) immediately instead of during the maintenance window."
  type        = bool
  default     = false
}

variable "maintenance_day_of_week" {
  description = "Day of the weekly maintenance window (e.g. MONDAY). Required for SINGLE_INSTANCE/ACTIVE_STANDBY ActiveMQ."
  type        = string
  default     = "SUNDAY"

  validation {
    condition     = contains(["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"], var.maintenance_day_of_week)
    error_message = "maintenance_day_of_week must be an upper-case day name (e.g. SUNDAY)."
  }
}

variable "maintenance_time_of_day" {
  description = "Start time of the maintenance window, 24h HH:MM (e.g. '03:00')."
  type        = string
  default     = "03:00"

  validation {
    condition     = can(regex("^([01][0-9]|2[0-3]):[0-5][0-9]$", var.maintenance_time_of_day))
    error_message = "maintenance_time_of_day must be HH:MM in 24-hour format."
  }
}

variable "maintenance_time_zone" {
  description = "Time zone for the maintenance window (e.g. 'UTC', 'Asia/Kolkata')."
  type        = string
  default     = "UTC"
}

variable "tags" {
  description = "Additional tags merged onto every resource."
  type        = map(string)
  default     = {}
}

outputs.tf

output "broker_id" {
  description = "The unique ID of the Amazon MQ broker."
  value       = aws_mq_broker.this.id
}

output "broker_name" {
  description = "The name of the broker."
  value       = aws_mq_broker.this.broker_name
}

output "arn" {
  description = "ARN of the broker."
  value       = aws_mq_broker.this.arn
}

output "instances" {
  description = "Per-instance details (endpoints, console_url, ip_address) for every broker node."
  value       = aws_mq_broker.this.instances
}

output "primary_endpoints" {
  description = "Wire-protocol endpoints (e.g. ssl://...:61617 or amqps://...:5671) of the active broker instance."
  value       = aws_mq_broker.this.instances[0].endpoints
}

output "console_url" {
  description = "URL of the active broker's web console (ActiveMQ) or management UI (RabbitMQ)."
  value       = aws_mq_broker.this.instances[0].console_url
}

output "security_group_id" {
  description = "ID of the security group created for the broker."
  value       = aws_security_group.this.id
}

output "configuration_id" {
  description = "ID of the managed broker configuration, or null when none is created."
  value       = try(aws_mq_configuration.this[0].id, null)
}

output "configuration_latest_revision" {
  description = "Latest revision number of the managed configuration, or null when none is created."
  value       = try(aws_mq_configuration.this[0].latest_revision, null)
}

How to use it

# Generate and store the broker admin password in Secrets Manager; never hardcode it.
resource "random_password" "mq_admin" {
  length  = 32
  special = false # Amazon MQ passwords disallow commas and some symbols; alphanumeric is safe.
}

resource "aws_secretsmanager_secret" "mq_admin" {
  name = "orders/mq-admin-password"
}

resource "aws_secretsmanager_secret_version" "mq_admin" {
  secret_id     = aws_secretsmanager_secret.mq_admin.id
  secret_string = random_password.mq_admin.result
}

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

  name_prefix = "orders-broker"
  environment = "prod"

  engine_type        = "ActiveMQ"
  engine_version     = "5.18"
  host_instance_type = "mq.m5.large"

  # Active/Standby across two AZs on durable EFS storage.
  deployment_mode = "ACTIVE_STANDBY_MULTI_AZ"
  storage_type    = "efs"

  vpc_id     = module.network.vpc_id
  subnet_ids = slice(module.network.private_subnet_ids, 0, 2) # exactly 2 AZs for Active/Standby

  # Lock down access to the app tier only.
  allowed_security_group_ids = [module.orders_service.app_security_group_id]

  # Single admin user, sourced from Secrets Manager, with web-console access.
  users = [{
    username       = "orders_admin"
    password       = aws_secretsmanager_secret_version.mq_admin.secret_string
    console_access = true
    groups         = ["admins"]
  }]

  # Pin broker behaviour: enable JMX-free audit, tune the destination policy.
  configuration_data = file("${path.module}/activemq.xml")

  # Encrypt at rest with a customer-managed key, ship logs, patch on Sundays.
  kms_key_id              = aws_kms_key.orders.arn
  logs_general            = true
  logs_audit              = true
  maintenance_day_of_week = "SUNDAY"
  maintenance_time_of_day = "03:00"
  maintenance_time_zone   = "Asia/Kolkata"

  tags = {
    Team       = "fulfilment"
    CostCentre = "ECOM-204"
  }
}

# Downstream: hand the OpenWire SSL endpoint to the ECS task definition so the
# order worker can connect to the broker.
resource "aws_ssm_parameter" "mq_endpoint" {
  name  = "/orders/mq/openwire-ssl-endpoint"
  type  = "String"
  value = module.amazon_mq.primary_endpoints[0]
}

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/mq/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  name_prefix = "..."
  environment = "..."
  vpc_id = "..."
  subnet_ids = ["...", "..."]
  users = ["...", "..."]
}

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

cd live/prod/mq && 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
name_prefix string Yes Short prefix for all resource names (service/app name).
environment string Yes One of dev, staging, prod; used in naming and tags.
engine_type string "ActiveMQ" No Broker engine: ActiveMQ or RabbitMQ.
engine_version string "5.18" No Engine version (e.g. 5.18, 3.13); must be AWS-supported.
host_instance_type string "mq.m5.large" No Broker instance class (e.g. mq.t3.micro, mq.m5.large).
deployment_mode string "ACTIVE_STANDBY_MULTI_AZ" No SINGLE_INSTANCE, ACTIVE_STANDBY_MULTI_AZ, or CLUSTER_MULTI_AZ.
storage_type string "efs" No efs (Multi-AZ durable) or ebs (single-AZ, lower latency).
vpc_id string Yes VPC in which the broker security group is created.
subnet_ids list(string) Yes Private subnet IDs (count depends on deployment_mode).
publicly_accessible bool false No Whether the broker has a public endpoint; keep false.
authentication_strategy string "simple" No simple (broker users) or ldap.
users list(object) Yes Broker users; passwords >= 12 chars, no commas (pass via a secret).
configuration_data string null No ActiveMQ XML / RabbitMQ Cuttlefish config body; null = defaults.
kms_key_id string null No Customer-managed KMS key ARN for at-rest encryption; null = AWS-owned.
allowed_cidr_blocks list(string) [] No CIDRs permitted to reach the broker’s TLS ports.
allowed_security_group_ids list(string) [] No Source SG IDs (app tier) permitted to reach the broker.
extra_security_group_ids list(string) [] No Additional existing SGs to attach to the broker.
logs_general bool true No Stream general broker logs to CloudWatch.
logs_audit bool true No Stream audit logs (ActiveMQ only; ignored for RabbitMQ).
auto_minor_version_upgrade bool true No Allow minor engine upgrades during maintenance.
apply_immediately bool false No Apply changes/reboots immediately vs. during maintenance.
maintenance_day_of_week string "SUNDAY" No Maintenance window day (upper-case day name).
maintenance_time_of_day string "03:00" No Maintenance window start time (HH:MM, 24h).
maintenance_time_zone string "UTC" No Maintenance window time zone (e.g. Asia/Kolkata).
tags map(string) {} No Additional tags merged onto every resource.

Outputs

Name Description
broker_id The unique ID of the Amazon MQ broker.
broker_name The name of the broker.
arn ARN of the broker.
instances Per-instance details (endpoints, console_url, ip_address) for every node.
primary_endpoints Wire-protocol endpoints of the active broker instance.
console_url URL of the active broker’s web/management console.
security_group_id ID of the security group created for the broker.
configuration_id ID of the managed configuration, or null when none is created.
configuration_latest_revision Latest revision of the managed configuration, or null.

Enterprise scenario

A logistics company is lifting a legacy order-routing application off an on-premises ActiveMQ cluster and into AWS, and the application is hard-wired to JMS durable topics and OpenWire — rewriting it onto SQS is off the table for this release. The platform team consumes this module pinned at ref=v1.0.0 to stand up an ACTIVE_STANDBY_MULTI_AZ ActiveMQ 5.18 broker on mq.m5.large across two private subnets with EFS storage, a customer-managed KMS key, and a custom activemq.xml that pins the dead-letter strategy and per-destination memory limits used on-prem. Because the module forces publicly_accessible = false, scopes the security group to the order-service app SG on ports 61617/8162 only, and ships both general and audit logs to CloudWatch, the migrated broker clears the company’s security review on the first pass and survives an AZ failure with an automatic standby promotion — while every other team that later adopts it inherits the same HA, encrypted, audited baseline.

Best practices

TerraformAWSAmazon MQModuleIaC
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