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 user
  • GET /users/{id} - Get user by ID
  • PUT /users/{id} - Update user
  • DELETE /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: