Illustrative scenario. A representative engagement shape, not a client deployment. Design and rationale are real and reusable; the company is not. Grounded in the AWS Well-Architected Framework and AWS multi-account guidance.
1. Scenario and constraints
A Series A B2B SaaS company, roughly 40 engineers, runs a containerizable web application and a PostgreSQL database. Everything — production, staging, and one-off experiments — lives in a single AWS account and a single VPC, provisioned by hand in the console over two years.
Enterprise deals are now on the table, and each one stalls at the same two gates: a SOC 2 Type II report, and a contractual 99.9% uptime SLA.
| Constraint | Value | Drives |
|---|---|---|
| Enterprise SLA | 99.9%, contractual | Multi-AZ everywhere, no single points of failure |
| Compliance | SOC 2 Type II within ~6 months | Account isolation, centralized immutable logging, continuous config monitoring |
| Current state | One account, one VPC, all click-ops | Multi-account org and IaC, built from zero |
| Blast radius | Prod shares an account with staging and experiments | Account-per-environment |
| Team | ~40 engineers, AWS-literate, no platform team | Managed services; Terraform with guardrails, not raw console access |
| Cost | Series A — spend must stay proportional to revenue | Right-sizing, Savings Plans after baseline, no speculative multi-region |
| Tenancy | Multi-tenant SaaS | Tenant isolation at the application and data layer |
The "no platform team" line matters as much as the SLA. Forty engineers who have only ever used the console will not absorb a Kubernetes control plane and a hand-rolled Terraform monorepo at the same time. The design has to be operable by the team that exists.
2. Account topology
The move from one account to many is the foundational decision, and it is what most directly unblocks SOC 2. AWS gives you the account as the hardest security and billing boundary it has, so environments and trust domains become separate accounts, governed centrally through AWS Organizations and provisioned with AWS Control Tower. This follows AWS's own multi-account guidance.
| Organizational unit | Account | Holds |
|---|---|---|
| Security | log-archive | Immutable CloudTrail and Config history, write-once |
| Security | audit | Read-only cross-account access for auditors and security tooling |
| Infrastructure | network | Transit Gateway, shared VPC endpoints, egress |
| Infrastructure | shared-services | CI/CD runners, Terraform state, container registry |
| Workloads | prod | Production VPC and workload |
| Workloads | staging | Pre-production, identical shape to prod |
| Sandbox | sandbox | Experiments, isolated, with a hard budget cap |
Seven accounts, not one per service. The split is by trust boundary and environment, which is what an auditor reasons about and what contains a blast radius. Guardrails are applied once at the OU level as Service Control Policies — deny leaving the approved regions, deny disabling CloudTrail, deny public S3 — so a policy is inherited by every account rather than reapplied by hand.
The log-archive account is the linchpin for SOC 2: the first thing a serious
incident touches is the audit trail, and logs that live in the account being
investigated are evidence you may not be able to trust. Here no workload role
can reach it.
3. Tagging and naming
AWS does not impose a naming scheme, so one is defined and then enforced by AWS Config rules and SCPs, not by convention. A tagging standard that relies on discipline decays; one that fails a Config rule does not.
<org>-<workload>-<environment>-<region>-<resource>| Resource | Name |
|---|---|
| ECS cluster | acme-app-prod-use1-cluster |
| ECS service | acme-app-prod-use1-web |
| RDS instance | acme-app-prod-use1-pg |
| ALB | acme-app-prod-use1-alb |
| S3 bucket | acme-app-prod-use1-assets |
Every resource carries a mandatory tag set, following AWS
tagging best practices:
Environment, Owner, CostCenter, DataClassification, and ManagedBy:terraform.
CostCenter turns cost allocation into a report rather than an
investigation; DataClassification is what lets a reviewer find every
resource that touches customer data in one query.
4. Network design
One VPC per workload account, connected through a
Transit Gateway
in the network account — the AWS counterpart to a hub-and-spoke. Region
us-east-1, spanning two Availability Zones for the 99.9% target.
| Scope | Range | Notes |
|---|---|---|
| Network VPC | 10.0.0.0/21 | network account, Transit Gateway and egress |
| Prod VPC | 10.1.0.0/21 | prod account |
↳ public-a / public-b | 10.1.0.0/24 · 10.1.1.0/24 | ALB, NAT gateways |
↳ app-a / app-b | 10.1.2.0/24 · 10.1.3.0/24 | ECS Fargate tasks |
↳ data-a / data-b | 10.1.4.0/24 · 10.1.5.0/24 | RDS, no route to the internet |
| Staging VPC | 10.2.0.0/21 | staging account, identical layout |
| Reserved | 10.3.0.0/21 – 10.15.0.0/21 | Future accounts |
Non-overlapping ranges assigned from a plan, because Transit Gateway will not route between two VPCs with overlapping CIDRs, and re-addressing a live VPC is a migration, not an edit.
The data subnets have no route to a NAT gateway or the internet in either direction. The database is reachable only from the app tier, inside the VPC.
5. Design decisions
| Decision | Choice | Why | Rejected |
|---|---|---|---|
| Compute | ECS Fargate | A containerizable monolith needs a container runtime, not an orchestration platform; Fargate removes node patching and scaling from a team with no platform function | EKS — a Kubernetes control plane is an operational commitment this team cannot staff; EC2 — instance patching and AMI management become the team's job; Lambda — the app is long-running, not a set of event handlers, so this is a rewrite |
| Database | RDS PostgreSQL, Multi-AZ | Managed backups, patching and synchronous standby failover meet the SLA with no custom work; the app already speaks Postgres | Self-managed on EC2 — replication, backups and failover become manual; Aurora — stronger, but a larger change than this migration needs on day one |
| Edge | CloudFront + WAF | TLS termination, managed rule groups, and a cache that absorbs load off the origin | ALB alone — no managed WAF rules, no edge caching, no global POPs |
| Ingress | Application Load Balancer | Health checks and target groups drive zero-downtime ECS rolling deploys | Network Load Balancer — layer 4 only, no path routing for a web app |
| Secrets | Secrets Manager + ECS task roles | The task assumes a role and reads the secret at runtime; nothing is baked into an image or an environment variable | Secrets in task definitions — plaintext in the console and in state, the most common SOC 2 finding |
| Identity | IAM Identity Center, SSO, no IAM users | Short-lived credentials federated from the identity provider; nothing long-lived to leak | Per-account IAM users — static keys that leak into laptops and CI and never expire |
| Detection | GuardDuty + Config, org-wide | Continuous threat detection and config drift, aggregated to the audit account — a standing SOC 2 control, not a quarterly scramble | Point-in-time audits — evidence assembled by hand before each review |
6. Resource inventory
| Service | Size / tier | Purpose |
|---|---|---|
| ECS Fargate | 2 vCPU / 4 GB tasks, min 4 across 2 AZs, target-tracking autoscale | Web tier |
| RDS PostgreSQL | db.r6g.xlarge, Multi-AZ, gp3 storage | Application database |
| CloudFront | Standard, WAF attached | Global edge, caching, TLS |
| Application Load Balancer | Cross-zone | Ingress, health-checked rolling deploys |
| Transit Gateway | Single, in network | Inter-VPC routing |
| S3 | Versioned, SSE-KMS | Assets, backups, Terraform state |
| Secrets Manager | Rotation enabled | Database and third-party credentials |
| CloudTrail + Config | Org trail to log-archive | Audit and compliance evidence |
db.r6g is deliberate: Graviton (ARM) instances run the same PostgreSQL at a
lower price than the equivalent Intel class, and the database is the largest
steady-state line item.
7. Identity and access
- IAM Identity Center federates SSO into every account; there are no standing IAM users
- Human access is role assumption, time-bound, with production behind an elevated permission set that is granted per-session, not held
- Workload access is ECS task roles — each service assumes exactly the permissions it needs, and reads secrets at runtime
- CI/CD authenticates by OIDC federation (see the Terraform section) — no AWS keys are stored in the pipeline
- Cross-account read for auditors terminates in the
auditaccount, which can see everything and change nothing
8. Resilience: meeting the SLA
| Requirement | Mechanism | Result |
|---|---|---|
| 99.9% uptime | ECS tasks across 2 AZs behind a cross-zone ALB; the loss of an AZ removes capacity, not availability | Meets target |
| Database failover | RDS Multi-AZ synchronous standby, automatic promotion | Failover in ~60–120s, no data loss |
| Zero-downtime deploys | ALB health checks drive ECS rolling replacement | No maintenance windows |
| Backup | Automated RDS snapshots, point-in-time recovery, cross-region snapshot copy | Recoverable from Region loss |
| Recovery from error | Everything is Terraform; the environment rebuilds from source | Reproducible, not a runbook |
Availability is designed at the Availability Zone level. Full multi-Region active-active is deliberately not in the baseline — it roughly doubles cost and complexity, and 99.9% does not require it. The threshold where that changes is in section 11.
9. Cost: what dominates and what moves it
| Lever | Mechanism | Notes |
|---|---|---|
| NAT gateway traffic | Route AWS-bound traffic through VPC endpoints, not the NAT gateway | The most routinely underestimated line on an AWS bill — NAT charges per hour and per GB. S3 and DynamoDB gateway endpoints are free and also keep the traffic off the public internet, so this is a security win as well |
| Graviton | db.r6g and ARM Fargate | Same workload, lower price, no code change for a container |
| Compute Savings Plans | 1-year commitment on the steady-state Fargate baseline | Applied after the baseline is measured, never at design time |
| Right-sizing | Task CPU/memory reviewed against real utilization after the first full cycle | Pre-sizing a workload nobody has run yet produces confident guesses |
| Non-prod | Staging scaled down and scheduled off outside working hours; sandbox under a hard budget action | Staging at prod scale is spending for an SLA it does not have |
| S3 lifecycle | Logs transition to infrequent-access, then Glacier, on a schedule | SOC 2 retention runs years; storing years of logs at standard price is a large avoidable cost |
Budgets and anomaly alerts are provisioned by the same Terraform that creates the resources, so cost is a designed property rather than a monthly surprise.
10. Delivery: Terraform
The current environment is entirely click-ops, so the highest-leverage change is making every resource Terraform-managed. This is also a SOC 2 control in its own right: every infrastructure change arrives with an author, a reviewer, and a diff.
State. Remote state in S3 with DynamoDB locking,
in the shared-services account, one state file per account and
environment. A single monolithic state is a single blast radius — a bad
apply against prod should be unable to touch staging, and separate state
guarantees it.
Structure. Reusable child modules
(network, ecs-service, rds, observability) composed by thin
per-environment root modules. Staging and prod call the same modules with
different variables, so "it worked in staging" is a meaningful statement.
Directory-per-environment, not workspaces. Each environment is its own directory with its own backend. Workspaces share a backend and a codebase and make it far too easy to run an apply against the wrong one; a directory boundary is visible in the path.
CI/CD. GitHub Actions authenticating by
OIDC federation
— no long-lived AWS keys anywhere. terraform plan runs on every pull
request and the plan
is posted as a comment; apply runs on merge, gated behind an environment
approval for production.
Policy as code, before apply. Every plan is scanned against policy rules so a public bucket or an unencrypted volume fails the pull request rather than reaching an account — the pattern behind our Infrastructure Policy Check API.
Drift detection. A scheduled plan surfaces any change made in the
console out of band. Click-ops is the disease being cured here, so detecting
its return is part of the cure.
For the deeper Terraform-versus-alternatives argument, see our post on Terraform vs Bicep.
11. What changes if
| If | Then |
|---|---|
| Traffic becomes spiky or event-driven | Move those paths to Lambda and EventBridge; keep Fargate for the steady web tier |
| A platform team is hired | EKS becomes viable and gives denser multi-service packing, now that someone can own the cluster |
| RTO must drop below ~15 minutes across a Region | Active-passive DR in a second Region with a warm standby; expect a significant cost increase |
| PCI scope is added | The cardholder data environment gets its own account and network segment, isolated from the main workload |
| EU customers require data residency | A separate regional stack, with an SCP that denies resource creation outside the permitted Regions |
| Cost becomes the binding constraint | Aggressive Fargate right-sizing, single-AZ staging, and an explicitly agreed lower non-prod SLA — written down, not discovered in an incident |
12. What we deliver
- This topology as reviewed Terraform, structured as reusable modules and per-environment roots, parameterised to your constraints
- The multi-account Organization and Control Tower guardrails, with SCPs applied at the OU level
- A CI/CD pipeline with OIDC, plan-on-PR, policy scanning, and drift detection
- The evidence a SOC 2 auditor asks for — centralized immutable logs, continuous config monitoring, and a change history — produced as a byproduct of the pipeline rather than assembled by hand
- A cost baseline with budgets and anomaly alerts deployed alongside the infrastructure