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
- Any application that sends transactional email — sign-up confirmations, password resets, receipts, alerts — and needs a verified, DKIM-signed sending domain.
- Multi-environment setups where
dev,staging, andprodeach need their own identity, configuration set, and event tracking with identical wiring. - When deliverability matters and you want SPF/DKIM/DMARC alignment via a custom MAIL FROM domain, not the shared
amazonses.comReturn-Path. - When you must observe bounce and complaint rates to stay under SES’s reputation thresholds (bounces < 5%, complaints < 0.1%) and want those metrics streamed to CloudWatch or SNS automatically.
- Reach for raw resources instead if you only ever need a single throwaway sandbox sender with no DNS control (e.g. a quick
me@gmail.comemail-address identity) — the configuration set and MAIL FROM machinery would be dead weight.
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 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/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
- Lock sending to the configuration set in IAM. Use a
ses:ConfigurationSet(andses:FromAddress) condition on the send policy as shown above — this guarantees every message is tracked and suppressed correctly, and stops an app from quietly sending un-monitored mail. - Always run a custom MAIL FROM domain in production. It aligns SPF and the Return-Path with your brand, which is what lets you pass strict DMARC; pair it with a DNS-published DMARC policy (start at
p=none, graduate top=reject). - Treat bounces and complaints as cost and reputation, not just noise. Keep
suppressed_reasonson so SES auto-suppresses bad addresses (you stop paying to send to them) and routeBOUNCE/COMPLAINTto SNS for a feedback loop — repeatedly mailing dead addresses is the fastest way into the SES sandbox. - Prefer RSA_2048 DKIM and let SES rotate keys. Easy DKIM removes private-key handling entirely; the only manual step is publishing the three CNAMEs from
dkim_signing_records, ideally via your DNS-as-code module rather than the console. - Name configuration sets per environment and purpose (e.g.
kloudvin-transactional-prod,kloudvin-marketing-staging) so reputation metrics, dedicated IP pools, and suppression are scoped and never bleed transactional reputation into bulk sends. - Use a dedicated IP pool only at real volume. Below a few hundred thousand emails a month, the shared SES pool warms and protects your reputation better than a cold dedicated IP — leave
dedicated_ip_pool_namenull until throughput justifies the warm-up.