DEV Community

Nauman Tanwir
Nauman Tanwir

Posted on

Deploying FastAPI to AWS: Part 3 - Going Serverless with Lambda

This is Part 3 of our FastAPI deployment series. We've covered EC2 and ECS Fargate - now let's explore the serverless approach.


After deploying my FastAPI journal API using EC2 and ECS Fargate, I started working on some side projects. The problem? Even the smallest ECS setup was costing me $30-50/month per project, and most of my side projects get maybe 100 requests per day.

That's when I discovered Lambda could run the same FastAPI application for literally $1-2 per month. Here's how serverless changed my approach to deploying personal projects.

When Serverless Makes Sense

Lambda isn't always the answer, but it's perfect when:

  • Variable traffic: Some days 10 requests, some days 1000
  • Cost is a concern: You want to pay only for what you use
  • Minimal maintenance: You don't want to manage any infrastructure
  • Quick experiments: You want to deploy and test ideas fast

For my journal API side project, Lambda was perfect because I'm the only user most days, but occasionally I share it with friends for feedback.

The Serverless Architecture

Here's what we're building:

Internet β†’ API Gateway β†’ Lambda Functions β†’ RDS Proxy β†’ RDS PostgreSQL
                                    ↓
                            CloudWatch Logs (automatic)
Enter fullscreen mode Exit fullscreen mode

This setup gives us:

  • Pay-per-request pricing
  • Automatic scaling (0 to thousands of requests)
  • No server management
  • Built-in monitoring

Step 1: Adapting FastAPI for Lambda

Lambda doesn't run web servers like uvicorn. Instead, it processes events. We need an adapter to make FastAPI work with Lambda events.

Install Mangum

pip install mangum
Enter fullscreen mode Exit fullscreen mode

Modify Your FastAPI App

Create a new file lambda_handler.py:

from mangum import Mangum
from app.main import app

# Disable lifespan events for Lambda
app.router.lifespan_context = None

# Create the Lambda handler
handler = Mangum(app, lifespan="off")
Enter fullscreen mode Exit fullscreen mode

Update Your FastAPI App

Make some Lambda-friendly changes to your main app:

# In app/main.py
from fastapi import FastAPI
from contextlib import asynccontextmanager
import os

# Check if running in Lambda
IS_LAMBDA = os.environ.get('AWS_LAMBDA_FUNCTION_NAME') is not None

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    if not IS_LAMBDA:
        # Only run startup tasks when not in Lambda
        print("Starting up...")
    yield
    # Shutdown
    if not IS_LAMBDA:
        print("Shutting down...")

# Create app with conditional lifespan
if IS_LAMBDA:
    app = FastAPI(title="Journal API")
else:
    app = FastAPI(title="Journal API", lifespan=lifespan)

# Your existing routes...
Enter fullscreen mode Exit fullscreen mode

Handle Database Connections

Lambda functions are stateless, so we need to handle database connections carefully:

# In your database connection file
import os
from sqlalchemy import create_engine
from sqlalchemy.pool import NullPool

def get_database_url():
    if os.environ.get('AWS_LAMBDA_FUNCTION_NAME'):
        # Use RDS Proxy for Lambda
        return f"postgresql://{user}:{password}@{rds_proxy_endpoint}:5432/{database}"
    else:
        # Direct connection for local development
        return f"postgresql://{user}:{password}@{rds_endpoint}:5432/{database}"

# Create engine with appropriate pooling
if os.environ.get('AWS_LAMBDA_FUNCTION_NAME'):
    # No connection pooling for Lambda
    engine = create_engine(get_database_url(), poolclass=NullPool)
else:
    # Normal pooling for non-Lambda environments
    engine = create_engine(get_database_url())
Enter fullscreen mode Exit fullscreen mode

Step 2: Set Up RDS Proxy

Lambda functions can overwhelm your database with connections. RDS Proxy solves this by managing connection pooling.

Create RDS Proxy

# First, create a secret for database credentials
aws secretsmanager create-secret \
    --name journal-db-credentials \
    --description "Database credentials for journal API" \
    --secret-string '{"username":"postgres","password":"YourSecurePassword123"}'

