IaC AWS

Terraform Module: AWS SES (Email) — verified domain identity with DKIM, DMARC, and a dedicated configuration set

Quick take — A reusable Terraform module for AWS SESv2 email identities: domain or email verification, Easy DKIM key rotation, MAIL FROM, configuration sets with CloudWatch event tracking, and deliverability-safe defaults. 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 "ses" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ses?ref=v1.0.0"

  identity               = "..."  # Domain or single email address to verify with SES.
  configuration_set_name = "..."  # Name of the configuration set (TLS, IP pool, suppressio…
}

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

What this module is

Amazon SES (Simple Email Service) is AWS’s high-scale outbound (and optional inbound) email platform. Before SES will send a single message on your behalf, you have to prove you own the sending domain or address — that is the email identity. With the v2 API, aws_sesv2_email_identity represents that identity and is also where you turn on Easy DKIM, attach a default configuration set, and read back the CNAME tokens you publish to DNS.

The trouble is that a production-ready identity is never one resource. You need Easy DKIM (three CNAME records and key rotation), a custom MAIL FROM domain so SPF and the Return-Path align under your brand instead of amazonses.com, a configuration set to pin a dedicated IP pool / TLS policy and to fan out bounce, complaint, and delivery events, and an event destination so those events actually land somewhere (CloudWatch, SNS, or Firehose). Wiring those four resources by hand in every environment is exactly the repetitive, easy-to-get-wrong work a module should absorb.

This module wraps aws_sesv2_email_identity together with aws_sesv2_email_identity_mail_from_attributes, aws_sesv2_configuration_set, and aws_sesv2_configuration_set_event_destination. It is var-driven: hand it a domain (or a single address), and it gives you back the DKIM tokens to publish, a configured sending profile, and deliverability event tracking — with sane, reputation-protecting defaults baked in.

When to use it

Module structure

terraform-module-aws-ses/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # A domain identity exists when the input has no "@"; otherwise it's an
  # email-address identity. MAIL FROM and DKIM only make sense for domains.
  is_domain = !strcontains(var.identity, "@")

  # Custom MAIL FROM subdomain (e.g. "bounce.kloudvin.com") only when enabled
  # AND we are dealing with a domain identity.
  mail_from_enabled = var.mail_from_subdomain != null && local.is_domain
  mail_from_domain  = local.mail_from_enabled ? "${var.mail_from_subdomain}.${var.identity}" : null

  tags = merge(
    {
      ManagedBy = "terraform"
      Module    = "terraform-module-aws-ses"
    },
    var.tags,
  )
}

# --- Configuration set: the reusable "sending profile" ----------------------
resource "aws_sesv2_configuration_set" "this" {
  configuration_set_name = var.configuration_set_name

  delivery_options {
    tls_policy            = var.require_tls ? "REQUIRE" : "OPTIONAL"
    sending_pool_name     = var.dedicated_ip_pool_name
    max_delivery_seconds  = var.max_delivery_seconds
  }

  reputation_options {
    reputation_metrics_enabled = var.reputation_metrics_enabled
  }

  sending_options {
    sending_enabled = var.sending_enabled
  }

  suppression_options {
    suppressed_reasons = var.suppressed_reasons
  }

  tags = local.tags
}

# --- The email identity (domain or address) ---------------------------------
resource "aws_sesv2_email_identity" "this" {
  email_identity         = var.identity
  configuration_set_name = aws_sesv2_configuration_set.this.configuration_set_name

  # Easy DKIM: SES generates and rotates the signing keys; you publish 3 CNAMEs.
  dynamic "dkim_signing_attributes" {
    for_each = local.is_domain ? [1] : []
    content {
      next_signing_key_length = var.dkim_signing_key_length
    }
  }

  tags = local.tags
}

# --- Custom MAIL FROM domain for SPF / Return-Path alignment -----------------
resource "aws_sesv2_email_identity_mail_from_attributes" "this" {
  count = local.mail_from_enabled ? 1 : 0

  email_identity = aws_sesv2_email_identity.this.email_identity

  mail_from_domain       = local.mail_from_domain
  behavior_on_mx_failure = var.behavior_on_mx_failure
}

