Home/Blog

Scaling a Startup on AWS: A Cost-Conscious Architecture

A startup AWS architecture that starts lean and scales by symptom: day-one foundations, ECS on Fargate, Terraform, NAT and database traps, cost controls.

Scaling a Startup on AWS: A Cost-Conscious Architecture

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.

DecisionDo it on day oneWhy it is expensive later
Account structureAWS Organizations with separate production, non-production and shared accountsMoving live resources between accounts is slow and risky
Human accessIAM Identity Center with MFA; no IAM users; root locked with MFA and no access keysUntangling shared credentials after a founder or contractor leaves
Root account emailA group alias the company controls, not a personal inboxRecovering an account whose root email belongs to someone who left
Audit trailOrganization-wide CloudTrail to a locked-down log bucketYou cannot reconstruct what happened before logging was on
Infrastructure as codeTerraform (or CDK) with remote state from the first resourceImporting a hand-built environment into code takes days
Budgets and alertsA monthly budget with forecast alerts per accountThe first surprise bill arrives weeks after the cause
Tagsteam, service, environment on everything via provider default tagsUntagged history can never be attributed
RegionOne region close to users, chosen onceMoving 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:

OptionFixed costResilienceNotes
NAT Gateway in every AZHighest; each gateway bills hourly plus per GB processedBestThe textbook design; overkill for many MVPs
One shared NAT GatewayLowerOutbound traffic depends on one AZA reasonable MVP compromise if you accept that risk
Tasks in public subnets with public IPsNo NATGoodSecurity groups allow inbound only from the load balancer; each public IPv4 address is billed hourly
VPC endpointsGateway endpoints for S3 and DynamoDB are free; interface endpoints bill per hour per AZGoodECR 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:

SignalWhat it meansWhat to do
Deploys need a maintenance windowOne task, no rolling capacityMinimum of two tasks, health-checked rolling deploys
p95 latency tracks database CPURead load exceeds one instanceCache hot reads, add a read replica
A web request waits for a slow jobNo async pathSQS plus a worker service
Database connections hit the limit during scale-outEach task opens its own poolSmaller pools per task, RDS Proxy
One team's deploy breaks another's serviceShared service, separate ownersSplit the service along team lines
Cost per user rises as users growSomething scales worse than it shouldProfile 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

StageControls worth having
MVPBudgets with forecast alerts, S3 gateway endpoint, log retention on every log group, staging scaled to zero at night
GrowthCost Anomaly Detection, right-sizing from real metrics, Fargate Spot for workers and staging, Graviton where the image builds for arm64
ScaleSavings 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 table

What 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.