Amazon SES Terraform Setup: A Production-Ready Walkthrough

Isometric illustration of a teal Terraform pillar with HCL file icons and a purple automation gear, representing Amazon SES infrastructure as code

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 plan surfaces console-only changes (a colleague flipping sending_enabled to debug a campaign) before they bite you on Black Friday.
  • Disaster recovery. Losing a region or an account becomes a terraform apply against 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
Source: HashiCorp AWS provider documentation, 2026.

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_enabled off to debug, forgets, and your next apply re-enables it - or worse, it stays off because someone imported it. Run terraform plan on 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_topic will fail with InvalidParameter: Identity not verified if it applies before DNS propagates. The fix is either an explicit depends_on chain 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_length from RSA_1024_BIT to RSA_2048_BIT rotates 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.secret ends 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 .tfvars or 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.

Ready to Start Your Email Marketing Journey?

Join thousands of businesses using Mailblast to grow their audience.

← Back to Blog