Skip to content
← All work
Reference architecture

Taking a single-account AWS SaaS to multi-account, SOC 2-ready, and 99.9%: AWS Organizations landing zone, Transit Gateway networking, ECS Fargate, RDS Multi-AZ, and a Terraform delivery pipeline with remote state and OIDC.

AWSTerraformECS FargateRDSOrganizations

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.

ConstraintValueDrives
Enterprise SLA99.9%, contractualMulti-AZ everywhere, no single points of failure
ComplianceSOC 2 Type II within ~6 monthsAccount isolation, centralized immutable logging, continuous config monitoring
Current stateOne account, one VPC, all click-opsMulti-account org and IaC, built from zero
Blast radiusProd shares an account with staging and experimentsAccount-per-environment
Team~40 engineers, AWS-literate, no platform teamManaged services; Terraform with guardrails, not raw console access
CostSeries A — spend must stay proportional to revenueRight-sizing, Savings Plans after baseline, no speculative multi-region
TenancyMulti-tenant SaaSTenant 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 unitAccountHolds
Securitylog-archiveImmutable CloudTrail and Config history, write-once
SecurityauditRead-only cross-account access for auditors and security tooling
InfrastructurenetworkTransit Gateway, shared VPC endpoints, egress
Infrastructureshared-servicesCI/CD runners, Terraform state, container registry
WorkloadsprodProduction VPC and workload
WorkloadsstagingPre-production, identical shape to prod
SandboxsandboxExperiments, 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>
ResourceName
ECS clusteracme-app-prod-use1-cluster
ECS serviceacme-app-prod-use1-web
RDS instanceacme-app-prod-use1-pg
ALBacme-app-prod-use1-alb
S3 bucketacme-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.

An AWS Organization with three organizational units. The Security OU contains the log-archive and audit accounts. The Infrastructure OU contains the network account, which holds a Transit Gateway, and the shared-services account, which holds Terraform state and CI/CD. The Workloads OU contains the prod and staging accounts. The prod account holds a VPC at 10.1.0.0/21 spanning two Availability Zones, each with a public subnet holding an Application Load Balancer, a private subnet holding ECS Fargate tasks, and a data subnet holding an RDS Multi-AZ PostgreSQL instance. CloudFront and WAF sit in front of the load balancer. All VPCs attach to the Transit Gateway, and CloudTrail and Config flow to the log-archive account.
Account and network topology. Every VPC attaches to the Transit Gateway in the network account; audit trails flow one way into the log-archive account. Solid lines carry traffic, dotted lines are control and audit paths.
ScopeRangeNotes
Network VPC10.0.0.0/21network account, Transit Gateway and egress
Prod VPC10.1.0.0/21prod account
public-a / public-b10.1.0.0/24 · 10.1.1.0/24ALB, NAT gateways
app-a / app-b10.1.2.0/24 · 10.1.3.0/24ECS Fargate tasks
data-a / data-b10.1.4.0/24 · 10.1.5.0/24RDS, no route to the internet
Staging VPC10.2.0.0/21staging account, identical layout
Reserved10.3.0.0/2110.15.0.0/21Future 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

DecisionChoiceWhyRejected
ComputeECS FargateA containerizable monolith needs a container runtime, not an orchestration platform; Fargate removes node patching and scaling from a team with no platform functionEKS — 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
DatabaseRDS PostgreSQL, Multi-AZManaged backups, patching and synchronous standby failover meet the SLA with no custom work; the app already speaks PostgresSelf-managed on EC2 — replication, backups and failover become manual; Aurora — stronger, but a larger change than this migration needs on day one
EdgeCloudFront + WAFTLS termination, managed rule groups, and a cache that absorbs load off the originALB alone — no managed WAF rules, no edge caching, no global POPs
IngressApplication Load BalancerHealth checks and target groups drive zero-downtime ECS rolling deploysNetwork Load Balancer — layer 4 only, no path routing for a web app
SecretsSecrets Manager + ECS task rolesThe task assumes a role and reads the secret at runtime; nothing is baked into an image or an environment variableSecrets in task definitions — plaintext in the console and in state, the most common SOC 2 finding
IdentityIAM Identity Center, SSO, no IAM usersShort-lived credentials federated from the identity provider; nothing long-lived to leakPer-account IAM users — static keys that leak into laptops and CI and never expire
DetectionGuardDuty + Config, org-wideContinuous threat detection and config drift, aggregated to the audit account — a standing SOC 2 control, not a quarterly scramblePoint-in-time audits — evidence assembled by hand before each review

6. Resource inventory

ServiceSize / tierPurpose
ECS Fargate2 vCPU / 4 GB tasks, min 4 across 2 AZs, target-tracking autoscaleWeb tier
RDS PostgreSQLdb.r6g.xlarge, Multi-AZ, gp3 storageApplication database
CloudFrontStandard, WAF attachedGlobal edge, caching, TLS
Application Load BalancerCross-zoneIngress, health-checked rolling deploys
Transit GatewaySingle, in networkInter-VPC routing
S3Versioned, SSE-KMSAssets, backups, Terraform state
Secrets ManagerRotation enabledDatabase and third-party credentials
CloudTrail + ConfigOrg trail to log-archiveAudit 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 audit account, which can see everything and change nothing

8. Resilience: meeting the SLA

RequirementMechanismResult
99.9% uptimeECS tasks across 2 AZs behind a cross-zone ALB; the loss of an AZ removes capacity, not availabilityMeets target
Database failoverRDS Multi-AZ synchronous standby, automatic promotionFailover in ~60–120s, no data loss
Zero-downtime deploysALB health checks drive ECS rolling replacementNo maintenance windows
BackupAutomated RDS snapshots, point-in-time recovery, cross-region snapshot copyRecoverable from Region loss
Recovery from errorEverything is Terraform; the environment rebuilds from sourceReproducible, 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

LeverMechanismNotes
NAT gateway trafficRoute AWS-bound traffic through VPC endpoints, not the NAT gatewayThe 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
Gravitondb.r6g and ARM FargateSame workload, lower price, no code change for a container
Compute Savings Plans1-year commitment on the steady-state Fargate baselineApplied after the baseline is measured, never at design time
Right-sizingTask CPU/memory reviewed against real utilization after the first full cyclePre-sizing a workload nobody has run yet produces confident guesses
Non-prodStaging scaled down and scheduled off outside working hours; sandbox under a hard budget actionStaging at prod scale is spending for an SLA it does not have
S3 lifecycleLogs transition to infrequent-access, then Glacier, on a scheduleSOC 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.

A Terraform delivery pipeline. A pull request triggers terraform plan and a policy scan; the plan is posted to the pull request for review. On merge, GitHub Actions authenticates to AWS by OpenID Connect with no stored credentials, then applies to staging, runs tests, and applies to production behind a manual approval gate. Terraform state is stored per environment in an S3 bucket with DynamoDB state locking in the shared-services account.
Delivery pipeline. State is remote and per-environment; CI authenticates to AWS by OIDC with no stored keys; production is gated behind an approval.

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

IfThen
Traffic becomes spiky or event-drivenMove those paths to Lambda and EventBridge; keep Fargate for the steady web tier
A platform team is hiredEKS becomes viable and gives denser multi-service packing, now that someone can own the cluster
RTO must drop below ~15 minutes across a RegionActive-passive DR in a second Region with a warm standby; expect a significant cost increase
PCI scope is addedThe cardholder data environment gets its own account and network segment, isolated from the main workload
EU customers require data residencyA separate regional stack, with an SCP that denies resource creation outside the permitted Regions
Cost becomes the binding constraintAggressive 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