Remember that time you wanted to build an API but the thought of provisioning servers, configuring nginx, setting up SSL certificates, and managing scaling made you want to take a nap instead?
Yeah, me too.
AWS Lambda fixes all that. You write code, deploy it, and AWS handles literally everything else. No servers. No scaling configuration. No infrastructure management. Just pure, beautiful business logic.
Today we're building a real serverless API from scratch - the kind you'd actually use in production. We'll cover authentication, secrets management, environment variables, error handling, and deployment. By the end, you'll have a working API that scales automatically and costs almost nothing to run.
Think of Lambda as hiring a matatu conductor who only gets paid when passengers actually board. No passengers? No cost. Full matatu? They handle it effortlessly. That's Lambda - you pay per request, and it scales automatically from zero to millions of requests.
What We're Building
A simple user management API with these endpoints:
POST /users- Create new userGET /users/{id}- Get user by IDPUT /users/{id}- Update userDELETE /users/{id}- Delete user
We'll use:
- AWS Lambda for compute
- API Gateway for HTTP routing
- DynamoDB for storage
- Secrets Manager for database credentials
- CloudWatch for logging
- Node.js 20.x runtime
Prerequisites
Before we start, make sure you have:
- AWS account (free tier is enough)
- AWS CLI installed and configured
- Node.js 20+ installed locally
- Basic understanding of JavaScript/Node.js
- Coffee (this is important)
Step 1: Setting Up the Project
Create a new directory and initialize the project:
mkdir serverless-user-api
cd serverless-user-api
npm init -y
Install dependencies:
npm install aws-sdk uuid
npm install --save-dev aws-sam-cli
Create the project structure:
mkdir src
touch src/index.js
touch template.yaml
touch .env.example
Step 2: Writing the Lambda Handler
Lambda functions need a handler - a function that AWS calls when your Lambda is invoked. Here's our complete user API handler:
// src/index.js
const AWS = require('aws-sdk');
const { v4: uuidv4 } = require('uuid');
// Initialize AWS services
const dynamodb = new AWS.DynamoDB.DocumentClient();
const secretsManager = new AWS.SecretsManager();
// Environment variables
const USERS_TABLE = process.env.USERS_TABLE;
const SECRET_NAME = process.env.SECRET_NAME;
// Cache for database credentials
let dbCredentials = null;
/**
* Retrieve database credentials from Secrets Manager
* Uses caching to avoid hitting Secrets Manager on every request
*/
async function getDbCredentials() {
if (dbCredentials) {
return dbCredentials;
}
try {
const data = await secretsManager.getSecretValue({ SecretId: SECRET_NAME }).promise();
dbCredentials = JSON.parse(data.SecretString);
return dbCredentials;
} catch (error) {
console.error('Error retrieving secret:', error);
throw new Error('Failed to retrieve database credentials');
}
}
/**
* Create standardized API response
*/
function createResponse(statusCode, body) {
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*', // Configure this properly in production
'Access-Control-Allow-Credentials': true,
},
body: JSON.stringify(body),
};
}
/**
* Create new user
*/
async function createUser(data) {
const userId = uuidv4();
const timestamp = new Date().toISOString();
const user = {
id: userId,
...data,
createdAt: timestamp,
updatedAt: timestamp,
};
await dynamodb.put({
TableName: USERS_TABLE,
Item: user,
}).promise();
return user;
}
/**
* Get user by ID
*/
async function getUser(userId) {
const result = await dynamodb.get({
TableName: USERS_TABLE,
Key: { id: userId },
}).promise();
if (!result.Item) {
throw new Error('User not found');
}
return result.Item;
}
/**
* Update user
*/
async function updateUser(userId, updates) {
const timestamp = new Date().toISOString();
// Build update expression dynamically
const updateExpressions = [];
const expressionAttributeNames = {};
const expressionAttributeValues = {};
Object.keys(updates).forEach((key, index) => {
const placeholder = `#attr${index}`;
const valuePlaceholder = `:val${index}`;
updateExpressions.push(`${placeholder} = ${valuePlaceholder}`);
expressionAttributeNames[placeholder] = key;
expressionAttributeValues[valuePlaceholder] = updates[key];
});
// Add updated timestamp
updateExpressions.push('#updatedAt = :updatedAt');
expressionAttributeNames['#updatedAt'] = 'updatedAt';
expressionAttributeValues[':updatedAt'] = timestamp;
const result = await dynamodb.update({
TableName: USERS_TABLE,
Key: { id: userId },
UpdateExpression: `SET ${updateExpressions.join(', ')}`,
ExpressionAttributeNames: expressionAttributeNames,
ExpressionAttributeValues: expressionAttributeValues,
ReturnValues: 'ALL_NEW',
}).promise();
return result.Attributes;
}
/**
* Delete user
*/
async function deleteUser(userId) {
await dynamodb.delete({
TableName: USERS_TABLE,
Key: { id: userId },
}).promise();
return { message: 'User deleted successfully' };
}
/**
* Main Lambda handler
*/
exports.handler = async (event) => {
console.log('Event:', JSON.stringify(event, null, 2));
try {
// Ensure we have database credentials (from Secrets Manager)
await getDbCredentials();
const { httpMethod, pathParameters, body } = event;
const userId = pathParameters?.id;
switch (httpMethod) {
case 'POST':
// Create user
if (!body) {
return createResponse(400, { error: 'Request body is required' });
}
const userData = JSON.parse(body);
const newUser = await createUser(userData);
return createResponse(201, newUser);
case 'GET':
// Get user
if (!userId) {
return createResponse(400, { error: 'User ID is required' });
}
const user = await getUser(userId);
return createResponse(200, user);
case 'PUT':
// Update user
if (!userId) {
return createResponse(400, { error: 'User ID is required' });
}
if (!body) {
return createResponse(400, { error: 'Request body is required' });
}
const updates = JSON.parse(body);
const updatedUser = await updateUser(userId, updates);
return createResponse(200, updatedUser);
case 'DELETE':
// Delete user
if (!userId) {
return createResponse(400, { error: 'User ID is required' });
}
const deleteResult = await deleteUser(userId);
return createResponse(200, deleteResult);
default:
return createResponse(405, { error: 'Method not allowed' });
}
} catch (error) {
console.error('Error:', error);
// Handle specific errors
if (error.message === 'User not found') {
return createResponse(404, { error: error.message });
}
// Generic error response
return createResponse(500, { error: 'Internal server error', details: error.message });
}
};
Step 3: AWS SAM Template
AWS SAM (Serverless Application Model) makes deploying Lambda functions easier. Create template.yaml:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless User Management API
Globals:
Function:
Timeout: 10
Runtime: nodejs20.x
Environment:
Variables:
USERS_TABLE: !Ref UsersTable
SECRET_NAME: !Ref DatabaseCredentialsSecret
Resources:
# Lambda Function
UserApiFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: index.handler
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref UsersTable
- AWSSecretsManagerGetSecretValuePolicy:
SecretArn: !Ref DatabaseCredentialsSecret
Events:
CreateUser:
Type: Api
Properties:
Path: /users
Method: POST
GetUser:
Type: Api
Properties:
Path: /users/{id}
Method: GET
UpdateUser:
Type: Api
Properties:
Path: /users/{id}
Method: PUT
DeleteUser:
Type: Api
Properties:
Path: /users/{id}
Method: DELETE
# DynamoDB Table
UsersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: !Sub '${AWS::StackName}-users'
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
BillingMode: PAY_PER_REQUEST
# Secrets Manager Secret
DatabaseCredentialsSecret:
Type: AWS::SecretsManager::Secret
Properties:
Name: !Sub '${AWS::StackName}-db-credentials'
Description: Database credentials for user API
SecretString: !Sub |
{
"username": "admin",
"password": "change-me-in-production",
"database": "users_db"
}
Outputs:
ApiUrl:
Description: API Gateway endpoint URL
Value: !Sub 'https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod'
UsersFunctionArn:
Description: Lambda Function ARN
Value: !GetAtt UserApiFunction.Arn
UsersTableName:
Description: DynamoDB Table Name
Value: !Ref UsersTable
Step 4: Deploy to AWS
First, build the SAM application:
sam build
Then deploy (first time requires guided deployment):
sam deploy --guided
Answer the prompts:
- Stack Name:
user-api-dev - AWS Region:
us-east-1(or your preferred region) - Confirm changes:
Y - Allow SAM CLI IAM role creation:
Y - Save arguments to config:
Y
SAM will create:
- Lambda function
- API Gateway
- DynamoDB table
- Secrets Manager secret
- IAM roles and policies
After deployment completes, you'll see the API URL in the outputs.
Step 5: Testing the API
Create a user:
curl -X POST https://your-api-url.amazonaws.com/Prod/users \
-H "Content-Type: application/json" \
-d '{
"name": "John Kamau",
"email": "john@example.com",
"role": "developer"
}'
Get user by ID:
curl https://your-api-url.amazonaws.com/Prod/users/USER_ID
Update user:
curl -X PUT https://your-api-url.amazonaws.com/Prod/users/USER_ID \
-H "Content-Type: application/json" \
-d '{
"name": "John Kamau Updated",
"role": "senior-developer"
}'
Delete user:
curl -X DELETE https://your-api-url.amazonaws.com/Prod/users/USER_ID
Best Practices We Implemented
1. Secrets Management: We use AWS Secrets Manager for sensitive data instead of hardcoding credentials or using plain environment variables.
2. Caching: Database credentials are cached in memory to avoid hitting Secrets Manager on every request (saves money and reduces latency).
3. Proper Error Handling: Different error types return appropriate HTTP status codes (404 for not found, 400 for bad requests, 500 for server errors).
4. Structured Logging: CloudWatch logs include the full event for debugging.
5. Environment Variables: Configuration is externalized using environment variables defined in the SAM template.
6. CORS Headers: API responses include CORS headers for browser-based clients.
7. Idempotent Operations: Using UUID for user IDs ensures operations can be safely retried.
Common Gotchas
Cold Starts: First request after inactivity can take 1-2 seconds as Lambda initializes. Solution: Use provisioned concurrency for production APIs with strict latency requirements, or implement warming strategies.
Payload Size Limits: Lambda request/response payloads are limited to 6MB. Don't try to return huge datasets directly - use pagination or store large files in S3.
Timeout Configuration: Default timeout is 3 seconds. Our function uses 10 seconds (configured in SAM template). Adjust based on your needs, but remember Lambda max timeout is 15 minutes.
IAM Permissions: Lambda functions need explicit IAM permissions for every AWS service they access. SAM handles this with policy templates like DynamoDBCrudPolicy.
Environment Variable Limits: 4KB total size limit for all environment variables combined. For larger configs, use Parameter Store or Secrets Manager.
Monitoring and Debugging
Check CloudWatch Logs:
sam logs -n UserApiFunction --stack-name user-api-dev --tail
View metrics in CloudWatch:
- Invocation count
- Duration
- Error rate
- Throttles
Set up alarms for production:
- Error rate > 1%
- Duration > 5 seconds
- Throttles > 0
Cost Breakdown
With Lambda's pricing (as of 2025):
Free tier (monthly):
- 1 million requests
- 400,000 GB-seconds compute
After free tier:
- $0.20 per 1M requests
- $0.0000166667 per GB-second
Example: 100,000 API calls/month
- Our function uses 512MB RAM, averages 200ms execution
- Requests: 100K × $0.20 / 1M = $0.02
- Compute: 100K × 0.2s × 0.5GB × $0.0000166667 = $0.17
- Total: ~$0.19/month for 100K requests
Compare that to running a $5-20/month server 24/7 even when idle.
Next Steps
Add Authentication: Implement API Gateway Lambda authorizers or use AWS Cognito for user authentication.
Add Validation: Use a library like Joi or Yup to validate request payloads before processing.
Implement Pagination: For listing users, add pagination using DynamoDB's scan/query pagination.
Add Testing: Write unit tests using Jest and integration tests using SAM local.
Set Up CI/CD: Use GitHub Actions or AWS CodePipeline to automate deployments.
Enable X-Ray: Add AWS X-Ray for distributed tracing and performance analysis.
The Bottom Line
You just built a production-grade serverless API that:
- Scales automatically from zero to millions of requests
- Costs almost nothing at low traffic
- Requires zero server management
- Uses industry best practices for secrets management
- Has proper error handling and logging
This is the power of serverless. You wrote business logic, and AWS handled everything else - scaling, availability, infrastructure, operating system updates, security patches.
The matatu conductor model works. Pay only for actual passengers. No idle costs. Infinite scale.
Now go build something amazing with Lambda.
Sources: