Skip to main content
Back to Blog
AWSCloud ArchitectureServerlessS3RDSLambdaSaaS

Building a Cloud‑Native Architecture on AWS with S3, RDS, and Lambda

Learn how to design a scalable, secure, cloud-native SaaS architecture on AWS using S3 for storage, RDS for relational data, and Lambda for serverless compute — with real-world patterns, code examples, and best practices.

August 20, 202612 min readNiraj Kumar

Introduction

If you've ever tried to spin up a traditional three-tier web application — a web server, an app server, and a database — you know the drill: provision EC2 instances, patch operating systems, configure load balancers, worry about autoscaling groups, and hope nothing falls over at 2 AM. It works, but it's a lot of undifferentiated heavy lifting for something that, at its core, just needs to store files, persist structured data, and run some business logic.

This is where a cloud-native architecture built on AWS's managed services shines. By combining three foundational AWS services — Amazon S3 (object storage), Amazon RDS (managed relational databases), and AWS Lambda (serverless compute) — you can build a SaaS-style application that scales automatically, costs less to operate, and lets your team focus on product features instead of server maintenance.

In this guide, we'll walk through:

  • Why this trio of services works so well together
  • How each service fits into a typical SaaS architecture
  • A real-world example: a document-processing SaaS feature
  • Code snippets you can adapt for your own projects
  • Best practices, common mistakes, and pro tips for 2026-era AWS development

Whether you're a beginner exploring serverless AWS for the first time or an intermediate developer looking to tighten up your architecture, this post will give you a practical, end-to-end mental model.


Why Cloud-Native on AWS?

"Cloud-native" isn't just a buzzword — it describes applications designed from the ground up to take advantage of managed, elastic cloud infrastructure rather than being lifted-and-shifted from on-premises servers. Key characteristics include:

  • Managed services over self-hosted infrastructure — let AWS handle patching, replication, and failover.
  • Event-driven execution — code runs in response to events (a file upload, an API call, a schedule) rather than running continuously.
  • Elastic scaling — resources scale up and down automatically based on demand.
  • Pay-for-use pricing — you're billed for what you actually consume, not idle capacity.

For SaaS products specifically, this matters because usage patterns are often unpredictable. A new customer onboarding might trigger a burst of file uploads and database writes, followed by hours of near-zero activity. Cloud-native architecture handles this gracefully; traditional server-based architecture often doesn't, unless you over-provision (and overpay).


The Core Building Blocks

Let's break down the three services at the heart of this architecture.

Amazon S3 — Durable, Scalable Object Storage

Amazon S3 (Simple Storage Service) is where you store files: user uploads, generated reports, static assets, backups, and more. It's designed for 99.999999999% (11 nines) durability and virtually unlimited scale.

Key features relevant to SaaS architectures:

  • Buckets and prefixes act as your storage namespace (e.g., tenant-uploads/{tenant_id}/{file_id}.pdf).
  • Event notifications let S3 trigger Lambda functions, SQS queues, or SNS topics whenever an object is created, updated, or deleted.
  • Storage classes (Standard, Intelligent-Tiering, Glacier) let you optimize cost based on access frequency.
  • Presigned URLs allow secure, time-limited direct uploads/downloads from the browser without routing large files through your backend.

Amazon RDS — Managed Relational Databases

Amazon RDS provides managed relational database engines (PostgreSQL, MySQL, MariaDB, SQL Server, Oracle) without the operational burden of manual backups, patching, or replication setup.

Key features:

  • Automated backups and point-in-time recovery
  • Multi-AZ deployments for high availability
  • Read replicas to scale read-heavy workloads
  • RDS Proxy, which is essential when Lambda functions connect to RDS (more on this below)

For most SaaS applications, RDS with PostgreSQL is a strong default choice — it's mature, feature-rich (JSONB, full-text search, extensions), and well-supported across ORMs and frameworks.

AWS Lambda — Serverless Compute

AWS Lambda runs your code in response to events without you provisioning or managing servers. You upload a function (or container image), define what triggers it, and AWS handles execution, scaling, and infrastructure.

Key features:

  • Event sources: API Gateway, S3, SQS, EventBridge, DynamoDB Streams, and more
  • Automatic scaling: from zero to thousands of concurrent executions
  • Pay-per-invocation and duration billing
  • Support for multiple runtimes: Node.js, Python, Java, Go, .NET, Ruby, and custom runtimes via containers

Putting It Together: A Reference Architecture

Imagine a SaaS product where users upload documents (invoices, contracts, reports) that need to be processed, validated, and indexed. Here's how the three services collaborate:

flowchart LR
    A[User Browser] -->|Presigned URL Upload| B[(S3 Bucket)]
    B -->|ObjectCreated Event| C[Lambda: Process Document]
    C -->|Write Metadata| D[(RDS PostgreSQL via RDS Proxy)]
    C -->|Extracted Text| E[(S3 Processed Bucket)]
    F[API Gateway] --> G[Lambda: API Handlers]
    G --> D
    A -->|REST/GraphQL Calls| F

Flow explanation:

  1. The client requests a presigned S3 URL from an API Lambda function.
  2. The browser uploads the file directly to S3, bypassing your backend entirely (saving compute and bandwidth costs).
  3. S3 fires an ObjectCreated event, which triggers a processing Lambda function.
  4. The Lambda function extracts text/metadata, validates the file, and writes structured results into RDS via RDS Proxy.
  5. Application users query this data through a separate set of API Lambda functions behind API Gateway.

This pattern — direct-to-S3 uploads plus event-driven processing — is one of the most common and effective SaaS architecture patterns on AWS today.


Real-World Example: Document Upload & Processing Pipeline

Let's make this concrete with code. We'll build a simplified version of the pipeline above using the AWS SAM (Serverless Application Model) framework.

Step 1: Define Infrastructure with SAM

# template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Resources:
  UploadsBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: saas-app-uploads
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: s3:ObjectCreated:*
            Function: !GetAtt ProcessDocumentFunction.Arn

  ProcessDocumentFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: process_document.handler
      Runtime: python3.13
      MemorySize: 512
      Timeout: 30
      VpcConfig:
        SecurityGroupIds:
          - sg-0123456789abcdef0
        SubnetIds:
          - subnet-0111111111111111
          - subnet-0222222222222222
      Environment:
        Variables:
          DB_PROXY_ENDPOINT: !GetAtt RDSProxy.Endpoint
          DB_NAME: saas_app
      Policies:
        - S3ReadPolicy:
            BucketName: saas-app-uploads
        - VPCAccessPolicy: {}

  RDSProxy:
    Type: AWS::RDS::DBProxy
    Properties:
      DBProxyName: saas-app-proxy
      EngineFamily: POSTGRESQL
      RoleArn: arn:aws:iam::123456789012:role/rds-proxy-role
      Auth:
        - AuthScheme: SECRETS
          SecretArn: arn:aws:secretsmanager:us-east-1:123456789012:secret:rds-creds
      VpcSubnetIds:
        - subnet-0111111111111111
        - subnet-0222222222222222

Step 2: Write the Lambda Function

# process_document.py
import os
import json
import boto3
import psycopg2
from psycopg2.extras import execute_values

s3_client = boto3.client("s3")

def get_db_connection():
    return psycopg2.connect(
        host=os.environ["DB_PROXY_ENDPOINT"],
        dbname=os.environ["DB_NAME"],
        user=os.environ["DB_USER"],
        password=os.environ["DB_PASSWORD"],  # pulled from Secrets Manager in practice
        connect_timeout=5,
    )

def handler(event, context):
    for record in event["Records"]:
        bucket = record["s3"]["bucket"]["name"]
        key = record["s3"]["object"]["key"]

        # Fetch object metadata
        response = s3_client.head_object(Bucket=bucket, Key=key)
        size_bytes = response["ContentLength"]
        content_type = response.get("ContentType", "unknown")

        tenant_id, file_id = parse_key(key)

        # Persist metadata to RDS
        conn = get_db_connection()
        try:
            with conn.cursor() as cur:
                cur.execute(
                    """
                    INSERT INTO documents (tenant_id, file_id, s3_key, size_bytes, content_type, status)
                    VALUES (%s, %s, %s, %s, %s, %s)
                    ON CONFLICT (file_id) DO UPDATE
                    SET status = EXCLUDED.status
                    """,
                    (tenant_id, file_id, key, size_bytes, content_type, "processed"),
                )
            conn.commit()
        finally:
            conn.close()

    return {"statusCode": 200, "body": json.dumps({"message": "processed"})}

def parse_key(key: str):
    # Expected format: tenant-uploads/{tenant_id}/{file_id}.ext
    parts = key.split("/")
    tenant_id = parts[1]
    file_id = parts[2].split(".")[0]
    return tenant_id, file_id

Step 3: Database Schema

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    tenant_id UUID NOT NULL,
    file_id UUID NOT NULL UNIQUE,
    s3_key TEXT NOT NULL,
    size_bytes INTEGER NOT NULL,
    content_type TEXT,
    status TEXT DEFAULT 'pending',
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_documents_tenant_id ON documents (tenant_id);

A few things worth noticing here:

  • The Lambda function runs inside a VPC so it can reach RDS privately.
  • It connects through RDS Proxy, not directly to the database — this is essential (explained below).
  • Credentials are pulled from Secrets Manager rather than hardcoded — never store DB passwords in environment variables in plaintext for production systems.

Why RDS Proxy Matters for Lambda

This is one of the most misunderstood parts of Lambda + RDS architectures, so it deserves its own section.

Lambda functions are stateless and short-lived. Under load, AWS may spin up dozens or hundreds of concurrent execution environments, each potentially opening its own database connection. Relational databases have a hard limit on concurrent connections (often in the low hundreds), so a traffic spike can easily exhaust your RDS connection pool and cause cascading failures.

RDS Proxy solves this by sitting between your Lambda functions and RDS, pooling and multiplexing connections efficiently. Benefits include:

  • Connection pooling across many concurrent Lambda invocations
  • Faster failover during database maintenance or failure events
  • IAM authentication support, reducing the need to pass credentials around
  • Reduced database CPU overhead from connection churn

Rule of thumb: if you're connecting Lambda to RDS in a production SaaS application, use RDS Proxy. Don't connect directly unless your traffic is extremely low and predictable.


Security Best Practices

Security should be baked into the architecture from day one, not bolted on afterward.

  • Least-privilege IAM roles — each Lambda function should have a narrowly scoped execution role. A function that only reads from one S3 bucket shouldn't have s3:* permissions across your account.
  • Private subnets for RDS — your database should never be publicly accessible. Place it in private subnets with no route to an internet gateway.
  • Encrypt data at rest and in transit — enable S3 default encryption (SSE-S3 or SSE-KMS) and RDS storage encryption; enforce TLS for all connections.
  • Use Secrets Manager or Parameter Store — never hardcode database credentials or API keys in Lambda code or environment variables.
  • Presigned URLs with short expiry — when allowing direct-to-S3 uploads, set presigned URL expiration to the minimum practical window (e.g., 5–15 minutes).
  • Bucket policies and Block Public Access — enable S3 Block Public Access at the account level unless you have an explicit, reviewed reason not to.
  • Multi-tenant isolation — if you're building multi-tenant SaaS, enforce tenant isolation at the data layer (row-level security in PostgreSQL, or separate schemas/prefixes) in addition to application-layer checks.

Performance & Scalability Considerations

Cold Starts

Lambda cold starts can add latency, especially for functions inside a VPC (needed to reach RDS). In 2026, AWS has significantly improved VPC networking performance for Lambda (via Hyperplane ENIs), but it's still worth:

  • Using provisioned concurrency for latency-sensitive API paths
  • Keeping deployment package sizes small
  • Avoiding unnecessary dependencies in your Lambda runtime

Database Scaling

  • Use read replicas for reporting or analytics queries so they don't compete with transactional workloads.
  • Consider Aurora Serverless v2 (PostgreSQL/MySQL compatible) if your workload is spiky — it scales compute capacity automatically without the operational complexity of manual instance resizing.
  • Monitor connection counts and query latency with RDS Performance Insights.

S3 Performance

  • S3 automatically scales to very high request rates; you rarely need to worry about throughput limits for typical SaaS workloads.
  • Use randomized or hashed key prefixes if you expect extremely high request rates to avoid old-style partition hotspots (less of a concern today than a few years ago, but still good practice for very high-throughput systems).

Common Mistakes to Avoid

  • Connecting Lambda directly to RDS without a proxy — leads to connection exhaustion under load.
  • Overly broad IAM policies — using wildcard permissions ("Resource": "*") instead of scoping to specific buckets, tables, or secrets.
  • Ignoring cold start latency in user-facing APIs — not testing real-world latency before shipping a Lambda-backed API to production.
  • Storing large files or binary blobs in RDS — the database should hold structured metadata; the actual files belong in S3.
  • Not setting Lambda timeouts appropriately — a Lambda function stuck waiting on a slow database query can rack up costs and delay downstream processing.
  • Skipping infrastructure-as-code — manually clicking through the AWS console leads to configuration drift and un-reproducible environments. Use SAM, CDK, or Terraform.
  • Forgetting about idempotency — S3 event notifications can occasionally deliver duplicate events; your Lambda logic should handle reprocessing gracefully (as shown with the ON CONFLICT clause above).

🚀 Pro Tips

  • Use AWS CDK or SAM instead of manual console configuration — infrastructure-as-code makes your architecture reproducible, reviewable, and easy to roll back.
  • Enable S3 Event Notifications with EventBridge instead of direct Lambda triggers when you need more complex routing logic (e.g., fan-out to multiple consumers).
  • Batch small RDS writes where possible — for high-frequency Lambda invocations, consider buffering writes through SQS and processing them in batches to reduce database load.
  • Tag everything — consistent tagging (environment, service, tenant-tier) makes cost allocation and monitoring dramatically easier as your SaaS product grows.
  • Set up CloudWatch Alarms on Lambda errors, throttles, and RDS CPU/connections — don't wait for customers to report issues.
  • Use Aurora Serverless v2 for unpredictable, multi-tenant workloads — it removes the guesswork of capacity planning for variable traffic.
  • Version your Lambda functions and use aliases for safer, gradual rollouts (e.g., canary deployments with traffic shifting).

📌 Key Takeaways

  • S3, RDS, and Lambda together form a low-maintenance, highly scalable foundation for SaaS-style applications, letting teams focus on features instead of infrastructure.
  • Event-driven patterns — like S3 triggering Lambda on file upload — reduce coupling and improve system responsiveness without constant polling.
  • RDS Proxy is essential (not optional) when connecting Lambda functions to a relational database in any production workload with meaningful concurrency.
  • Security, connection management, and infrastructure-as-code are the three areas most likely to cause pain if neglected early in the architecture's lifecycle.

Conclusion

Building a cloud-native SaaS architecture on AWS doesn't require reinventing the wheel — it requires composing well-understood, battle-tested managed services in the right way. S3 gives you durable, scalable storage for files and assets. RDS gives you a reliable, familiar relational database without the operational burden of self-managing it. Lambda ties everything together with event-driven, serverless compute that scales automatically and bills you only for what you use.

The architecture pattern covered here — direct-to-S3 uploads, event-triggered processing via Lambda, and structured metadata persisted in RDS through RDS Proxy — is a proven foundation used by countless production SaaS applications today. It's not the only way to build on AWS, but it's a pragmatic, cost-effective starting point that scales gracefully as your product grows from a handful of early customers to a much larger user base.

As you build out your own architecture, keep security, connection management, and infrastructure-as-code front and center from the very beginning. These are the areas that are cheap to get right early and expensive to fix later.


References

All Articles
AWSCloud ArchitectureServerlessS3RDSLambdaSaaS

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.