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)
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
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")
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...
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())
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
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/"
Deploy with SAM
# Build the application
sam build
# Deploy
sam deploy --guided
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)}'
}
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
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"}'
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
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)
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
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
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:
- Start with Lambda for MVPs and experiments
- Move to ECS Fargate when traffic becomes consistent
- 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:
Top comments (0)