← Back to list

Demystifying AWS SES in Production: Multi-Tenant Architecture, Terraform Automation, and Lessons…

How to build a secure, multi-tenant email pipeline with infrastructure as code, bypass IAM permission pitfalls, and ace the AWS sandbox…

Minamalak · 2026-06-07 16:26 · 0 claps · 5.1 min read
#terraform #continuous-integration #continuous-delivery #aws #s
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 🏛️ · Architecture

Demystifying AWS SES in Production: Multi-Tenant Architecture, Terraform Automation, and Lessons Learned

How to build a secure, multi-tenant email pipeline with infrastructure as code, bypass IAM permission pitfalls, and ace the AWS sandbox compliance review.

Moving an application’s email infrastructure to the cloud sounds straightforward: verify a domain, generate some SMTP keys, and start sending. However, anyone who has configured Amazon Simple Email Service (SES) at an enterprise level knows that the path to production is filled with strict guardrails, eventual consistency gotchas, and rigid compliance checks.

When building a secure, scalable, multi-tenant email pipeline, handling configuration manually in the AWS Console is a recipe for drift and security vulnerabilities.

This guide outlines a production-ready approach to automating AWS SES using Terraform and Terragrunt, mastering the AWS Sandbox compliance review, and why a strict “Plan-First” workflow is your absolute best defense against cloud delivery disruptions.

1. Automated Infrastructure: The Terraform Blueprint

In a modern multi-tenant architecture, hardcoded resources are an operational liability. If you deploy infrastructure for multiple clients or environments (Dev, Staging, Prod), your Terraform module must be dynamic, collision-free, and locked down by strict Identity and Access Management (IAM) policies.

Here is a comprehensive, production-grade main.tf configuration that provisions a verified domain, sets up bulletproof DNS security (DKIM, SPF, DMARC), creates an isolated SMTP user, and securely stores credentials in AWS Secrets Manager.

Terraform

locals {
  secret_name = "ses-secrets-${var.account_name}-${var.region}"
}
# =========================================================================
# 1. SES Domain Identity & Verification
# =========================================================================
resource "aws_ses_domain_identity" "domain_identity" {
  domain = var.domain_name
}
resource "aws_route53_record" "ses_verification" {
  zone_id = var.zone_id
  name    = "_amazonses.${aws_ses_domain_identity.domain_identity.domain}"
  type    = "TXT"
  ttl     = "600"
  records = [aws_ses_domain_identity.domain_identity.verification_token]
}
# =========================================================================
# 2. Advanced Email Authentication (DKIM, SPF, DMARC)
# =========================================================================
resource "aws_ses_domain_dkim" "dkim_identity" {
  domain = aws_ses_domain_identity.domain_identity.domain
}
resource "aws_route53_record" "amazonses_dkim_record" {
  count   = 3
  zone_id = var.zone_id
  name    = "${aws_ses_domain_dkim.dkim_identity.dkim_tokens[count.index]}._domainkey"
  type    = "CNAME"
  ttl     = "300"
  records = ["${aws_ses_domain_dkim.dkim_identity.dkim_tokens[count.index]}.dkim.amazonses.com"]
}
resource "aws_ses_domain_mail_from" "main" {
  domain           = aws_ses_domain_identity.domain_identity.domain
  mail_from_domain = "mail.${aws_ses_domain_identity.domain_identity.domain}"
}
resource "aws_route53_record" "ses_spf" {
  zone_id = var.zone_id
  name    = aws_ses_domain_mail_from.main.mail_from_domain
  type    = "TXT"
  ttl     = "600"
  records = ["v=spf1 include:amazonses.com ~all"]
}
resource "aws_route53_record" "dmarc" {
  zone_id = var.zone_id
  name    = "_dmarc.${var.domain_name}"
  type    = "TXT"
  ttl     = "600"
  records = ["v=DMARC1; p=none;"] 
}
# =========================================================================
# 3. Isolated SMTP User & Secrets Management
# =========================================================================
resource "aws_iam_user" "smtp_user" {
  name = "ses-smtp-${var.account_name}-${var.region}"
}
resource "aws_iam_access_key" "smtp_key" {
  user = aws_iam_user.smtp_user.name
}
resource "aws_secretsmanager_secret" "ses_smtp_creds" {
  name        = local.secret_name
  description = "SES SMTP credentials for application runtime"
}
resource "aws_secretsmanager_secret_version" "ses_creds_val" {
  secret_id     = aws_secretsmanager_secret.ses_smtp_creds.id
  secret_string = jsonencode({
    smtp_server   = "email-smtp.${var.region}.amazonaws.com"
    smtp_port     = 587
    smtp_username = aws_iam_access_key.smtp_key.id
    smtp_password = aws_iam_access_key.smtp_key.ses_smtp_password_v4
    sender_email  = "support@${aws_ses_domain_identity.domain_identity.domain}"
  })
}
# =========================================================================
# 4. Granular IAM Permissions (The SMTP Gotcha Fix)
# =========================================================================
resource "aws_iam_policy" "ses_api_sender" {
  name        = "SES-API-Sender-Policy-${var.account_name}-${var.region}"
  description = "Allows the application to send emails via SES API and SMTP interfaces"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = [
          "ses:SendEmail",
          "ses:SendRawEmail"
        ]
        Resource = ["*"]
        Condition = {
          StringLike = {
            "ses:FromAddress" = "*@${aws_ses_domain_identity.domain_identity.domain}"
          }
        }
      }
    ]
  })
}
resource "aws_iam_user_policy_attachment" "attach_smtp_user" {
  user       = aws_iam_user.smtp_user.name
  policy_arn = aws_iam_policy.ses_api_sender.arn
}

