Start a startup's AWS architecture with a small set of managed, boring services in one region: containers on ECS with Fargate behind an Application Load Balancer, managed PostgreSQL, S3 and CloudFront, defined in Terraform from day one and split across a few AWS accounts. Scale in steps triggered by symptoms you can measure, such as database read load, queue backlog or deploy contention, not by user counts or ambition. Keep cost proportional by right-sizing before committing, avoiding the NAT Gateway and cross-AZ traps, and buying Savings Plans only for the usage floor.
Key takeaways
- A handful of day-one decisions (accounts, identity, IaC, budgets) are nearly free now and painful to retrofit. Make them first.
- ECS on Fargate, managed PostgreSQL, S3 and CloudFront carry most products a long way. Kubernetes can wait until there is a reason.
- Move to the next stage when a measurable symptom appears, not when a user count is reached.
- Networking is the hidden cost at small scale: NAT Gateways, public IPv4 addresses and cross-AZ traffic surprise more startups than compute does.
- Scale on the metric that reflects user pain (requests per target, queue backlog), not CPU by default.
- Commit to Savings Plans only for the floor of stable usage; burst capacity belongs on on-demand or Spot.
Day-one foundations
These cost almost nothing on the first day and a lot on the four hundredth. Do them before the first production deploy.
| Decision | Do it on day one | Why it is expensive later |
|---|---|---|
| Account structure | AWS Organizations with separate production, non-production and shared accounts | Moving live resources between accounts is slow and risky |
| Human access | IAM Identity Center with MFA; no IAM users; root locked with MFA and no access keys | Untangling shared credentials after a founder or contractor leaves |
| Root account email | A group alias the company controls, not a personal inbox | Recovering an account whose root email belongs to someone who left |
| Audit trail | Organization-wide CloudTrail to a locked-down log bucket | You cannot reconstruct what happened before logging was on |
| Infrastructure as code | Terraform (or CDK) with remote state from the first resource | Importing a hand-built environment into code takes days |
| Budgets and alerts | A monthly budget with forecast alerts per account | The first surprise bill arrives weeks after the cause |
| Tags | team, service, environment on everything via provider default tags | Untagged history can never be attributed |
| Region | One region close to users, chosen once | Moving data and services between regions is a migration |
If you want the full list, our startup infrastructure checklist covers the rest of the zero-to-production path.
Budgets in code, so every account gets one:
resource "aws_budgets_budget" "monthly" {
name = "monthly-total"
budget_type = "COST"
limit_amount = var.monthly_budget_usd
limit_unit = "USD"
time_unit = "MONTHLY"
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_email_addresses = ["founders@example.com"]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = ["founders@example.com"]
}
}Apply for AWS Activate credits if you are eligible. Credits make the first year cheaper, and they also hide waste; track spend as if you were paying it.
Stage 1: the MVP stack
The goal at this stage is a production environment that a small team can deploy many times a day and nobody has to babysit.
- Compute: one ECS service on Fargate behind an Application Load Balancer. No servers to patch, no cluster to upgrade.
- Database: RDS for PostgreSQL on a small instance, single-AZ, with automated backups and point-in-time recovery. Aurora Serverless v2 is an alternative for very spiky or mostly idle workloads; it can now scale down to zero capacity and pause, at the cost of a wait on the first connection after a pause.
- Files and frontend: S3 for uploads and static assets, CloudFront in front of both the frontend and the API.
- DNS and certificates: Route 53 and ACM, so certificates renew without anyone remembering.
- Secrets: Secrets Manager or SSM Parameter Store, injected into tasks at start. Nothing in environment files committed to Git.
The whole footprint fits in one module call, which pays off the day you need a second environment:
module "app" {
source = "./modules/service"
name = "api"
environment = "production"
cpu = 256
memory = 512
desired_count = 1 # rolling deploys still work; a task crash means a short gap
cpu_architecture = "ARM64" # Graviton, if your image builds for arm64
db_engine = "postgres"
db_instance_class = "db.t4g.micro"
db_allocated_storage = 20
db_multi_az = false # until downtime costs customers
db_backup_retention = 7
}Inside the module, the task definition is plain:
resource "aws_ecs_task_definition" "api" {
family = "api"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 256
memory = 512
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.api_task.arn
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "ARM64"
}
container_definitions = jsonencode([
{
name = "api"
image = "${aws_ecr_repository.api.repository_url}:${var.image_tag}"
essential = true
portMappings = [{ containerPort = 8080 }]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = "/ecs/api"
awslogs-region = var.region
awslogs-stream-prefix = "api"
}
}
}
])
}The networking decision nobody warns you about
Fargate tasks in private subnets need a route out for image pulls, logs and third-party APIs. That route has a cost shape you should choose deliberately:
| Option | Fixed cost | Resilience | Notes |
|---|---|---|---|
| NAT Gateway in every AZ | Highest; each gateway bills hourly plus per GB processed | Best | The textbook design; overkill for many MVPs |
| One shared NAT Gateway | Lower | Outbound traffic depends on one AZ | A reasonable MVP compromise if you accept that risk |
| Tasks in public subnets with public IPs | No NAT | Good | Security groups allow inbound only from the load balancer; each public IPv4 address is billed hourly |
| VPC endpoints | Gateway endpoints for S3 and DynamoDB are free; interface endpoints bill per hour per AZ | Good | ECR pulls need the ECR API and Docker endpoints plus the S3 gateway endpoint; at tiny scale interface endpoints can cost more than the NAT traffic they remove |
Whatever you choose, add the S3 gateway endpoint. It is free and removes image layer and upload traffic from the NAT path.
Stage 2: growth
Traction brings two new problems: traffic that varies, and work that should not happen inside a web request.
Autoscale on the metric that reflects user pain. CPU is the default and usually the wrong signal for a web API: a service can sit at modest CPU while requests queue behind a slow dependency. Requests per target tracks load directly.
resource "aws_appautoscaling_target" "api" {
service_namespace = "ecs"
scalable_dimension = "ecs:service:DesiredCount"
resource_id = "service/${var.cluster}/${var.service}"
min_capacity = 2
max_capacity = 20
}
resource "aws_appautoscaling_policy" "requests_per_target" {
name = "api-requests-per-target"
service_namespace = aws_appautoscaling_target.api.service_namespace
scalable_dimension = aws_appautoscaling_target.api.scalable_dimension
resource_id = aws_appautoscaling_target.api.resource_id
policy_type = "TargetTrackingScaling"
target_tracking_scaling_policy_configuration {
target_value = 800
scale_in_cooldown = 300
scale_out_cooldown = 30
predefined_metric_specification {
predefined_metric_type = "ALBRequestCountPerTarget"
resource_label = "${var.alb_arn_suffix}/${var.tg_arn_suffix}"
}
}
}The target value comes from your own load test: find the request rate at which one task's latency starts to climb, and set the target comfortably below it. The asymmetric cooldowns are deliberate. Adding capacity late costs you an outage; removing it late costs you a little money. With a minimum of two tasks, ECS also spreads the service across two Availability Zones on a best-effort basis.
Move slow work to a queue. Emails, webhooks, image processing and report generation go on SQS and are processed by a separate worker service. Scale workers on backlog per task (visible messages divided by running tasks), so the queue drains in a time you choose. Workers are a good fit for Fargate Spot, because a message whose processing is interrupted simply becomes visible again and is retried. Fargate Spot has run ARM64 tasks since September 2024, so Graviton workers can use it too.
Take read load off the primary. When p95 latency tracks database CPU, add a cache (ElastiCache with Valkey or Redis) for hot reads and sessions, and a read replica for reporting queries. Both are cheaper than the next instance size up and add headroom instead of moving the ceiling.
Add a staging environment from the same module and a pipeline that deploys to it on every merge. Deploy credentials should come from OIDC federation, not stored keys; our guide to CI/CD with GitHub Actions covers the setup.
Turn on Multi-AZ for the database once downtime costs you customers. It keeps a synchronous standby in another zone and fails over automatically. Test a failover in staging so you know how your connection handling behaves when the endpoint moves.
Stage 3: scaling up
By now you have real traffic, a team of several squads and customers with expectations.
- Database: Aurora PostgreSQL with Multi-AZ and replicas if you need more read scaling or faster failover. Put RDS Proxy in front if serverless functions or many short-lived tasks open connections.
- Async architecture: EventBridge for domain events between services, SQS for work queues, Step Functions for multi-step workflows with retries and compensation.
- Edge: more caching at CloudFront, including API responses that can tolerate short staleness, and WAF in front of public endpoints.
- Isolation: separate accounts per environment and, as teams grow, per major domain.
- Recovery: AWS Backup copying to another region and account, with restore tests on a schedule.
- Kubernetes, maybe: EKS when you have a platform team and a specific need, such as many services with complex scheduling or a portable platform requirement. Our Kubernetes vs serverless comparison helps make that call.
Knowing when to move between stages
Stage boundaries are symptoms, not user counts. Move up when you see them, and not before:
| Signal | What it means | What to do |
|---|---|---|
| Deploys need a maintenance window | One task, no rolling capacity | Minimum of two tasks, health-checked rolling deploys |
| p95 latency tracks database CPU | Read load exceeds one instance | Cache hot reads, add a read replica |
| A web request waits for a slow job | No async path | SQS plus a worker service |
| Database connections hit the limit during scale-out | Each task opens its own pool | Smaller pools per task, RDS Proxy |
| One team's deploy breaks another's service | Shared service, separate owners | Split the service along team lines |
| Cost per user rises as users grow | Something scales worse than it should | Profile before adding capacity |
The last row matters most. Healthy infrastructure gets cheaper per user over time. If yours does not, adding capacity turns a design problem into a bigger bill.
Cost controls by stage
| Stage | Controls worth having |
|---|---|
| MVP | Budgets with forecast alerts, S3 gateway endpoint, log retention on every log group, staging scaled to zero at night |
| Growth | Cost Anomaly Detection, right-sizing from real metrics, Fargate Spot for workers and staging, Graviton where the image builds for arm64 |
| Scale | Savings Plans on the stable floor, cost allocation by team, cost estimates on infrastructure pull requests |
On commitments, the rule that saves startups most often: commit to your trough, not your peak. Unused commitment is still billed, and a one-year plan bought during a launch spike outlives the spike. Our cloud cost optimization guide covers the mechanics of Savings Plans and the order to apply optimizations in.
Find leftovers regularly. Unattached EBS volumes are the classic case; they survive the instance that created them and bill until someone deletes them:
aws ec2 describe-volumes \
--filters Name=status,Values=available \
--query 'Volumes[].{ID:VolumeId,GB:Size,Created:CreateTime}' \
--output tableWhat goes wrong in production
These are the incidents startups hit on AWS again and again, from the point of view of whoever gets paged.
Database connections run out during a traffic spike. Autoscaling adds tasks, every task opens a full connection pool, and the database refuses new connections just when you need it most. Size pools per task so that maximum tasks times pool size stays under the database's connection limit, or put RDS Proxy in between.
Burstable instances run out of credits. T-family instances earn CPU credits while idle and spend them under load. Depending on the credit mode, a sustained load either throttles to the baseline or bills for surplus credits; RDS runs T-family instances in unlimited mode. A database that lives above its baseline needs a different instance class.
Scaling stops at a quota. Fargate vCPU quotas, Elastic IP limits and API rate limits are per account and per region. Check them before a launch or a marketing push, because increases are requests, not settings.
The single NAT Gateway's zone has a problem. Every outbound call from every task fails, including calls to payment providers. If you chose one NAT Gateway to save money, know that this is the trade.
A restore that was never tested. Automated backups exist, the restore procedure does not. Restore production into a scratch instance once a quarter and time it.
Kubernetes too early. A small team spends its weeks on cluster upgrades, ingress controllers and node groups instead of the product. ECS on Fargate would have carried the same workload.
Production and experiments in one account. Someone's experiment hits a shared quota, or a cleanup script deletes the wrong resources. Account boundaries are the cheapest isolation you will ever buy.
Frequently Asked Questions
What is the best AWS architecture for an early-stage startup?
For most web products: containers on ECS with Fargate behind an Application Load Balancer, a managed PostgreSQL database, S3 for files and CloudFront in front, all defined in Terraform. Add SQS for background work when you need it. It is boring, cheap to run at low scale, and grows a long way before it needs replacing.
Should a startup use ECS or EKS?
Use ECS with Fargate unless your team already runs Kubernetes well or needs something only Kubernetes offers. ECS has no control plane to upgrade and far fewer moving parts. Moving to EKS later is a containers-to-containers migration, not a rewrite.
Should a startup use Lambda or containers?
Use Lambda for event-driven glue, scheduled jobs and spiky, short tasks, and containers for the main API and long-running workers. Many startups run both. The main API is usually simpler to develop, test and debug as a container.
How do I keep AWS costs low as a startup?
Set budgets and anomaly alerts on day one, use small instances and right-size from real metrics, and watch NAT Gateway and data transfer charges. Run staging and workers on cheaper capacity such as Fargate Spot where interruptions are acceptable. Buy Savings Plans only after usage has a stable floor, and only for that floor.
When should a startup use multiple AWS accounts?
From the start. Separate accounts for production, non-production and shared tooling under AWS Organizations cost nothing extra and give the strongest isolation AWS offers. Splitting one crowded account later means moving live resources, which is slow and risky.
When should we turn on Multi-AZ for the database?
As soon as an hour of database downtime would cost you customers or revenue, which for most startups is around the time paying users arrive. Multi-AZ keeps a synchronous standby in another zone and fails over automatically. Until then, automated backups with point-in-time recovery and a tested restore are the minimum.
Need help scaling on AWS?
We design startup architectures that stay small until they need to grow, and we run what we build. If we build it, we are on the pager for it, so we pick the boring option wherever it holds and the exotic one only where it earns its keep.