Provisioning Amazon SES with Terraform takes roughly 50 lines of HCL for a production-ready setup - one domain identity, DKIM, a custom MAIL FROM, a configuration set with event destinations, an IAM user with a least-privilege policy, and SNS topics for bounce and complaint feedback. The walkthrough below builds each piece against the HashiCorp AWS provider docs, then assembles them into a single reusable module you can drop into any environment.
Why provision SES with Terraform
Infrastructure as code turns SES configuration from a tribal-knowledge clickfest into a reviewable artifact. HashiCorp's 2024 State of Cloud Strategy Survey reported that more than 80% of enterprises now integrate IaC into their CI/CD pipelines, and SES is a textbook fit: it is regional, identity-heavy, and tightly coupled to DNS and IAM. Codifying it gives you four concrete wins.
- Reproducibility. A staging account can mirror production with a single workspace switch - same configuration set name, same DKIM signing length, same SNS wiring.
- Audit trail. Every change lands in Git with an author, a diff, and a PR review, which is what SOC 2 and ISO 27001 auditors actually ask for.
- Drift detection.
terraform plansurfaces console-only changes (a colleague flippingsending_enabledto debug a campaign) before they bite you on Black Friday. - Disaster recovery. Losing a region or an account becomes a
terraform applyagainst a new backend, not a runbook scavenger hunt.
The trade-off is that DNS verification is asynchronous, so you need a couple of patterns (covered below) to keep apply deterministic.
Verifying a sending domain (DKIM, SPF, DMARC)
A verified sending domain in SES requires three DNS layers: an _amazonses TXT record for ownership, three DKIM CNAMEs for cryptographic signing, and SPF/DMARC TXT records published at the apex. Terraform's aws_ses_domain_identity and aws_ses_domain_dkim resources expose the tokens; pair them with aws_route53_record and the verification finishes in one apply.
resource "aws_ses_domain_identity" "primary" {
domain = "example.com"
}
resource "aws_ses_domain_dkim" "primary" {
domain = aws_ses_domain_identity.primary.domain
}
resource "aws_route53_record" "ses_verification" {
zone_id = var.route53_zone_id
name = "_amazonses.${aws_ses_domain_identity.primary.domain}"
type = "TXT"
ttl = 600
records = [aws_ses_domain_identity.primary.verification_token]
}
resource "aws_route53_record" "ses_dkim" {
count = 3
zone_id = var.route53_zone_id
name = "${aws_ses_domain_dkim.primary.dkim_tokens[count.index]}._domainkey.${aws_ses_domain_identity.primary.domain}"
type = "CNAME"
ttl = 600
records = ["${aws_ses_domain_dkim.primary.dkim_tokens[count.index]}.dkim.amazonses.com"]
}
For SPF and DMARC, add two more TXT records at the apex. SES requires include:amazonses.com in your SPF policy; DMARC starts permissive with p=none so you can monitor before enforcing.
resource "aws_route53_record" "spf" {
zone_id = var.route53_zone_id
name = aws_ses_domain_identity.primary.domain
type = "TXT"
ttl = 600
records = ["v=spf1 include:amazonses.com -all"]
}
resource "aws_route53_record" "dmarc" {
zone_id = var.route53_zone_id
name = "_dmarc.${aws_ses_domain_identity.primary.domain}"
type = "TXT"
ttl = 600
records = ["v=DMARC1; p=none; rua=mailto:dmarc@example.com; fo=1"]
}
If you want Terraform to block until SES confirms verification (so downstream IAM resources do not race ahead of a usable identity), add the aws_ses_domain_identity_verification data source - it polls until the status is Success or times out after 45 minutes.
v2 alternative: aws_sesv2_email_identity
The newer v2 resource collapses identity + DKIM signing attributes into one block and is the path AWS is investing in for new features. Use it for greenfield projects.
resource "aws_sesv2_email_identity" "primary" {
email_identity = "example.com"
dkim_signing_attributes {
next_signing_key_length = "RSA_2048_BIT"
}
}
The 2048-bit key is the modern default - it survives the 1024-bit deprecation pressure from major mailbox providers and is one of the recommendations we cover in how to set up Amazon SES.
Custom MAIL FROM for SPF alignment
The MAIL FROM domain (the Return-Path envelope sender) defaults to amazonses.com, which technically passes SPF but fails strict DMARC alignment. Setting a subdomain like bounce.example.com aligns SPF with your From header and is required for any DMARC policy stricter than p=none.
resource "aws_ses_domain_mail_from" "primary" {
domain = aws_ses_domain_identity.primary.domain
mail_from_domain = "bounce.${aws_ses_domain_identity.primary.domain}"
behavior_on_mx_failure = "UseDefaultValue"
}
resource "aws_route53_record" "mail_from_mx" {
zone_id = var.route53_zone_id
name = aws_ses_domain_mail_from.primary.mail_from_domain
type = "MX"
ttl = 600
records = ["10 feedback-smtp.${var.aws_region}.amazonses.com"]
}
resource "aws_route53_record" "mail_from_spf" {
zone_id = var.route53_zone_id
name = aws_ses_domain_mail_from.primary.mail_from_domain
type = "TXT"
ttl = 600
records = ["v=spf1 include:amazonses.com -all"]
}
behavior_on_mx_failure accepts UseDefaultValue (fall back to amazonses.com when the MX record is missing) or RejectMessage (refuse to send). Production should keep the default until you have monitored MAIL FROM resolution for a week.
Setting up configuration sets and event destinations
A configuration set is the routing rule for engagement events (sends, deliveries, bounces, complaints, opens, clicks, rendering failures). One configuration set per logical workload - transactional, marketing, system - lets you stream events to separate destinations and apply per-stream TLS and reputation policies.
resource "aws_ses_configuration_set" "transactional" {
name = "transactional"
delivery_options {
tls_policy = "Require"
}
reputation_metrics_enabled = true
sending_enabled = true
}
tls_policy = "Require" refuses to deliver to receivers that cannot negotiate TLS, which is the safer default for transactional mail. reputation_metrics_enabled publishes bounce and complaint rates to CloudWatch so you can wire alarms at 5% bounce and 0.1% complaint - the thresholds where SES starts throttling you.
Event destinations
Each configuration set can send events to CloudWatch, Kinesis Firehose, or SNS. The example below fans bounces, complaints, and deliveries into an SNS topic for downstream consumers (Lambda, SQS, or HTTPS subscribers).
resource "aws_sns_topic" "ses_events" {
name = "ses-transactional-events"
}
resource "aws_ses_event_destination" "sns" {
name = "all-events"
configuration_set_name = aws_ses_configuration_set.transactional.name
enabled = true
matching_types = ["bounce", "complaint", "delivery", "reject", "renderingFailure"]
sns_destination {
topic_arn = aws_sns_topic.ses_events.arn
}
}
Valid matching_types are send, reject, bounce, complaint, delivery, open, click, and renderingFailure. Include open and click only if you actually consume the data - they roughly double event volume.
Creating IAM credentials for sending applications
SES sending applications need an IAM principal with ses:SendEmail and ses:SendRawEmail on the verified identity. The least-privilege policy below scopes those actions to the specific configuration set and a single From address, which is what an auditor will ask you to demonstrate. For a deeper treatment of the policy surface, see Amazon SES IAM permissions.
resource "aws_iam_user" "ses_sender" {
name = "ses-transactional-sender"
path = "/services/"
}
data "aws_iam_policy_document" "ses_send" {
statement {
effect = "Allow"
actions = [
"ses:SendEmail",
"ses:SendRawEmail",
]
resources = [
aws_ses_domain_identity.primary.arn,
"arn:aws:ses:${var.aws_region}:${data.aws_caller_identity.current.account_id}:configuration-set/${aws_ses_configuration_set.transactional.name}",
]
condition {
test = "StringEquals"
variable = "ses:FromAddress"
values = ["noreply@${aws_ses_domain_identity.primary.domain}"]
}
}
}
resource "aws_iam_user_policy" "ses_send" {
name = "ses-send"
user = aws_iam_user.ses_sender.name
policy = data.aws_iam_policy_document.ses_send.json
}
resource "aws_iam_access_key" "ses_sender" {
user = aws_iam_user.ses_sender.name
}
data "aws_caller_identity" "current" {}
Pipe aws_iam_access_key.ses_sender.id and aws_iam_access_key.ses_sender.secret into a secrets manager (AWS Secrets Manager, Vault, Doppler) rather than a .tfvars file. For workloads running on EC2, ECS, EKS, or Lambda, replace the IAM user entirely with an instance/task/execution role and attach the same policy - no long-lived keys to rotate.
SNS feedback topics for bounce and complaint events
Even with a configuration set wired to SNS, SES recommends a second layer: per-identity notification topics for bounces and complaints. These fire regardless of which configuration set sent the message, so you catch feedback on rogue sends that bypassed your normal pipeline.
resource "aws_sns_topic" "bounces" {
name = "ses-bounces"
}
resource "aws_sns_topic" "complaints" {
name = "ses-complaints"
}
resource "aws_ses_identity_notification_topic" "bounce" {
identity = aws_ses_domain_identity.primary.domain
notification_type = "Bounce"
topic_arn = aws_sns_topic.bounces.arn
include_original_headers = true
}
resource "aws_ses_identity_notification_topic" "complaint" {
identity = aws_ses_domain_identity.primary.domain
notification_type = "Complaint"
topic_arn = aws_sns_topic.complaints.arn
include_original_headers = true
}
notification_type accepts Bounce, Complaint, or Delivery. include_original_headers = true is the difference between knowing a message bounced and knowing which campaign, subject line, and List-Unsubscribe URL it carried - turn it on.
A complete production-ready example
Stitching the pieces together produces a roughly 50-line module covering identity, DKIM, MAIL FROM, configuration set, event destination, IAM user, and SNS feedback - everything a sending application needs.
variable "domain" { type = string }
variable "aws_region" { type = string }
variable "route53_zone_id" { type = string }
resource "aws_ses_domain_identity" "this" { domain = var.domain }
resource "aws_ses_domain_dkim" "this" { domain = aws_ses_domain_identity.this.domain }
resource "aws_ses_domain_mail_from" "this" {
domain = aws_ses_domain_identity.this.domain
mail_from_domain = "bounce.${var.domain}"
behavior_on_mx_failure = "UseDefaultValue"
}
resource "aws_ses_configuration_set" "this" {
name = "${replace(var.domain, ".", "-")}-default"
reputation_metrics_enabled = true
sending_enabled = true
delivery_options { tls_policy = "Require" }
}
resource "aws_sns_topic" "feedback" { name = "${replace(var.domain, ".", "-")}-ses-feedback" }
resource "aws_ses_event_destination" "feedback" {
name = "feedback"
configuration_set_name = aws_ses_configuration_set.this.name
enabled = true
matching_types = ["bounce", "complaint", "delivery", "reject"]
sns_destination { topic_arn = aws_sns_topic.feedback.arn }
}
resource "aws_ses_identity_notification_topic" "bounce" {
identity = aws_ses_domain_identity.this.domain
notification_type = "Bounce"
topic_arn = aws_sns_topic.feedback.arn
include_original_headers = true
}
resource "aws_iam_user" "sender" { name = "${replace(var.domain, ".", "-")}-ses-sender" }
resource "aws_iam_access_key" "sender" { user = aws_iam_user.sender.name }
data "aws_iam_policy_document" "send" {
statement {
actions = ["ses:SendEmail", "ses:SendRawEmail"]
resources = [aws_ses_domain_identity.this.arn]
}
}
resource "aws_iam_user_policy" "send" {
name = "ses-send"
user = aws_iam_user.sender.name
policy = data.aws_iam_policy_document.send.json
}
Add the four Route 53 records (_amazonses, three DKIM CNAMEs, MAIL FROM MX, SPF, DMARC) shown earlier and you have a complete, auditable SES stack.
| Resource | Purpose | Required? |
|---|---|---|
| aws_ses_domain_identity | Register the sending domain with SES | Yes |
| aws_ses_domain_dkim | Generate three DKIM signing tokens | Yes |
| aws_ses_domain_mail_from | Custom Return-Path for SPF/DMARC alignment | Recommended |
| aws_ses_configuration_set | Per-workload TLS, reputation, sending toggles | Yes |
| aws_ses_event_destination | Stream events to SNS, Kinesis, or CloudWatch | Recommended |
| aws_ses_identity_notification_topic | Identity-level bounce/complaint SNS feedback | Recommended |
| aws_iam_user + access_key | Credentials for sending applications | Yes |
Common Terraform pitfalls
Three failure modes catch almost every team adopting SES-as-code: state drift from console edits, DNS verification race conditions, and IAM credential leakage. Each has a structural fix, not a runbook fix.
- State drift from console edits. A teammate flips
sending_enabledoff to debug, forgets, and your nextapplyre-enables it - or worse, it stays off because someone imported it. Runterraform planon a schedule (Atlantis, Spacelift, or a GitHub Action) and alert on non-zero diffs. Treat the console as read-only outside of break-glass procedures. - DNS verification race conditions.
aws_ses_identity_notification_topicwill fail withInvalidParameter: Identity not verifiedif it applies before DNS propagates. The fix is either an explicitdepends_onchain through the verification data source, or splitting DNS into a stage-zero workspace that runs first. - DKIM key rotation needs planning. Switching
next_signing_key_lengthfromRSA_1024_BITtoRSA_2048_BITrotates the keys, which republishes three new CNAMEs. If your DNS is also managed by Terraform, this is one clean apply; if not, schedule a window and pre-publish the new records. - IAM access keys in state files.
aws_iam_access_key.secretends up in your Terraform state as plaintext. Use a remote backend with encryption at rest (S3 + KMS), restrict state access via IAM, and pipe the secret straight into a secret manager output - never to a.tfvarsor a CI log. - Sandbox accounts cannot send to arbitrary recipients. New SES accounts start in sandbox mode (verified-to-verified only). Terraform cannot lift this - file a production-access support ticket and plan for one to two business days before launch.
How Mailblast fits into a Terraform-managed SES stack
Mailblast is the management layer on top of your own Amazon SES account - the campaign editor, list management, automation, and analytics that you would otherwise build yourself. Terraform still owns the infrastructure underneath: domain identity, DKIM, configuration sets, IAM, SNS feedback. You point Mailblast at the IAM credentials your module produces, and it handles drag-and-drop authoring, Liquid templating, double opt-in, suppression lists, and per-recipient engagement reporting on top of the SES primitives.
The split keeps your DevOps and marketing surfaces clean. Platform engineers manage SES with the module above, marketing self-serves through Mailblast, and audit gets one Git history covering every infrastructure change. The free plan includes 1,000 contacts and 10,000 emails per month; paid plans start at $10/month for that same 1,000 contacts and add automation plus unbranded sending, with reputation staying tied to your own domain and your own SES account. Total cost is the Mailblast subscription plus your existing SES per-1,000 fees, and your IaC story stays intact.
FAQ
Should I use the SES v1 or v2 Terraform resources?
Use v2 (aws_sesv2_email_identity) for new projects - it consolidates identity creation, DKIM signing attributes, and configuration set association into one resource. The v1 resources (aws_ses_domain_identity, aws_ses_domain_dkim) still work and remain supported, so existing modules do not need an urgent rewrite, but v2 is the path AWS is investing in.
Why does terraform apply succeed but SES still shows my domain as unverified?
Terraform creates the identity instantly, but SES only marks it verified after it can resolve the _amazonses TXT record and three DKIM CNAMEs from public DNS. If you manage DNS outside Terraform, propagation can take minutes to hours. Use the aws_ses_domain_identity_verification data source to make Terraform block until SES confirms verification, preventing downstream resources from racing ahead.
Do I need a separate IAM user for every application, or can they share one?
Best practice is one IAM user (or role) per sending application or environment. Separate credentials let you scope ses:FromAddress conditions per app, rotate keys without disrupting unrelated services, and trace abuse to a single workload. For EC2, ECS, or Lambda, prefer instance/task/execution roles over long-lived access keys.
Can Terraform request production access to move my account out of the SES sandbox?
No. Sandbox-to-production access is a manual AWS Support ticket review and is not exposed as an API. Terraform can provision every resource around it (identities, configuration sets, IAM, SNS), but you must file the production-access request from the SES console. Plan for one to two business days of lead time before launching.
How do I handle multiple regions for SES failover with Terraform?
Declare a provider alias per region (provider "aws" { alias = "us_east_1" region = "us-east-1" }) and create a domain identity in each. DKIM tokens differ per region, so publish all CNAMEs to the same DNS zone. Configuration sets and IAM users are regional too - either duplicate them per region or use a single global IAM user with permissions across all regions.
Disclosure: This post is published by Mailblast, a hosted management layer for teams sending email through their own Amazon SES account.