Architectural Highlights:

  • The Condition Block Strategy: Notice that the IAM policy targets Resource = ["*"] but uses an explicit Condition block restricting ses:FromAddress to *@yourdomain.com. This is highly critical. When sending via the raw SMTP interface, AWS evaluates policies against both the sender and recipient addresses. Restricting the Resource to just your domain will trigger an immediate 554 Access Denied on recipient validation. This condition block provides maximum security while allowing outbound delivery to external recipients.
  • Dynamically Naming Resources: Appending ${var.account_name}-${var.region} to IAM policies, roles, and Secrets Manager blocks prevents state naming collisions entirely when scaling multi-tenant stacks across multiple AWS regions or accounts.

2. Navigating the AWS Compliance Guardrail: Production Access

Every new AWS account has its SES instance locked inside the SES Sandbox environment. In Sandbox mode, you encounter two strict, anti-abuse limitations:

  1. You can only send mail from verified domains or emails.
  2. You can only send mail to explicitly verified recipient email addresses.

To build a true production system, you must ask AWS to lift these restrictions. This is a strictly manual, ticket-driven process. AWS intentionally does not expose an API endpoint or a Terraform resource to request production access; it requires a human compliance audit to prevent spam networks from weaponizing their infrastructure.

Pro-Tip: How to Guarantee First-Time Sandbox Approval

When you navigate to your AWS SES Dashboard and click “Request Production Access”, you will be asked to fill out a detailed text application outlining your use case. To ensure your request passes AWS review on the first try without back-and-forth delays, follow this structural template:

  • Mail Type: Always choose Transactional. Marketing mail undergoes significantly heavier scrutiny.
  • Website URL: Provide your primary, public corporate website or an application landing page. AWS engineers will click this link to verify that a legitimate enterprise exists behind the domain request.
  • The Use Case Pitch: In the written details description, explicitly call out your delivery hygiene. State clearly that you are running transactional application alerts, that your domain is fully hardened with SPF/DKIM/DMARC configurations, and that bounce and complaint metrics are actively tracked via internal application logging.

3. The Golden Rule of Infrastructure as Code: Always Plan First

In high-velocity CI/CD software pipelines, it can be tempting to stitch your pipeline steps together tightly and let automation automatically run apply directly after a code push. With infrastructure, that approach introduces massive hidden risks.

In enterprise engineering, you should always run a separate, decoupled plan phase first, review it, and then explicitly authorize an apply.

Consider the structural GitHub Actions architecture below. It utilizes a deliberate human-in-the-loop manual switch (inputs.apply) combined with automated multi-account matrix computation to cleanly isolate planning from active deployment.

Automated CI/CD Pipeline Architecture (manual_trigger.yml)

name: "IL - Manual Trigger Pipeline"

on:
  workflow_dispatch:
    inputs:
      target:
        required: true
        type: choice
        description: "Select the specific tenant target zone"
        options:
          - admin.dev.us-west-2
          - admin.staging.us-west-2
          - admin.production.us-west-2
      apply:
        required: true
        type: boolean
        description: "⚠️ Check to execute active deployment ⚠️"
        default: false

jobs:
  compute_matrix:
    runs-on: ubuntu-24.04
    outputs:
      matrix_values: ${{ steps.set_matrix.outputs.matrix_values }}
    steps:
      - uses: actions/checkout@v4
      - id: set_matrix
        run: |
          # Dynamically format target array into an isolated JSON matrix block
          targets="{\"target\": [\"${{ inputs.target }}\"]}"
          echo "matrix_values=${targets}" >> "$GITHUB_OUTPUT"

  plan:
    if: inputs.apply == false
    needs: compute_matrix
    strategy:
      matrix: ${{ fromJson(needs.compute_matrix.outputs.matrix_values) }}
    uses: ./.github/workflows/il_plan.yml
    with:
      target: ${{ matrix.target }}

  plan_and_apply:
    if: inputs.apply == true
    needs: compute_matrix
    strategy:
      matrix: ${{ fromJson(needs.compute_matrix.outputs.matrix_values) }}
    uses: ./.github/workflows/il_plan_and_apply.yml
    with:
      target: ${{ matrix.target }}

Why “Plan-First” Saves Environments:

  1. Preventing Destructive Recreations: Certain modifications in cloud infrastructure (like mutating immutable properties on active databases or network subnets) can cause Terraform to quietly choose a Destroy and Recreate execution path. Running a standalone plan forces these destructive modifications into plain sight.
  2. State Concurrency Security: In complex environments where team members collaborate across multiple Git branches, reviewing the plan output guarantees that your local configuration aligns perfectly with live cloud realities before executing structural mutations.

Conclusion

Automating enterprise-grade communication setups goes far beyond generating a simple user token. By abstracting resource provisioning using Terraform, locking down permissions through granular IAM Condition blocks, structuring your AWS production requests to focus purely on authenticated transactional hygiene, and maintaining a strict, defensive Plan-First workflow in your deployment engine, you eliminate drift and secure your system against accidental disruptions.

Treat your infrastructure code with the exact same rigor as your core product code, and your cloud communication pipelines will scale effortlessly with your architecture.


메타데이터
post_id
aedcaf7f9783
slug
demystifying-aws-ses-in-production-multi-tenant-architecture-terraform-automation-and-lessons-aedcaf7f9783
url
https://medium.com/@mina-malak/demystifying-aws-ses-in-production-multi-tenant-architecture-terraform-automation-and-lessons-aedcaf7f9783
canonical_url
https://medium.com/@mina-malak/demystifying-aws-ses-in-production-multi-tenant-architecture-terraform-automation-and-lessons-aedcaf7f9783
author_url
https://medium.com/@mina-malak
status
ok
fetched_at
2026-06-20 20:29:01