# Create RDS Proxy
aws rds create-db-proxy \
    --db-proxy-name journal-db-proxy \
    --engine-family POSTGRESQL \
    --auth '{
        "AuthScheme": "SECRETS",
        "SecretArn": "arn:aws:secretsmanager:us-east-1:account:secret:journal-db-credentials",
        "IAMAuth": "DISABLED"
    }' \
    --role-arn arn:aws:iam::account:role/RDSProxyRole \
    --vpc-subnet-ids subnet-xxxxxxxxx subnet-yyyyyyyyy \
    --vpc-security-group-ids sg-xxxxxxxxx

# Register RDS instance with proxy
aws rds register-db-proxy-targets \
    --db-proxy-name journal-db-proxy \
    --db-instance-identifiers journal-db
Enter fullscreen mode Exit fullscreen mode

Step 3: Package and Deploy with SAM

AWS SAM makes Lambda deployment much easier. Create a template.yaml:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Timeout: 30
    MemorySize: 512
    Runtime: python3.11

Parameters:
  DatabaseHost:
    Type: String
    Description: RDS Proxy endpoint

Resources:
  JournalApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: .
      Handler: lambda_handler.handler
      Environment:
        Variables:
          POSTGRES_HOST: !Ref DatabaseHost
          POSTGRES_PORT: 5432
          POSTGRES_DB: journal_db
      Events:
        ApiGateway:
          Type: Api
          Properties:
            Path: /{proxy+}
            Method: ANY
        ApiGatewayRoot:
          Type: Api
          Properties:
            Path: /
            Method: ANY
      VpcConfig:
        SecurityGroupIds:
          - sg-xxxxxxxxx
        SubnetIds:
          - subnet-xxxxxxxxx
          - subnet-yyyyyyyyy

  # IAM role for Lambda
  JournalApiRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
      Policies:
        - PolicyName: SecretsManagerAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - secretsmanager:GetSecretValue
                Resource: 
                  - arn:aws:secretsmanager:*:*:secret:journal-db-credentials*

Outputs:
  ApiGatewayUrl:
    Description: "API Gateway endpoint URL"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
Enter fullscreen mode Exit fullscreen mode

Deploy with SAM

# Build the application
sam build

# Deploy
sam deploy --guided
Enter fullscreen mode Exit fullscreen mode

The guided deployment will ask for:

  • Stack name: journal-api-lambda
  • AWS Region: us-east-1
  • Database host: Your RDS Proxy endpoint
  • Confirm changes before deploy: Y

Step 4: Handle Database Migrations

Lambda functions are ephemeral, so we need a different approach for migrations. Create a separate Lambda function:

# migration_handler.py
import subprocess
import os

def handler(event, context):
    """Run Alembic migrations in Lambda"""
    try:
        # Set database URL from environment
        os.environ['DATABASE_URL'] = f"postgresql://{os.environ['POSTGRES_USER']}:{os.environ['POSTGRES_PASSWORD']}@{os.environ['POSTGRES_HOST']}:5432/{os.environ['POSTGRES_DB']}"

        # Run migrations
        result = subprocess.run(['alembic', 'upgrade', 'head'], 
                              capture_output=True, text=True)

        if result.returncode == 0:
            return {
                'statusCode': 200,
                'body': f'Migrations completed successfully: {result.stdout}'
            }
        else:
            return {
                'statusCode': 500,
                'body': f'Migration failed: {result.stderr}'
            }
    except Exception as e:
        return {
            'statusCode': 500,
            'body': f'Error running migrations: {str(e)}'
        }
Enter fullscreen mode Exit fullscreen mode

Add this to your SAM template:

  MigrationFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: .
      Handler: migration_handler.handler
      Timeout: 300
      Environment:
        Variables:
          POSTGRES_HOST: !Ref DatabaseHost
          POSTGRES_PORT: 5432
          POSTGRES_DB: journal_db
Enter fullscreen mode Exit fullscreen mode

Step 5: Testing and Monitoring

Test Your Lambda Function

# Get your API Gateway URL from SAM output
curl https://your-api-id.execute-api.us-east-1.amazonaws.com/Prod/health

# Test authentication
curl -X POST https://your-api-id.execute-api.us-east-1.amazonaws.com/Prod/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "testpass123"}'
Enter fullscreen mode Exit fullscreen mode

Monitor Performance

Lambda automatically sends metrics to CloudWatch:

# View function metrics
aws logs describe-log-groups --log-group-name-prefix /aws/lambda/journal-api

# View recent logs
aws logs tail /aws/lambda/journal-api-JournalApiFunction-XXXXXXXXX --follow
Enter fullscreen mode Exit fullscreen mode

Step 6: Optimize for Production

