SES Environment Separation: Dev, Staging, and Prod
Most teams run dev, staging, and prod SES traffic from one AWS account. Here's how to separate environments properly — and the trade-offs.
A QA engineer at a fintech I worked with once kicked off a load test against staging. The load test was supposed to fire 50,000 fake password resets to seed addresses. It fired 50,000 real password resets to real production users instead. Why? Because staging and production both lived in the same AWS account, both used the same SES identity, and the test harness had a configuration override that pointed it at the production database "just for one experiment, two weeks ago."
Nobody had touched that override since. The seed addresses were never seed addresses. The production users got password reset emails on a Tuesday afternoon. The fintech ended up paying for breach notifications and an external review.
This is what bad environment separation looks like in SES specifically. Not a single dramatic mistake — a chain of small, individually defensible decisions that combine into an incident. Most SaaS teams have the ingredients for this story in their AWS account today. Whether they cook it depends on luck.
Why a single AWS account for SES is a bad default
A single AWS account makes everything easier — until it doesn't. For SES, the costs of mixing environments are sharper than for most services.
Shared identities and reputation. SES domain identities, sender reputation, bounce rate, and complaint rate are account-and-region-scoped. A test that generates 10,000 hard bounces hurts your production reputation. A buggy onboarding flow firing welcome emails into the void from staging burns down the same domain your production billing emails are sent from.
Shared sandbox status. When an account graduates from sandbox to production sending, it does so as one unit. You can't have a sandbox staging environment and a production-quota production environment in the same account.
Shared suppression list. SES maintains an account-level suppression list of addresses that hard bounced or complained. A staging test that bounces against a typo'd domain adds those addresses to a list that affects your production sends.
Shared configuration sets. Configuration sets — the SES feature for routing event destinations, IP pools, and tracking options — are account-scoped. You can have separate configuration sets per environment in one account, but you have to be disciplined about which one each send uses, and a misconfigured default will quietly merge environments.
Shared template namespace. Templates are account-and-region-scoped. welcome in staging and welcome in production are the same template if they're in the same account-region.
Each of these is solvable inside a single account. None of them is solved by default.
Account-per-environment vs region-per-environment
There are two serious options for separating SES environments. They have different trade-offs and the right answer depends on how your AWS organization is already set up.
Account-per-environment. Every environment lives in its own AWS account. Production is one account, staging is another, dev is a third (or shared with developer sandboxes). This is the AWS-recommended pattern in general and the right answer for SES specifically. Identities, reputation, suppression lists, configuration sets, templates — everything is isolated. A staging mistake cannot, by construction, affect production.
The cost is real. AWS Organizations to manage them. Cross-account IAM for legitimate cross-environment work. SSO and identity center setup. Per-account verified domains. Per-account quotas to request. If you don't already have multi-account discipline, adopting it for SES alone is a heavy lift.
Region-per-environment in one account. Production in eu-west-1, staging in eu-west-2, dev in eu-central-1. SES resources are region-scoped, so this gives you separate identities, separate templates, separate suppression lists. Production reputation is isolated.
The cost: it shares some account-level concerns (sandbox status applies per region, but root-level account compromise affects all of them), and it locks you into a regional architecture that may not match the rest of your infrastructure. It also confuses operators who expect "region" to be a deployment target, not an environment marker.
For most B2B SaaS teams I see, the right path is account-per-environment if you have any AWS Organizations setup at all, and region-per-environment if you genuinely have a single-account constraint you can't move away from. A single account, single region setup is a problem to fix, not a configuration to live with.
Sandbox status: an environment story whether you like it or not
Every new SES account starts in sandbox. Sandbox limits sending to verified identities only and caps you at 200 emails per 24 hours. To exit sandbox, you submit a request to AWS that explains your use case.
This interacts with environment separation in a non-obvious way. If you go account-per-environment, every account starts in sandbox. You will need to graduate your production account, your staging account, and probably your dev account separately. The applications differ — staging genuinely is a sandbox in spirit, and AWS reviewers know this — so writing "this is for staging traffic to internal addresses only" gives you what you actually need without forcing the same review your production case got.
A common mistake is graduating one shared account to production sending limits and treating the sandbox status of other environments as a problem. It isn't. Staging being limited to verified addresses is a feature. Anything that fires off mail in staging should be sending to addresses you control anyway.
Naming and tagging conventions that survive growth
If you're stuck with a single account for now, naming is doing all the work. The conventions that survive are the ones that make the wrong send obvious.
Templates. welcome is a name that will burn you. welcome_prod and welcome_staging is better. prod_welcome is even better — environment first, so it sorts by environment in any list view. Use whatever convention you like, but make it impossible to look at a template name without seeing the environment.
Configuration sets. Same rule. prod_default, staging_default. Tag each configuration set with the environment as well — Environment=production — so you can grep across the account.
Identities. Subdomain per environment is the safest pattern. mail.acme.com for production, mail.staging.acme.com for staging. Verified separately. DKIM signed separately. Failure in one cannot taint the other's reputation.
Suppression list entries. SES doesn't tag suppression entries by source, which is one of the structural reasons single-account multi-environment SES is painful. If staging hard-bounces an address, it sits in the same list production reads from. The only mitigation is making sure staging cannot hard-bounce production-relevant addresses, which is back to the verified-identity argument.
Preventing accidental cross-environment sends at the application layer
Naming gets you part of the way. The other part is application code that can't be tricked.
The pattern that works is environment determination from infrastructure, not configuration. The application reads its environment from a source that staging code cannot fake — the AWS account ID via STS, the EKS namespace it runs in, an instance metadata tag — and selects the SES template name and configuration set deterministically from that.
import boto3
def current_environment() -> str:
sts = boto3.client("sts")
account_id = sts.get_caller_identity()["Account"]
return ACCOUNT_TO_ENV[account_id] # Hardcoded mapping
def template_for(name: str) -> str:
return f"{current_environment()}_{name}"
The thing this prevents is the QA-engineer-with-an-override case from the opening of this post. There is no environment variable to flip. There is no configuration file to override. The application sends to the templates that match the account it's running in, full stop.
A safer variant prevents the application from sending at all if the account ID is one it doesn't recognize. Default-deny, not default-allow.
Per-environment suppression list strategy
If you go account-per-environment, suppression lists are isolated by default. If you don't, you're going to need a strategy.
The pragmatic approach: production maintains its suppression list as the source of truth. Staging gets a copy of production's suppression list synced periodically — read-only — so staging can't send to addresses that have unsubscribed in production. Staging's own bounces and complaints don't propagate to production.
If you're forced into single-account, this is the only pattern I've seen work in practice. It treats production as canonical and staging as a derived environment, which is how it should be.
Where Sovy fits
Environment separation is, fundamentally, an AWS architecture decision. Sovy doesn't replace that decision, and it's not a substitute for proper account isolation.
What Sovy does is enforce environment-aware template management on top of whatever AWS structure you choose. Templates are first-class environment-scoped objects in Sovy: a change to the staging version of a template does not propagate to production until somebody explicitly promotes it through a defined workflow. Role-based access can be scoped per environment — your support team can edit staging templates but not production. Audit logs capture every environment-crossing operation.
The point isn't that Sovy is the right answer if your environments share an AWS account. It's that whichever AWS architecture you have, the human-facing layer that controls template promotion across environments is something you either build or buy. Building it usually means CI scripts, naming conventions, and tribal knowledge. Buying it means a tool that's been thinking about this specific problem.
A reference setup, briefly
For a B2B SaaS with three environments and existing AWS Organizations:
- Three AWS accounts:
acme-prod,acme-staging,acme-dev. - Per-account verified domains:
mail.acme.com,mail.staging.acme.com,mail.dev.acme.com. - Per-account configuration sets, named simply (
default). - Per-account template namespace, with templates carrying their semantic name only (
welcome, notprod_welcome). - Application code that reads its account ID from STS and refuses to send if the account doesn't match its expected mapping.
- Production suppression list synced read-only into staging weekly.
- A control plane (whatever you choose) that promotes template changes from dev to staging to production through an approval workflow, with audit at each step.
This is boring. That is the point. Boring infrastructure for transactional email is the goal — interesting infrastructure here means incidents.
Sovy is a control layer for Amazon SES templates. It adds environment-scoped versioning, audit logs, and role-based access on top of SES while staying outside the email delivery path. If your team is operating across multiple environments and the manual coordination is starting to bite, we'd like to hear from you.