# --- Where bounce / complaint / delivery events go --------------------------
resource "aws_sesv2_configuration_set_event_destination" "cloudwatch" {
  count = var.enable_cloudwatch_events ? 1 : 0

  configuration_set_name = aws_sesv2_configuration_set.this.configuration_set_name
  event_destination_name = "${var.configuration_set_name}-cloudwatch"

  event_destination {
    enabled              = true
    matching_event_types = var.tracked_event_types

    cloud_watch_destination {
      dimension_configuration {
        dimension_name           = "ses:configuration-set"
        dimension_value_source   = "MESSAGE_TAG"
        default_dimension_value  = var.configuration_set_name
      }
    }
  }
}

resource "aws_sesv2_configuration_set_event_destination" "sns" {
  count = var.sns_topic_arn != null ? 1 : 0

  configuration_set_name = aws_sesv2_configuration_set.this.configuration_set_name
  event_destination_name = "${var.configuration_set_name}-sns"

  event_destination {
    enabled              = true
    matching_event_types = var.sns_event_types

    sns_destination {
      topic_arn = var.sns_topic_arn
    }
  }
}

variables.tf

variable "identity" {
  description = "Domain (e.g. \"mail.kloudvin.com\") or a single email address (e.g. \"noreply@kloudvin.com\") to verify with SES."
  type        = string

  validation {
    condition     = length(trimspace(var.identity)) > 0
    error_message = "identity must be a non-empty domain or email address."
  }
}

variable "configuration_set_name" {
  description = "Name of the SES configuration set that pins TLS policy, IP pool, suppression, and event tracking."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9_-]{1,64}$", var.configuration_set_name))
    error_message = "configuration_set_name may contain only letters, digits, hyphens and underscores (1-64 chars)."
  }
}

variable "dkim_signing_key_length" {
  description = "Easy DKIM key length. RSA_2048_BIT is recommended; RSA_1024_BIT exists for legacy compatibility."
  type        = string
  default     = "RSA_2048_BIT"

  validation {
    condition     = contains(["RSA_1024_BIT", "RSA_2048_BIT"], var.dkim_signing_key_length)
    error_message = "dkim_signing_key_length must be RSA_1024_BIT or RSA_2048_BIT."
  }
}

variable "mail_from_subdomain" {
  description = "Subdomain label used to build the custom MAIL FROM domain (e.g. \"bounce\" -> bounce.<identity>). Set null to skip and use the default amazonses.com Return-Path. Ignored for email-address identities."
  type        = string
  default     = null
}

variable "behavior_on_mx_failure" {
  description = "What SES does if the MAIL FROM MX record is missing. USE_DEFAULT_VALUE falls back to amazonses.com; REJECT_MESSAGE drops the send."
  type        = string
  default     = "USE_DEFAULT_VALUE"

  validation {
    condition     = contains(["USE_DEFAULT_VALUE", "REJECT_MESSAGE"], var.behavior_on_mx_failure)
    error_message = "behavior_on_mx_failure must be USE_DEFAULT_VALUE or REJECT_MESSAGE."
  }
}

variable "require_tls" {
  description = "If true, SES refuses to deliver unless the connection is TLS-encrypted (delivery_options tls_policy = REQUIRE)."
  type        = bool
  default     = true
}

variable "dedicated_ip_pool_name" {
  description = "Name of a dedicated IP pool to send from. Leave null to use the shared SES pool."
  type        = string
  default     = null
}

variable "max_delivery_seconds" {
  description = "Maximum time SES retries delivery before giving up, in seconds (300-50400). Null uses the SES default."
  type        = number
  default     = null

  validation {
    condition     = var.max_delivery_seconds == null || (var.max_delivery_seconds >= 300 && var.max_delivery_seconds <= 50400)
    error_message = "max_delivery_seconds must be between 300 and 50400 when set."
  }
}

variable "reputation_metrics_enabled" {
  description = "Publish per-configuration-set reputation metrics (bounce/complaint rates) to CloudWatch."
  type        = bool
  default     = true
}

variable "sending_enabled" {
  description = "Master kill switch for this configuration set. Set false to pause all sending without destroying the identity."
  type        = bool
  default     = true
}

variable "suppressed_reasons" {
  description = "Reasons SES auto-adds recipients to the account suppression list for this set. Typically [\"BOUNCE\", \"COMPLAINT\"]."
  type        = list(string)
  default     = ["BOUNCE", "COMPLAINT"]

  validation {
    condition     = alltrue([for r in var.suppressed_reasons : contains(["BOUNCE", "COMPLAINT"], r)])
    error_message = "suppressed_reasons may only contain BOUNCE and/or COMPLAINT."
  }
}

variable "enable_cloudwatch_events" {
  description = "Create a CloudWatch event destination so engagement/deliverability events are emitted as metrics."
  type        = bool
  default     = true
}

variable "tracked_event_types" {
  description = "Event types routed to the CloudWatch destination."
  type        = list(string)
  default     = ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "OPEN", "CLICK"]
}

variable "sns_topic_arn" {
  description = "ARN of an SNS topic to receive events (e.g. bounces/complaints for a feedback handler). Null disables the SNS destination."
  type        = string
  default     = null

  validation {
    condition     = var.sns_topic_arn == null || can(regex("^arn:aws[a-z-]*:sns:", var.sns_topic_arn))
    error_message = "sns_topic_arn must be a valid SNS topic ARN when set."
  }
}

variable "sns_event_types" {
  description = "Event types routed to the SNS destination (kept narrow on purpose to avoid topic spam)."
  type        = list(string)
  default     = ["BOUNCE", "COMPLAINT"]
}

variable "tags" {
  description = "Additional tags merged onto the identity and configuration set."
  type        = map(string)
  default     = {}
}

outputs.tf

output "identity_arn" {
  description = "ARN of the SES email identity."
  value       = aws_sesv2_email_identity.this.arn
}

output "identity" {
  description = "The verified domain or email address."
  value       = aws_sesv2_email_identity.this.email_identity
}

output "identity_type" {
  description = "Whether SES treats this as a DOMAIN or EMAIL_ADDRESS identity."
  value       = aws_sesv2_email_identity.this.identity_type
}

output "verified_for_sending" {
  description = "True once SES has confirmed the identity is verified and ready to send."
  value       = aws_sesv2_email_identity.this.verified_for_sending_status
}

output "dkim_tokens" {
  description = "The three Easy DKIM tokens. Publish each as a CNAME: <token>._domainkey.<domain> -> <token>.dkim.amazonses.com."
  value       = aws_sesv2_email_identity.this.dkim_signing_attributes[0].tokens
}

output "dkim_signing_records" {
  description = "Ready-to-publish DKIM CNAME records (name -> value) for the domain's DNS zone."
  value = local.is_domain ? {
    for token in aws_sesv2_email_identity.this.dkim_signing_attributes[0].tokens :
    "${token}._domainkey.${var.identity}" => "${token}.dkim.amazonses.com"
  } : {}
}

output "mail_from_domain" {
  description = "Custom MAIL FROM domain in use, or null if the default amazonses.com Return-Path is used."
  value       = local.mail_from_enabled ? local.mail_from_domain : null
}

output "mail_from_mx_record" {
  description = "MX record to publish for the MAIL FROM domain (publish with priority 10), or null when not enabled."
  value       = local.mail_from_enabled ? "feedback-smtp.${data.aws_region.current.name}.amazonses.com" : null
}

output "configuration_set_name" {
  description = "Name of the configuration set to pass as the SES configuration set on every SendEmail call."
  value       = aws_sesv2_configuration_set.this.configuration_set_name
}

output "configuration_set_arn" {
  description = "ARN of the configuration set."
  value       = aws_sesv2_configuration_set.this.arn
}

data "aws_region" "current" {}

How to use it

# Bounce/complaint feedback topic an SQS-backed Lambda subscribes to.
resource "aws_sns_topic" "ses_feedback" {
  name = "kloudvin-ses-feedback-prod"
}

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

  identity               = "mail.kloudvin.com"
  configuration_set_name = "kloudvin-transactional-prod"

  # Deliverability: align SPF/Return-Path under our own domain, enforce TLS.
  mail_from_subdomain    = "bounce"
  behavior_on_mx_failure = "REJECT_MESSAGE"
  require_tls            = true
  dkim_signing_key_length = "RSA_2048_BIT"

  # Stream metrics + route hard signals to the feedback handler.
  enable_cloudwatch_events = true
  sns_topic_arn            = aws_sns_topic.ses_feedback.arn

  tags = {
    Environment = "prod"
    Team        = "platform"
  }
}

# Downstream: hand an app role permission to send only through this identity
# + configuration set, and surface the DKIM records to whatever manages DNS.
resource "aws_iam_role_policy" "app_send_email" {
  name = "ses-send-transactional"
  role = aws_iam_role.app.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["ses:SendEmail", "ses:SendRawEmail"]
      Resource = module.ses_email_prod.identity_arn
      Condition = {
        StringEquals = {
          "ses:FromAddress" = "noreply@mail.kloudvin.com"
        }
        "ForAllValues:StringEquals" = {
          "ses:ConfigurationSet" = module.ses_email_prod.configuration_set_name
        }
      }
    }]
  })
}

output "publish_these_dkim_cnames" {
  value = module.ses_email_prod.dkim_signing_records
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  identity = "..."
  configuration_set_name = "..."
}

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

cd live/prod/ses && 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
identity string yes Domain or single email address to verify with SES.
configuration_set_name string yes Name of the configuration set (TLS, IP pool, suppression, events).
dkim_signing_key_length string "RSA_2048_BIT" no Easy DKIM key length: RSA_1024_BIT or RSA_2048_BIT.
mail_from_subdomain string null no Subdomain label for the custom MAIL FROM domain; null uses amazonses.com. Ignored for address identities.
behavior_on_mx_failure string "USE_DEFAULT_VALUE" no USE_DEFAULT_VALUE or REJECT_MESSAGE when the MAIL FROM MX is missing.
require_tls bool true no Require TLS for delivery (REQUIRE vs OPTIONAL).
dedicated_ip_pool_name string null no Dedicated IP pool to send from; null uses the shared pool.
max_delivery_seconds number null no Max delivery retry window in seconds (300–50400); null uses the default.
reputation_metrics_enabled bool true no Publish per-set bounce/complaint reputation metrics to CloudWatch.
sending_enabled bool true no Master pause switch for the configuration set.
suppressed_reasons list(string) ["BOUNCE","COMPLAINT"] no Reasons SES auto-suppresses recipients (BOUNCE/COMPLAINT).
enable_cloudwatch_events bool true no Create the CloudWatch event destination.
tracked_event_types list(string) ["SEND","DELIVERY","BOUNCE","COMPLAINT","REJECT","OPEN","CLICK"] no Event types routed to CloudWatch.
sns_topic_arn string null no SNS topic ARN for events; null disables the SNS destination.
sns_event_types list(string) ["BOUNCE","COMPLAINT"] no Event types routed to SNS.
tags map(string) {} no Extra tags merged onto the identity and configuration set.

Outputs

Name Description
identity_arn ARN of the SES email identity (use as the IAM policy resource).
identity The verified domain or email address.
identity_type DOMAIN or EMAIL_ADDRESS.
verified_for_sending True once SES confirms the identity is ready to send.
dkim_tokens The three raw Easy DKIM tokens.
dkim_signing_records Map of ready-to-publish DKIM CNAME records (name -> value).
mail_from_domain Active custom MAIL FROM domain, or null.
mail_from_mx_record MX value to publish for the MAIL FROM domain (priority 10), or null.
configuration_set_name Name to pass as the SES configuration set on every send.
configuration_set_arn ARN of the configuration set.

Enterprise scenario

A fintech platform sends statement-ready and password-reset emails from mail.acmebank.com and must keep complaint rates well under SES’s 0.1% ceiling or risk an account-level sending pause. They instantiate this module once per region (us-east-1 and eu-west-1) with a dedicated IP pool for warmed, predictable IP reputation, behavior_on_mx_failure = REJECT_MESSAGE so no statement ever leaves with a misaligned Return-Path, and the sns_topic_arn wired to a Lambda that writes every bounce and complaint into a suppression-audit table for the compliance team. The CloudWatch event destination feeds a dashboard with an alarm at 0.08% complaint rate, giving on-call engineers a head start before AWS intervenes.

Best practices

TerraformAWSSES (Email)ModuleIaC
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