Handle Cold Starts

Cold starts can add 1-3 seconds to the first request. Here are some optimizations:

# In lambda_handler.py
import json
from mangum import Mangum
from app.main import app

# Initialize outside the handler for reuse
handler = Mangum(app, lifespan="off")

# Warm-up function
def lambda_handler(event, context):
    # Handle ALB health checks
    if event.get('httpMethod') == 'GET' and event.get('path') == '/health':
        return {
            'statusCode': 200,
            'body': json.dumps({'status': 'healthy'}),
            'headers': {'Content-Type': 'application/json'}
        }

    # Handle normal requests
    return handler(event, context)
Enter fullscreen mode Exit fullscreen mode

Use Provisioned Concurrency

For critical endpoints, you can eliminate cold starts:

# Add to your SAM template
  JournalApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      # ... other properties
      ProvisionedConcurrencyConfig:
        ProvisionedConcurrencyLevel: 2
Enter fullscreen mode Exit fullscreen mode

Optimize Package Size

Keep your deployment package small for faster cold starts:

# Create requirements-lambda.txt with only production dependencies
pip install --target ./package -r requirements-lambda.txt
Enter fullscreen mode Exit fullscreen mode

Real-World Performance and Costs

Performance Comparison

Metric EC2 ECS Fargate Lambda
Cold Start N/A 30-60s 1-3s
Warm Response 50-100ms 50-100ms 50-100ms
Scaling Time Manual 1-2 minutes Instant
Max Concurrent Instance limit Task limit 1000+

Cost Comparison (for my side project)

Monthly usage: ~10,000 requests, mostly during weekdays

  • EC2 (t3.micro): $8.50 + RDS = ~$25/month
  • ECS Fargate: $15-30 + RDS = ~$35-50/month
  • Lambda: $0.20 + API Gateway $0.35 + RDS = ~$15/month

For low-traffic applications, Lambda is significantly cheaper.

When NOT to Use Lambda

Lambda isn't perfect for everything:

Avoid Lambda When:

  • Long-running processes: 15-minute execution limit
  • Large file processing: Memory and storage limitations
  • Consistent high traffic: Costs can exceed container solutions
  • Complex state management: Stateless nature can be limiting
  • Real-time applications: Cold starts affect user experience

Stick with ECS/EC2 When:

  • You need persistent connections (WebSockets)
  • Processing large files or datasets
  • Running background jobs longer than 15 minutes
  • Consistent traffic patterns make containers more cost-effective

Lessons Learned

The Good

  • Incredible cost savings for variable workloads
  • Zero infrastructure management
  • Automatic scaling handles any traffic spike
  • Built-in monitoring and logging
  • Fast deployment and iteration

The Challenges

  • Cold starts can surprise users
  • 15-minute timeout limits some use cases
  • VPC configuration can be tricky
  • Database connection management requires RDS Proxy
  • Debugging is different from traditional applications

Series Wrap-Up: Which Approach Should You Choose?

After deploying the same FastAPI application three different ways, here's my recommendation framework:

Choose EC2 When:

  • πŸŽ“ Learning cloud fundamentals
  • πŸ”§ Need specific OS configurations
  • πŸ’° Predictable, steady traffic patterns
  • 🏒 Legacy application requirements

Choose ECS Fargate When:

  • πŸš€ Building production applications
  • πŸ“ˆ Need automatic scaling and high availability
  • πŸ‘₯ Working with a team on modern DevOps practices
  • πŸ›‘οΈ Require enterprise-grade reliability

Choose Lambda When:

  • πŸ’Έ Cost optimization is critical
  • ⚑ Building MVPs or side projects
  • πŸ“Š Traffic is highly variable or unpredictable
  • 🎯 Want zero infrastructure management

My Current Setup Strategy

For new projects, I now follow this pattern:

  1. Start with Lambda for MVPs and experiments
  2. Move to ECS Fargate when traffic becomes consistent
  3. Consider EC2 only for special requirements

This approach minimizes costs during development and scales appropriately as projects grow.


What's your experience with serverless FastAPI applications? Have you tried Lambda, or are you using other serverless platforms? Share your thoughts and questions in the comments!

If this series helped you, please share it with other developers who might be facing similar deployment decisions.


This concludes our 3-part series on deploying FastAPI to AWS. Check out the other parts:

fastapi #aws #lambda #serverless #python #webdev #devops #tutorial #beginners

Top comments (0)