Every few months someone asks me a version of the same question: "Should I just build my SaaS on AWS, or is that overkill for a small team?" The honest answer is: it depends less on AWS itself and more on the specific decisions you make on top of it — service selection, data modeling, cost controls, and where you deliberately choose not to over-engineer.
Here's what I'd actually tell an engineering team building a SaaS product on AWS today, with the specifics that usually get skipped in the "10 AWS services for your startup" listicles.
The Compute Layer: Pick Based on Execution Shape, Not Hype
The Lambda-vs-containers debate isn't really about serverless philosophy — it's about execution shape:
- AWS Lambda fits short-lived, stateless, bursty work: API handlers, webhook processors, scheduled jobs. Watch two numbers: cold start latency (still meaningfully worse for Lambdas attached to a VPC, due to ENI creation — mitigated but not eliminated by Hyperplane ENIs) and reserved/provisioned concurrency cost if you need consistent p99 latency under load.
- ECS on Fargate fits long-running processes, WebSocket servers, or anything that benefits from a warm in-memory cache across requests. You trade Lambda's per-invocation billing for a flat per-task cost, which is often cheaper once request volume is high and steady.
- API Gateway (HTTP API) vs. Lambda Function URLs vs. ALB — HTTP APIs are cheaper than REST APIs and support JWT authorizers natively; Lambda Function URLs are the leanest option for a single-purpose endpoint with no need for API Gateway's request transformation or usage plans; ALB makes sense once you're fronting ECS/Fargate anyway.
Don't pick the architecture that sounds more "cloud-native." Pick the one that matches how your traffic actually behaves.
Multi-Tenancy at the Data Layer: Design the Keys Before You Design the Schema
This is the decision that's genuinely expensive to reverse. If you're on DynamoDB, single-table design with tenant-scoped keys is the pattern that scales without turning into an operational nightmare:
PK: TENANT#<tenantId>
SK: USER#<userId> // user profile item
SK: INVOICE#<invoiceId> // invoice item
SK: SETTINGS#billing // config item
GSI1PK: TENANT#<tenantId>#STATUS#<status>
GSI1SK: CREATED#<isoTimestamp>
Every access pattern is a query against a partition that's already scoped to a tenant — there's no cross-tenant table scan possible by construction, which is a much stronger isolation guarantee than "remember to add WHERE tenant_id = ? to every query." If your access patterns are relational (ad-hoc joins, reporting queries you can't fully enumerate up front), don't force this onto DynamoDB — use RDS Postgres with row-level security policies scoped by tenant instead, and put RDS Proxy in front of it once you're running enough Lambda concurrency to exhaust connection limits.
For customers who demand stronger isolation (and enterprise security reviews will ask), the escalation path is: shared table → tenant-scoped partition (above) → dedicated table → dedicated AWS account via AWS Organizations. Build the data layer so that escalation doesn't require a rewrite.
Cost Engineering, Not Cost Guessing
"Serverless" doesn't mean cheap — it means cost scales linearly (or worse) with usage, and a few specific line items catch teams off guard every time:
- NAT Gateways — around $0.045/hour plus $0.045/GB processed. A Lambda in a VPC that needs internet access (to call an external API) routes through a NAT Gateway unless you use VPC endpoints for AWS services. This alone has turned $50 months into $500 months for teams that didn't audit it.
- DynamoDB on-demand vs. provisioned — on-demand is safer while traffic is unpredictable, but provisioned capacity with auto-scaling is meaningfully cheaper once you understand your read/write patterns. Model this with real numbers before defaulting to on-demand forever.
- Lambda cost = (invocations × price per request) + (GB-seconds × price per GB-second) — memory allocation directly affects both cost and CPU allocation (Lambda allocates CPU proportionally to memory). Under-provisioning memory to save money often backfires because the function runs slower, which increases GB-seconds anyway. Profile it; don't guess it.
- Data transfer between AZs, between regions, and out to the internet — invisible in architecture diagrams, very visible on the bill.
Tag every resource by service, environment, and feature from day one using AWS resource tags plus AWS Cost Explorer / Cost and Usage Reports. Set budget alarms via AWS Budgets before you need them, not after.
Observability and Security as Code, Not as an Afterthought
A few concrete practices that pay for themselves fast:
- Least-privilege IAM, scoped per-function, not per-account. A Lambda that only writes to one DynamoDB table should have a policy that says exactly that:
not{ "Effect": "Allow", "Action": ["dynamodb:PutItem", "dynamodb:GetItem"], "Resource": "arn:aws:dynamodb:eu-west-1:123456789012:table/Tenants" }dynamodb:*onResource: "*". - Structured logging with embedded metrics — emit CloudWatch Embedded Metric Format (EMF) from your Lambdas so you get queryable, dashboardable metrics without a separate metrics pipeline.
- Distributed tracing with AWS X-Ray from day one, even at low traffic — retrofitting tracing into a system with five interacting services is much harder than instrumenting as you build.
- Dead-letter queues (DLQs) on every async Lambda trigger — SQS, EventBridge, and SNS-triggered Lambdas all need a documented failure path, or failed events vanish silently. This is one of the most common production incidents I see: "we lost some webhook events and found out three weeks later."
Common Technical Pitfalls
- Lambda + VPC + no VPC endpoints — every AWS API call routes through a NAT Gateway unnecessarily. Add VPC endpoints for S3, DynamoDB, and Secrets Manager and the NAT bill drops immediately.
- Hot partitions in DynamoDB — a poorly chosen partition key (e.g., a single "GLOBAL" key for a counter) throttles under load regardless of how much provisioned throughput you buy. Design keys for even distribution from the start.
- No idempotency keys on write APIs — retries (from clients, from API Gateway, from your own SDK) will double-write without one. Use a client-supplied idempotency key stored with a short TTL in DynamoDB to dedupe.
- Treating "serverless" as "no ops" — someone still owns cold start behavior, concurrency limits (default account-level Lambda concurrency is shared across all functions in a region — a noisy function can starve the others), and timeout tuning.
The Honest Takeaway
AWS is a strong foundation for a SaaS business, not because of any single service, but because it lets a small, focused team operate infrastructure that used to require a dedicated ops org. The teams that get the most out of it aren't running the most services — they've made a handful of deliberate calls early on tenant isolation, key design, and cost visibility, and instrumented everything well enough that the third production incident isn't a surprise.
Use exactly as much infrastructure as the business needs today, instrumented well enough that "today" doesn't quietly turn into a five-figure invoice or a data isolation bug three months from now.
Comments
Post a Comment