DevOps

Serverless Applications with AWS Lambda & API Gateway

Serverless Applications with AWS Lambda & API Gateway

Introduction

Building and managing servers manually is increasingly becoming unnecessary overhead for modern development teams. Developers today want to focus on writing code and delivering features—not on provisioning, patching, scaling, or managing infrastructure. That’s exactly what serverless computing offers. With AWS Lambda and API Gateway, you can build and deploy full-featured backends that scale automatically from zero to millions of requests and only charge you for actual usage.

In this comprehensive guide, you’ll learn how serverless architecture works, how Lambda and API Gateway integrate to create scalable APIs, deployment strategies using the Serverless Framework and AWS SAM, and production best practices for building resilient serverless applications.

What Is Serverless Computing?

Serverless doesn’t mean there are no servers—it means you don’t have to manage them. AWS handles provisioning, scaling, patching, and maintenance automatically. You deploy small, focused functions that run when triggered by events.

Benefits of Serverless Architecture

  • No server management: AWS handles all infrastructure operations.
  • Automatic scaling: Functions scale from zero to thousands concurrently.
  • Pay-per-use pricing: Charged only when functions execute (millisecond billing).
  • Fast development: Deploy code directly without container or VM management.
  • Built-in high availability: Functions run across multiple availability zones automatically.
  • Reduced operational burden: No OS updates, security patches, or capacity planning.

AWS Lambda Deep Dive

AWS Lambda executes code in response to events—API calls, file uploads, database changes, scheduled tasks, or queue messages. You write your logic as a function, and AWS runs it automatically when triggered.

Lambda Function Structure

# handler.py - Python Lambda Function
import json
import logging
import os
from typing import Any, Dict

# Configure logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def handler(event: Dict[str, Any], context) -> Dict[str, Any]:
    """
    Lambda handler function.
    
    Args:
        event: Event data from the trigger (API Gateway, S3, etc.)
        context: Runtime information (request ID, remaining time, etc.)
    
    Returns:
        Response dict with statusCode and body
    """
    logger.info(f"Request ID: {context.aws_request_id}")
    logger.info(f"Event: {json.dumps(event)}")
    
    try:
        # Parse request body for POST/PUT
        body = {}
        if event.get('body'):
            body = json.loads(event['body'])
        
        # Access query parameters
        query_params = event.get('queryStringParameters') or {}
        name = query_params.get('name', body.get('name', 'Guest'))
        
        # Access path parameters
        path_params = event.get('pathParameters') or {}
        user_id = path_params.get('userId')
        
        # Access environment variables
        stage = os.environ.get('STAGE', 'dev')
        
        response_body = {
            'message': f'Hello, {name}!',
            'stage': stage,
            'requestId': context.aws_request_id,
        }
        
        if user_id:
            response_body['userId'] = user_id
        
        return {
            'statusCode': 200,
            'headers': {
                'Content-Type': 'application/json',
                'Access-Control-Allow-Origin': '*',
                'Access-Control-Allow-Headers': 'Content-Type,Authorization',
            },
            'body': json.dumps(response_body)
        }
        
    except Exception as e:
        logger.error(f"Error: {str(e)}")
        return {
            'statusCode': 500,
            'headers': {'Content-Type': 'application/json'},
            'body': json.dumps({'error': 'Internal server error'})
        }

Node.js Lambda Function

// handler.js - Node.js Lambda Function
const AWS = require('aws-sdk');

// Initialize AWS SDK clients outside handler for connection reuse
const dynamodb = new AWS.DynamoDB.DocumentClient();
const TABLE_NAME = process.env.TABLE_NAME;

exports.handler = async (event, context) => {
  console.log('Request ID:', context.awsRequestId);
  console.log('Event:', JSON.stringify(event, null, 2));
  
  const headers = {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Headers': 'Content-Type,Authorization',
  };
  
  try {
    const httpMethod = event.httpMethod;
    const pathParameters = event.pathParameters || {};
    const body = event.body ? JSON.parse(event.body) : {};
    
    switch (httpMethod) {
      case 'GET':
        if (pathParameters.id) {
          return await getItem(pathParameters.id, headers);
        }
        return await listItems(headers);
        
      case 'POST':
        return await createItem(body, headers);
        
      case 'PUT':
        return await updateItem(pathParameters.id, body, headers);
        
      case 'DELETE':
        return await deleteItem(pathParameters.id, headers);
        
      default:
        return {
          statusCode: 405,
          headers,
          body: JSON.stringify({ error: 'Method not allowed' }),
        };
    }
  } catch (error) {
    console.error('Error:', error);
    return {
      statusCode: 500,
      headers,
      body: JSON.stringify({ error: 'Internal server error' }),
    };
  }
};

async function getItem(id, headers) {
  const result = await dynamodb.get({
    TableName: TABLE_NAME,
    Key: { id },
  }).promise();
  
  if (!result.Item) {
    return {
      statusCode: 404,
      headers,
      body: JSON.stringify({ error: 'Item not found' }),
    };
  }
  
  return {
    statusCode: 200,
    headers,
    body: JSON.stringify(result.Item),
  };
}

async function listItems(headers) {
  const result = await dynamodb.scan({
    TableName: TABLE_NAME,
    Limit: 100,
  }).promise();
  
  return {
    statusCode: 200,
    headers,
    body: JSON.stringify({ items: result.Items }),
  };
}

async function createItem(data, headers) {
  const item = {
    id: Date.now().toString(),
    ...data,
    createdAt: new Date().toISOString(),
  };
  
  await dynamodb.put({
    TableName: TABLE_NAME,
    Item: item,
  }).promise();
  
  return {
    statusCode: 201,
    headers,
    body: JSON.stringify(item),
  };
}

async function updateItem(id, data, headers) {
  const result = await dynamodb.update({
    TableName: TABLE_NAME,
    Key: { id },
    UpdateExpression: 'SET #data = :data, updatedAt = :updatedAt',
    ExpressionAttributeNames: { '#data': 'data' },
    ExpressionAttributeValues: {
      ':data': data,
      ':updatedAt': new Date().toISOString(),
    },
    ReturnValues: 'ALL_NEW',
  }).promise();
  
  return {
    statusCode: 200,
    headers,
    body: JSON.stringify(result.Attributes),
  };
}

async function deleteItem(id, headers) {
  await dynamodb.delete({
    TableName: TABLE_NAME,
    Key: { id },
  }).promise();
  
  return {
    statusCode: 204,
    headers,
    body: '',
  };
}

API Gateway Configuration

API Gateway acts as the front door to your Lambda functions. It receives HTTP requests, validates them, transforms data, and triggers your functions.

API Gateway Types

  • HTTP API: Lower latency, lower cost, simpler—ideal for most use cases.
  • REST API: Full-featured with request validation, transformations, and API keys.
  • WebSocket API: Real-time bidirectional communication.

Deploying with Serverless Framework

The Serverless Framework simplifies Lambda deployment with infrastructure-as-code.

# serverless.yml - Complete API Configuration
service: my-serverless-api

frameworkVersion: '3'

provider:
  name: aws
  runtime: nodejs18.x
  stage: ${opt:stage, 'dev'}
  region: ${opt:region, 'us-east-1'}
  
  # Environment variables available to all functions
  environment:
    TABLE_NAME: ${self:service}-${self:provider.stage}
    STAGE: ${self:provider.stage}
  
  # IAM permissions for Lambda functions
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:GetItem
            - dynamodb:PutItem
            - dynamodb:UpdateItem
            - dynamodb:DeleteItem
            - dynamodb:Scan
            - dynamodb:Query
          Resource:
            - !GetAtt ItemsTable.Arn
            - !Sub '${ItemsTable.Arn}/index/*'

  # API Gateway configuration
  httpApi:
    cors:
      allowedOrigins:
        - https://myapp.com
        - http://localhost:3000
      allowedHeaders:
        - Content-Type
        - Authorization
      allowedMethods:
        - GET
        - POST
        - PUT
        - DELETE
        - OPTIONS

functions:
  # GET /items and GET /items/{id}
  getItems:
    handler: handlers/items.get
    events:
      - httpApi:
          path: /items
          method: get
      - httpApi:
          path: /items/{id}
          method: get
    memorySize: 256
    timeout: 10
  
  # POST /items
  createItem:
    handler: handlers/items.create
    events:
      - httpApi:
          path: /items
          method: post
    memorySize: 256
    timeout: 10
  
  # PUT /items/{id}
  updateItem:
    handler: handlers/items.update
    events:
      - httpApi:
          path: /items/{id}
          method: put
    memorySize: 256
    timeout: 10
  
  # DELETE /items/{id}
  deleteItem:
    handler: handlers/items.delete
    events:
      - httpApi:
          path: /items/{id}
          method: delete
    memorySize: 256
    timeout: 10
  
  # Scheduled function (cron job)
  cleanupOldItems:
    handler: handlers/cleanup.handler
    events:
      - schedule:
          rate: rate(1 day)
          enabled: true
    memorySize: 512
    timeout: 300

resources:
  Resources:
    # DynamoDB Table
    ItemsTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:service}-${self:provider.stage}
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
          - AttributeName: userId
            AttributeType: S
          - AttributeName: createdAt
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        GlobalSecondaryIndexes:
          - IndexName: userId-createdAt-index
            KeySchema:
              - AttributeName: userId
                KeyType: HASH
              - AttributeName: createdAt
                KeyType: RANGE
            Projection:
              ProjectionType: ALL

plugins:
  - serverless-offline
  - serverless-prune-plugin

custom:
  prune:
    automatic: true
    number: 3

Deployment Commands

# Install Serverless Framework
npm install -g serverless

# Install project dependencies
npm install

# Deploy to AWS
serverless deploy --stage dev

# Deploy a single function
serverless deploy function -f getItems

# View logs
serverless logs -f getItems --tail

# Run locally for development
serverless offline

# Remove all resources
serverless remove --stage dev

AWS SAM (Serverless Application Model)

AWS SAM is AWS’s native infrastructure-as-code tool for serverless applications.

# template.yaml - AWS SAM Template
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless API with SAM

Globals:
  Function:
    Runtime: python3.11
    Timeout: 10
    MemorySize: 256
    Environment:
      Variables:
        TABLE_NAME: !Ref ItemsTable
        LOG_LEVEL: INFO

Parameters:
  Stage:
    Type: String
    Default: dev
    AllowedValues:
      - dev
      - staging
      - prod

Resources:
  # API Gateway
  ApiGateway:
    Type: AWS::Serverless::HttpApi
    Properties:
      StageName: !Ref Stage
      CorsConfiguration:
        AllowOrigins:
          - '*'
        AllowHeaders:
          - Content-Type
          - Authorization
        AllowMethods:
          - GET
          - POST
          - PUT
          - DELETE

  # Lambda Functions
  GetItemsFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub '${AWS::StackName}-get-items'
      Handler: app.get_items
      CodeUri: src/
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref ItemsTable
      Events:
        GetAll:
          Type: HttpApi
          Properties:
            ApiId: !Ref ApiGateway
            Path: /items
            Method: GET
        GetOne:
          Type: HttpApi
          Properties:
            ApiId: !Ref ApiGateway
            Path: /items/{id}
            Method: GET

  CreateItemFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub '${AWS::StackName}-create-item'
      Handler: app.create_item
      CodeUri: src/
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ItemsTable
      Events:
        Create:
          Type: HttpApi
          Properties:
            ApiId: !Ref ApiGateway
            Path: /items
            Method: POST

  # DynamoDB Table
  ItemsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub '${AWS::StackName}-items'
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH

Outputs:
  ApiEndpoint:
    Description: API Gateway endpoint URL
    Value: !Sub 'https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${Stage}'
  
  TableName:
    Description: DynamoDB table name
    Value: !Ref ItemsTable
# SAM CLI commands

# Build the application
sam build

# Run locally
sam local start-api

# Invoke single function locally
sam local invoke GetItemsFunction --event events/get.json

# Deploy with guided prompts
sam deploy --guided

# Deploy to specific environment
sam deploy --stack-name my-api-prod --parameter-overrides Stage=prod

# View logs
sam logs -n GetItemsFunction --tail

Handling Cold Starts

Cold starts occur when Lambda creates a new execution environment. Optimize them with these techniques:

# Optimized Python Lambda with connection reuse
import json
import os
import boto3
from functools import lru_cache

# Initialize clients OUTSIDE the handler for reuse across invocations
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])

@lru_cache(maxsize=1)
def get_config():
    """Cache configuration to avoid repeated calls."""
    # Expensive initialization done once
    return {
        'feature_flags': fetch_feature_flags(),
        'secrets': fetch_secrets(),
    }

def handler(event, context):
    # Handler code uses pre-initialized resources
    config = get_config()
    
    item_id = event['pathParameters']['id']
    response = table.get_item(Key={'id': item_id})
    
    return {
        'statusCode': 200,
        'body': json.dumps(response.get('Item', {}))
    }
# Provisioned Concurrency for latency-sensitive functions
functions:
  criticalApi:
    handler: handlers/critical.handler
    provisionedConcurrency: 5  # Keep 5 instances warm
    events:
      - httpApi:
          path: /critical
          method: get

API Authentication

# JWT Authorizer with Cognito
provider:
  httpApi:
    authorizers:
      cognitoAuthorizer:
        type: jwt
        identitySource: $request.header.Authorization
        issuerUrl: !Sub 'https://cognito-idp.${self:provider.region}.amazonaws.com/${CognitoUserPool}'
        audience:
          - !Ref CognitoUserPoolClient

functions:
  protectedEndpoint:
    handler: handlers/protected.handler
    events:
      - httpApi:
          path: /protected
          method: get
          authorizer:
            name: cognitoAuthorizer
# Custom Lambda Authorizer
import json
import jwt
import os

def authorizer(event, context):
    token = event.get('authorizationToken', '').replace('Bearer ', '')
    
    try:
        # Verify JWT token
        decoded = jwt.decode(
            token,
            os.environ['JWT_SECRET'],
            algorithms=['HS256']
        )
        
        return generate_policy(
            decoded['sub'],
            'Allow',
            event['methodArn'],
            context={'userId': decoded['sub'], 'email': decoded.get('email')}
        )
        
    except jwt.ExpiredSignatureError:
        raise Exception('Unauthorized')  # Returns 401
    except jwt.InvalidTokenError:
        raise Exception('Unauthorized')

def generate_policy(principal_id, effect, resource, context=None):
    return {
        'principalId': principal_id,
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [{
                'Action': 'execute-api:Invoke',
                'Effect': effect,
                'Resource': resource
            }]
        },
        'context': context or {}
    }

Common Mistakes to Avoid

1. Creating Connections Inside Handler

# WRONG - New connection on every invocation
def handler(event, context):
    dynamodb = boto3.resource('dynamodb')  # Slow!
    table = dynamodb.Table('my-table')
    return table.get_item(Key={'id': '123'})

# CORRECT - Reuse connections across invocations
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])

def handler(event, context):
    return table.get_item(Key={'id': '123'})

2. Not Setting Proper Timeouts

# WRONG - Default 6 second timeout may be too short
functions:
  slowOperation:
    handler: handlers/slow.handler
    # No timeout specified - defaults to 6s

# CORRECT - Set appropriate timeout based on workload
functions:
  slowOperation:
    handler: handlers/slow.handler
    timeout: 30  # 30 seconds for data processing
    memorySize: 1024  # More memory = more CPU

3. Over-Provisioning Memory

# WRONG - Using max memory for simple functions
functions:
  simpleApi:
    handler: handlers/simple.handler
    memorySize: 3008  # Expensive and unnecessary

# CORRECT - Right-size based on actual usage
# Use AWS Lambda Power Tuning to find optimal configuration
functions:
  simpleApi:
    handler: handlers/simple.handler
    memorySize: 256  # Monitor and adjust based on CloudWatch metrics

Monitoring and Observability

# Structured logging for CloudWatch Logs Insights
import json
import logging
import os

class JsonFormatter(logging.Formatter):
    def format(self, record):
        log_entry = {
            'timestamp': self.formatTime(record),
            'level': record.levelname,
            'message': record.getMessage(),
            'function': os.environ.get('AWS_LAMBDA_FUNCTION_NAME'),
            'request_id': getattr(record, 'request_id', None),
        }
        if record.exc_info:
            log_entry['exception'] = self.formatException(record.exc_info)
        return json.dumps(log_entry)

logger = logging.getLogger()
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    # Add request ID to all log entries
    extra = {'request_id': context.aws_request_id}
    logger.info('Processing request', extra=extra)
    
    # Your logic here
    
    logger.info('Request completed', extra=extra)

Best Practices Summary

  • Initialize outside handler: Reuse database connections and SDK clients.
  • Right-size memory: Use Lambda Power Tuning to find optimal configuration.
  • Set appropriate timeouts: Match timeout to expected execution time plus buffer.
  • Use environment variables: Keep configuration separate from code.
  • Implement structured logging: Enable CloudWatch Logs Insights queries.
  • Handle errors gracefully: Return proper HTTP status codes and error messages.
  • Use Provisioned Concurrency: For latency-sensitive production workloads.
  • Enable X-Ray tracing: Understand end-to-end request flow.

Final Thoughts

AWS Lambda and API Gateway make building serverless applications straightforward and cost-effective. You can deploy production-ready APIs without maintaining servers—reducing operational burden while gaining automatic scaling and high availability. The Serverless Framework and AWS SAM simplify deployment and infrastructure management, enabling rapid iteration.

To continue your cloud journey, check out Kubernetes 101: Deploying and Managing Containerised Apps and Secrets Management: Comparing Vault, AWS KMS and Other Tools. For official resources, see the AWS Lambda documentation and Serverless Framework documentation.

1 Comment

Leave a Comment