I recently built a multi-tenant SaaS API from scratch. The product uses AI models under the hood and re-trains them regularly on new data. The requirements were pretty clear: low operational overhead, pay-per-use pricing, and it had to scale from zero to thousands of requests. No servers to manage. AWS Lambda was the natural fit.
But here's the thing. Getting a production-grade Fastify app running properly on Lambda is not as simple as the tutorials make it look. There are a lot of moving pieces you need to get right. Especially when your API needs to serve AI model predictions, handle training data pipelines, and still stay cheap when traffic is low.
So in this post, I'm going to walk you through the full architecture. We're talking Fastify on Lambda, PostgreSQL through RDS Proxy, blue-green canary deployments with CodeDeploy, and a dual auth strategy with JWT and API keys. Everything is defined in CloudFormation, and deployments run through GitHub Actions with OIDC. No long-lived AWS credentials anywhere.
All code snippets here are sanitized since the actual project is under NDA. But the patterns are real and running in production right now. Let's get into it.
Photo by Daniil Komov on Pexels
Why Fastify on Lambda
Most serverless tutorials show you one Lambda function per route. That's fine for small projects. But when you have a SaaS API with dozens of endpoints, shared middleware, authentication, and OpenAPI docs, you need a real framework.
I went with Fastify over Express. Here's why:
Performance. Fastify uses a radix-tree router and schema-based serialization. It's one of the fastest Node.js frameworks out there. On Lambda, every millisecond costs money, so framework overhead actually matters.
Plugin system. Fastify has this encapsulated plugin architecture that maps really well to SaaS backends. Database connections, auth, API docs, route groups, they all register as independent plugins with explicit dependency ordering.
Lambda support out of the box. The @fastify/aws-lambda adapter handles converting API Gateway events to HTTP requests and back. The whole app initializes once per Lambda container and gets reused on warm invocations.
Here's what the Lambda handler looks like:
import awsLambdaFastify from "@fastify/aws-lambda";
import { buildApp } from "./app.js";
let proxy;
export const handler = async (event, context) => {
if (!proxy) {
const app = await buildApp();
proxy = awsLambdaFastify(app);
}
return proxy(event, context);
};
Notice the module-level proxy variable. Lambda reuses the execution environment between invocations. So the Fastify app initializes on the first cold start and after that, subsequent invocations skip the setup entirely. This one pattern cut my average response time from ~800ms (cold) to ~15ms (warm).
Photo by Brett Sayles on Pexels
App Initialization and Plugin Ordering
The Fastify app follows a strict initialization order. Plugins declare their dependencies, and Fastify resolves the graph:
import Fastify from "fastify";
import fastifyPlugin from "fastify-plugin";
export async function buildApp() {
const app = Fastify({
logger: {
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
hostname: req.hostname,
remoteAddress: req.ip,
}),
res: (res) => ({
statusCode: res.statusCode,
}),
},
},
trustProxy: true,
requestIdHeader: "x-request-id",
});
// Plugin registration order matters
await app.register(databasePlugin); // 1. Database pool
await app.register(authPlugin); // 2. Auth (depends on DB)
await app.register(swaggerPlugin); // 3. API docs
// Route registration
const prefix = `/${process.env.NODE_ENV}`;
await app.register(healthRoutes, { prefix });
await app.register(authRoutes, { prefix });
await app.register(userRoutes, { prefix });
await app.register(apiKeyRoutes, { prefix });
// Global error handler
app.setErrorHandler((error, request, reply) => {
request.log.error(error);
reply.status(error.statusCode || 500).send({
statusCode: error.statusCode || 500,
error: error.name,
message: error.message,
timestamp: new Date().toISOString(),
});
});
return app;
}
A few things worth pointing out:
trustProxy: true is a must when you're behind API Gateway. Without it, Fastify can't resolve the client's real IP address.
requestIdHeader gives you distributed tracing. Each request carries a unique ID through CloudWatch logs.
- Plugins use
fastify-plugin to break Fastify's default encapsulation. That way decorators like fastify.pg and fastify.authenticate are available across all route scopes.
- Routes are prefixed with the environment name (
/dev, /prod) to match the API Gateway stage path.
Photo by Daniil Komov on Pexels
Infrastructure as Code with CloudFormation
Every single piece of infrastructure is defined in CloudFormation YAML templates. I split them into two stacks: foundation (networking + database) and services (Lambda + API Gateway + deployment pipeline).
VPC and Network Architecture
The VPC uses a classic three-tier design across three availability zones:
# Simplified network.yml
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: "10.42.0.0/16"
EnableDnsSupport: true
EnableDnsHostnames: true
# Three tiers of subnets across 3 AZs:
# Public Subnets - NAT Gateway lives here
# App Subnets - Lambda functions (private)
# Database Subnets - RDS instances (private)
Why does this matter for Lambda? When you attach Lambda functions to a VPC, they need subnets to run in. I put them in private "app" subnets with a route to a NAT Gateway. That way they can still reach AWS services like Secrets Manager and CloudWatch without exposing a public IP. The database subnets are even more locked down. No internet access at all. Only reachable from the app subnets through security group rules.
The security group config is deliberately tight:
DatabaseSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: "RDS access from app subnets only"
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
CidrIp: !Ref AppSubnet1CIDR
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
CidrIp: !Ref AppSubnet2CIDR
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
CidrIp: !Ref AppSubnet3CIDR
Only traffic from the app subnets on port 5432 can reach the database. Everything else is denied by default.
Photo by Brett Sayles on Pexels
Authentication: JWT + API Key Dual Strategy
My API supports two authentication methods. Both go through a single Fastify preHandler.
JWT Bearer Tokens
These are for interactive sessions. A user logs in, gets a token:
// JWT generation on login
import jwt from "jsonwebtoken";
function generateToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, name: user.name },
process.env.JWT_SECRET,
{ expiresIn: "24h" }
);
}
API Keys
These are for programmatic access. Think CI/CD pipelines or third-party integrations. Longer-lived credentials with a custom prefix:
import crypto from "crypto";
function generateApiKey() {
const raw = crypto.randomBytes(48).toString("base64url");
return `hrr_${raw}`; // ~65 chars total
}
function hashApiKey(key) {
return crypto.createHash("sha256").update(key).digest("hex");
}
The plaintext key is returned once on creation. Only the SHA-256 hash gets stored in the database. On subsequent requests, the key from the Authorization header is hashed and compared against stored hashes.
The Auth Middleware
Both methods come together in a single authentication plugin:
async function authPlugin(fastify) {
fastify.decorate("authenticate", async (request, reply) => {
const authHeader = request.headers.authorization;
if (!authHeader) {
return reply.status(401).send({ message: "Authentication required" });
}
if (authHeader.startsWith("Bearer ")) {
// JWT flow: verify token, load user, check status
const token = authHeader.slice(7);
const payload = jwt.verify(token, process.env.JWT_SECRET);
const user = await userRepo.findById(fastify.pg, payload.sub);
if (!user || user.status !== "active") {
return reply.status(401).send({ message: "Authentication failed" });
}
request.user = user;
} else if (authHeader.startsWith("ApiKey ")) {
// API key flow: hash key, lookup credential, check expiry
const key = authHeader.slice(7);
const hash = hashApiKey(key);
const credential = await credentialRepo.findActiveByHash(
fastify.pg, hash, "api_key"
);
if (!credential) {
return reply.status(401).send({ message: "Authentication failed" });
}
request.user = credential.user;
// Fire-and-forget: update last_used_at without blocking
credentialRepo.updateLastUsed(fastify.pg, credential.id).catch(() => {});
} else {
return reply.status(401).send({ message: "Authentication failed" });
}
});
}
A few design decisions I want to highlight:
- Generic 401 responses. Whether the token is expired, the user is suspended, or the key doesn't exist, the response is always "Authentication failed". This prevents user enumeration attacks.
- Fire-and-forget last_used_at. Updating the API key's last-used timestamp is useful for analytics but it shouldn't add latency to the request. The
.catch(() => {}) makes sure a failed update doesn't crash anything.
- Single credential table. Both passwords and API keys live in a
user_credentials table with a credential_type column. Keeps things simple and supports multiple credentials per user.
Photo by Brett Sayles on Pexels
Database Layer: PostgreSQL with RDS Proxy
This is where serverless gets tricky. Every Lambda cold start creates a new database connection. Under load, hundreds of Lambda instances can spin up at the same time, each opening its own connection to PostgreSQL. You'll hit the max_connections limit fast.
RDS Proxy fixes this. It sits between Lambda and RDS, keeps a warm pool of database connections, and multiplexes Lambda requests across them.
# CloudFormation: RDS Proxy configuration
RDSProxy:
Type: AWS::RDS::DBProxy
Properties:
DBProxyName: !Sub "\({ProjectName}-\){Environment}-proxy"
EngineFamily: POSTGRESQL
RequireTLS: true
IdleClientTimeout: 1800
Auth:
- AuthScheme: SECRETS
SecretArn: !Ref DatabaseSecret
IAMAuth: DISABLED
VpcSubnetIds:
- !Ref DatabaseSubnet1
- !Ref DatabaseSubnet2
- !Ref DatabaseSubnet3
RDSProxyTargetGroup:
Type: AWS::RDS::DBProxyTargetGroup
Properties:
DBProxyName: !Ref RDSProxy
TargetGroupName: default
ConnectionPoolConfigurationInfo:
MaxConnectionsPercent: 100
MaxIdleConnectionsPercent: 50
ConnectionBorrowTimeout: 120
On the app side, the database plugin creates a pg connection pool that points to the RDS Proxy endpoint:
import pg from "pg";
import { getDbCredentials } from "../config/database.js";
async function databasePlugin(fastify) {
const credentials = await getDbCredentials();
const pool = new pg.Pool({
host: process.env.DB_HOST, // RDS Proxy endpoint
port: parseInt(process.env.DB_PORT),
database: process.env.DB_NAME,
user: credentials.username,
password: credentials.password,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
ssl: { rejectUnauthorized: true },
});
// Verify connectivity on startup
const client = await pool.connect();
client.release();
fastify.decorate("pg", pool);
fastify.addHook("onClose", () => pool.end());
}
Credentials from Secrets Manager
Database credentials are auto-generated by CloudFormation and stored in AWS Secrets Manager. The Lambda function fetches them once on cold start and caches them:
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
const smClient = new SecretsManagerClient();
let cachedCredentials = null;
export async function getDbCredentials() {
if (cachedCredentials) return cachedCredentials;
const response = await smClient.send(
new GetSecretValueCommand({ SecretId: process.env.DB_SECRET_ARN })
);
cachedCredentials = JSON.parse(response.SecretString);
return cachedCredentials;
}
This is a pattern I use everywhere: fetch once, cache for the lifetime of the Lambda container. The container might live for minutes or hours, but credentials only get fetched once. It's essential for keeping cold start latency low.
Photo by panumas nikhomkhai on Pexels
CI/CD: GitHub Actions with OIDC and Blue-Green Deployments
The deployment pipeline is honestly the part I'm most proud of. Zero long-lived credentials. Automatic canary deployments. And rollback on failure.
GitHub OIDC Authentication
Storing AWS access keys in GitHub Secrets is a security anti-pattern. Instead, I set up OIDC federation. GitHub's Actions runner generates a signed JWT, and AWS exchanges it for temporary credentials:
# CloudFormation: GitHub OIDC provider
GitHubOIDCProvider:
Type: AWS::IAM::OIDCProvider
Properties:
Url: "https://token.actions.githubusercontent.com"
ClientIdList:
- "sts.amazonaws.com"
DeployRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Federated: !Ref GitHubOIDCProvider
Action: "sts:AssumeRoleWithWebIdentity"
Condition:
StringEquals:
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
StringLike:
"token.actions.githubusercontent.com:sub":
"repo:my-org/my-repo:*"
That Condition block is super important. It restricts which GitHub repos and branches can assume the deploy role. Without it, any GitHub Actions workflow could assume your AWS role. That would be bad.
The Deployment Workflow
# .github/workflows/deploy.yml (simplified)
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: dev
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-2
- run: npm ci --omit=dev --silent
# Package and upload
- name: Deploy Lambda
run: |
zip -r function.zip . -x "*.test.*"
aws lambda update-function-code \
--function-name ${{ env.FUNCTION_NAME }} \
--zip-file fileb://function.zip
aws lambda wait function-updated \
--function-name ${{ env.FUNCTION_NAME }}
# Publish immutable version
- name: Publish Version
run: |
VERSION=$(aws lambda publish-version \
--function-name ${{ env.FUNCTION_NAME }} \
--description "SHA: ${{ github.sha }}" \
--query 'Version' --output text)
echo "NEW_VERSION=\(VERSION" >> \)GITHUB_ENV
# Blue-green: update alias to new version
- name: Update Alias
run: |
aws lambda update-alias \
--function-name ${{ env.FUNCTION_NAME }} \
--name live \
--function-version ${{ env.NEW_VERSION }}
Blue-Green with CodeDeploy Canary
Now here's where it gets really cool. The CodeDeploy configuration:
DeploymentConfig: LambdaCanary10Percent5Minutes
What this does:
- 10% of traffic goes to the new version for 5 minutes
- A CloudWatch alarm watches the
Errors metric on the Lambda alias
- If 3 or more errors happen in 60 seconds, CodeDeploy automatically rolls back to the previous version
- If the alarm stays green, traffic shifts to 100% on the new version
# CloudWatch alarm for automatic rollback
DeploymentAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
MetricName: Errors
Namespace: AWS/Lambda
Dimensions:
- Name: FunctionName
Value: !Ref ApiFunction
- Name: Resource
Value: !Sub "${ApiFunction}:live"
Statistic: Sum
Period: 60
EvaluationPeriods: 1
Threshold: 3
ComparisonOperator: GreaterThanOrEqualToThreshold
I love this setup. I can push to main with confidence knowing that bad deployments get caught within minutes. And rollback is automatic.
Photo by Wolfgang Weiser on Pexels
API Documentation with OpenAPI and Swagger
If you've ever maintained a separate OpenAPI spec file by hand, you know it's always out of date. Fastify solves this nicely.
You define JSON schemas on your route handlers, and @fastify/swagger generates a complete OpenAPI 3.0 spec from them automatically. The schema IS the documentation. No drift.
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
async function swaggerPlugin(fastify) {
await fastify.register(fastifySwagger, {
openapi: {
info: {
title: "SaaS API",
version: "1.0.0",
},
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
apiKeyAuth: {
type: "apiKey",
in: "header",
name: "Authorization",
},
},
},
},
});
await fastify.register(fastifySwaggerUi, {
routePrefix: `/${process.env.NODE_ENV}/docs`,
uiConfig: {
docExpansion: "list",
deepLinking: true,
},
});
}
Then each route defines its schema inline:
app.post("/auth/login", {
schema: {
tags: ["Auth"],
body: {
type: "object",
required: ["email", "password"],
properties: {
email: { type: "string", format: "email" },
password: { type: "string", minLength: 8 },
},
},
response: {
200: {
type: "object",
properties: {
token: { type: "string" },
user: {
type: "object",
properties: {
id: { type: "integer" },
email: { type: "string" },
name: { type: "string" },
},
},
},
},
},
},
handler: loginHandler,
});
Fastify uses these schemas for three things at once: input validation (rejects malformed requests before your handler runs), response serialization (strips unexpected fields), and OpenAPI spec generation. One schema, three benefits.
The Swagger UI is served at /{environment}/docs and works in every deployed environment. Super useful for QA and integration testing.
Photo by Stanislav Kondratiev on Pexels
Lessons Learned
After running this architecture in production for a while, here are the things I'd tell myself if I was starting over.
1. Cold Starts Are Manageable
With a VPC-attached Lambda running Node.js 20, my cold starts averaged 800-1200ms. Here's where that time goes:
- ENI attachment for VPC networking (~300ms)
- Secrets Manager fetch (~200ms)
- Fastify initialization + plugin registration (~150ms)
Warm invocations consistently ran under 20ms though. For a B2B SaaS API where the first request of the day might be slow, this was totally fine. If you need faster cold starts, look into provisioned concurrency.
2. I Chose CloudFormation Over CDK
And I'd do it again. Here's why:
- Transparency. Every resource, every property is right there in the template. No abstractions hiding default security groups or IAM policies from you.
- Debugging. When a deployment fails, you just read the template. With CDK, you're reading synthesized CloudFormation that was generated by TypeScript that generated CloudFormation. It's abstractions all the way down.
- Onboarding. A new developer can read a
.yml file and understand what's deployed. CDK requires knowing both the construct library and the underlying CloudFormation.
The tradeoff is verbosity. My database.yml template is 400+ lines. But I'd rather have verbose and explicit than concise and magical.
3. RDS Proxy Is Non-Negotiable
I cannot stress this enough. Without RDS Proxy, my first load test hit the database's max_connections limit within seconds. Each Lambda container opens its own connection. Under burst traffic, hundreds of containers spin up at once.
RDS Proxy multiplexes these connections. And the ConnectionBorrowTimeout: 120 setting gives new Lambda instances time to wait for an available connection instead of failing immediately.
4. Invest in Monitoring Early
The most valuable things I added were:
- Structured JSON logging. Fastify's built-in logger with custom serializers makes logs actually searchable in CloudWatch.
- Request ID correlation. Every log line includes the request ID. Makes it trivial to trace a single request through the whole call stack.
- Health check tiers.
/live (is the process running?), /ready (can it serve traffic?), and /health/db (is the database reachable?). Each serves a different monitoring need.
5. The Cost Breakdown
For a low-to-moderate traffic SaaS API (~50K requests/day):
- Lambda: ~$3/month (512MB, avg 50ms execution)
- API Gateway: ~$5/month (HTTP API pricing)
- RDS (db.t3.micro): ~$15/month
- RDS Proxy: ~$20/month
- NAT Gateway: ~$35/month (the silent budget killer)
- Secrets Manager: ~$0.40/month
Total: ~$78/month. And it scales to 10x the traffic without any architectural changes.
NAT Gateway is by far the most expensive component relative to the actual traffic. For side projects, consider using VPC endpoints for Secrets Manager and CloudWatch to skip the NAT Gateway entirely.
Photo by Roxanne Minnish on Pexels
Why This Architecture Is Perfect for AI Products
The project I built with this architecture actually uses AI models. The system re-trains models regularly on new incoming data and serves predictions through the API. So this isn't just a theoretical "you could use it for AI" thing. I'm running it for AI workloads in production right now.
Here's why this setup works so well for AI products specifically.
Most AI startups and indie hackers have the same problem. You build something cool with an LLM or a custom model, ship an MVP, and then traffic is completely unpredictable. Some days you get 50 requests. Other days a tweet goes viral and you get 50,000. Traditional servers mean you're either overpaying for capacity you don't use or scrambling to scale when things blow up.
With this serverless setup, you pay for what you use. At $78/month for 50K requests/day, you can run your AI SaaS backend for less than what most people spend on coffee. And when your product hits the front page of Hacker News, Lambda just scales. No panic. No midnight capacity planning. It handles the spike and scales back down when traffic drops.
For my project, the API handles both real-time predictions and stores training data that feeds back into the model re-training pipeline. The PostgreSQL database through RDS Proxy stores the training data, and separate batch jobs pick it up for model updates. The API itself stays lightweight and fast because it's only doing inference and data collection, not the heavy training work.
This matters for AI products because:
- Bursty traffic patterns. AI apps see huge spikes when users share outputs on social media. Lambda handles this without breaking a sweat.
- Low upfront cost. You don't need expensive servers before you've validated your idea. Start at near-zero and grow with your users.
- Training data collection. Your API collects user interactions that improve your models over time. PostgreSQL + RDS Proxy handles this reliably.
- API-first architecture. Most AI products need a solid API for their web app, mobile app, and integrations. Fastify + Lambda gives you that with OpenAPI docs built in.
- Focus on what matters. You should be spending time on your AI models and user experience, not babysitting servers.
I've seen people vibe code entire AI SaaS products in a weekend using tools like Cursor and Claude. The backend is often the bottleneck because setting up something production-ready feels overwhelming. This architecture solves that. Clone the patterns from this post, wire up your AI endpoints, and you have a backend that handles 10 users or 100,000 users without changing any infrastructure code.
The pay-per-use model also makes it easy to experiment. Want to add a new AI feature? Deploy it. If nobody uses it, it costs you nothing. Try doing that with a $200/month dedicated server.
Photo by Tara Winstead on Pexels
Conclusion
Building a serverless SaaS on AWS Lambda with Fastify absolutely works for production. The trick is treating Lambda as a real compute platform, not just a "function-as-a-service" toy.
If I were starting over, I'd change two things:
- Use VPC endpoints instead of a NAT Gateway to cut the monthly cost by ~40%
- Add provisioned concurrency for the critical auth endpoints to kill cold start latency
Everything else? The plugin architecture, the dual auth strategy, the blue-green deployments. I'd do it all the same way.
The serverless tax is real. Cold starts, connection pooling complexity, CloudFormation verbosity. But the operational simplicity makes it worth it. No patching servers. No capacity planning. No 3 AM pages because an EC2 instance ran out of disk space. Just push to main and the pipeline handles the rest.
Hope you enjoyed this deep dive. If you have questions about any part of this architecture, feel free to ask in the comments.
Don't forget to follow me on Twitter/X, I regularly tweet about AWS, DevOps, and various other Software Engineering topics.
Photo by Christina Morillo on Pexels
On this post
Comments
A reply stays under the note it answers.
No comments yet.
If you have a note on How I Architected a Fully Serverless SaaS on AWS Lambda with Fastify, sign in and leave it.