← Back to DevBytes

Lambda: Complete Setup and Configuration Guide

What is AWS Lambda?

AWS Lambda is a serverless compute service that lets you run code without provisioning or managing servers. You upload your code, define the execution environment, and Lambda executes it on demand — scaling automatically from a few requests per day to thousands per second. You pay only for the compute time consumed, measured in milliseconds.

At its core, Lambda follows an event-driven model. Your function sits idle until triggered by an event source — an HTTP request via API Gateway, a file upload to S3, a new message in an SQS queue, a scheduled CloudWatch event, or a direct invocation via the SDK. When triggered, Lambda initializes an execution environment, runs your code, and returns the result.

Key concepts

Why Lambda Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Lambda fundamentally changes how you build and operate applications. Here's why it has become a cornerstone of modern cloud architecture:

No server management

You never patch an OS, update a runtime, or worry about a failing EC2 instance. AWS handles all infrastructure concerns — availability, scaling, and maintenance — transparently. Your operations burden shifts from managing machines to managing code.

Granular cost model

With Lambda you pay per request and per millisecond of execution. There is no idle cost. A function that runs 100ms per invocation and receives 1 million requests costs a fraction of running even the smallest EC2 instance 24/7. For sporadic workloads, the savings are dramatic.

Automatic scaling

Lambda handles scale-out transparently. If 1,000 events arrive simultaneously, Lambda provisions 1,000 execution environments concurrently — up to your account limits. When traffic subsides, capacity scales back in. There is no warm-up, no load balancer configuration, no auto-scaling group tuning.

Event-driven architecture enabler

Lambda is the glue of the AWS ecosystem. It connects services effortlessly — process S3 uploads, react to DynamoDB stream changes, handle SNS notifications, or chain Step Functions workflows. This lets you build loosely coupled, composable systems that evolve independently.

Complete Setup and Configuration Guide

Prerequisites

Step 1: Create an Execution Role

Every Lambda function needs an IAM role that defines what it can do. At minimum, the role must trust Lambda and allow writing logs to CloudWatch. Here's how to create it via the AWS CLI:

# Create the trust policy document that allows Lambda to assume the role
cat > trust-policy.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

# Create the role
aws iam create-role \
  --role-name lambda-basic-execution-role \
  --assume-role-policy-document file://trust-policy.json

# Attach the AWSLambdaBasicExecutionRole managed policy
# This grants CloudWatch Logs write permissions
aws iam attach-role-policy \
  --role-name lambda-basic-execution-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

The AWSLambdaBasicExecutionRole managed policy grants permissions to create log groups, log streams, and put log events. For production functions that access S3, DynamoDB, or other services, you'll need to add custom policies.

Step 2: Write Your Function Code

Let's create a Python function that processes an API Gateway event, fetches data from an external service, and returns a formatted response. This is a realistic handler pattern:

# lambda_function.py
import json
import os
import logging
from datetime import datetime

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

def lambda_handler(event, context):
    """
    Event: API Gateway proxy integration event
    Context: Runtime information about the execution environment
    """
    logger.info(f"Request received at {datetime.utcnow().isoformat()}")
    logger.info(f"Event: {json.dumps(event, default=str)}")
    
    # Extract request details from API Gateway event
    http_method = event.get('httpMethod', 'GET')
    path = event.get('path', '/')
    query_params = event.get('queryStringParameters', {}) or {}
    body = event.get('body', None)
    
    # Parse body if present
    parsed_body = {}
    if body:
        try:
            parsed_body = json.loads(body)
        except json.JSONDecodeError:
            parsed_body = {'raw': body}
    
    # Business logic — here we simply echo and enrich
    response_data = {
        'message': 'Hello from Lambda',
        'method': http_method,
        'path': path,
        'query_params': query_params,
        'body': parsed_body,
        'timestamp': datetime.utcnow().isoformat(),
        'request_id': context.aws_request_id,
        'function_name': context.function_name,
        'memory_limit': context.memory_limit_in_mb,
        'remaining_time_ms': context.get_remaining_time_in_millis()
    }
    
    # Return API Gateway-compatible response
    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/json',
            'X-Request-Id': context.aws_request_id
        },
        'body': json.dumps(response_data)
    }

For Node.js developers, the equivalent handler looks like this:

// index.js
const { DateTime } = require('luxon');

exports.handler = async (event, context) => {
    console.log(`Request received at ${DateTime.utc().toISO()}`);
    console.log('Event:', JSON.stringify(event, null, 2));
    
    const httpMethod = event.httpMethod || 'GET';
    const path = event.path || '/';
    const queryParams = event.queryStringParameters || {};
    let parsedBody = {};
    
    if (event.body) {
        try {
            parsedBody = JSON.parse(event.body);
        } catch (e) {
            parsedBody = { raw: event.body };
        }
    }
    
    const responseData = {
        message: 'Hello from Lambda (Node.js)',
        method: httpMethod,
        path: path,
        queryParams: queryParams,
        body: parsedBody,
        timestamp: DateTime.utc().toISO(),
        requestId: context.awsRequestId,
        functionName: context.functionName,
        memoryLimit: context.memoryLimitInMB,
        remainingTimeMs: context.getRemainingTimeInMillis()
    };
    
    return {
        statusCode: 200,
        headers: {
            'Content-Type': 'application/json',
            'X-Request-Id': context.awsRequestId
        },
        body: JSON.stringify(responseData)
    };
};

Step 3: Package the Function

Lambda accepts code as a ZIP file or a container image. For simple functions, ZIP packaging is fastest. You can include dependencies alongside your handler:

# For Python — create a deployment package with dependencies
mkdir lambda-package
cp lambda_function.py lambda-package/
cd lambda-package

# Install dependencies into the package directory
pip install requests -t .

# Create the ZIP file (from inside lambda-package/)
zip -r ../deployment.zip .
cd ..

# Verify the package contents
unzip -l deployment.zip | head -20

For Node.js, the process is similar:

# For Node.js — create deployment package
mkdir lambda-package
cp index.js lambda-package/
cd lambda-package

# Initialize and install dependencies
npm init -y
npm install luxon

# Create ZIP (include node_modules)
zip -r ../deployment.zip index.js node_modules/ package.json
cd ..

For larger projects or when you need native modules, consider using the AWS SAM CLI or CDK which handle packaging automatically.

Step 4: Create the Lambda Function

Now deploy the packaged code using the AWS CLI:

# Create the Lambda function
aws lambda create-function \
  --function-name my-api-handler \
  --runtime python3.11 \
  --role arn:aws:iam::123456789012:role/lambda-basic-execution-role \
  --handler lambda_function.lambda_handler \
  --timeout 10 \
  --memory-size 256 \
  --environment-variables "ENVIRONMENT=production,LOG_LEVEL=INFO" \
  --zip-file fileb://deployment.zip

# Expected output includes:
# - FunctionArn
# - FunctionName
# - State: Active
# - Version: $LATEST

Let's break down the configuration parameters:

Step 5: Invoke and Test

Test your function directly without any triggers to verify it works:

# Invoke synchronously with a test payload
aws lambda invoke \
  --function-name my-api-handler \
  --payload '{"httpMethod":"GET","path":"/test","queryStringParameters":{"filter":"active"},"body":null}' \
  --cli-binary-format raw-in-base64-out \
  response.json

# View the response
cat response.json
# Expected: {"statusCode":200,"headers":{"Content-Type":"application/json",...},"body":"{...}"}

# Check CloudWatch logs
aws logs describe-log-groups --log-group-name-prefix /aws/lambda/my-api-handler

# Tail recent logs
LOG_GROUP="/aws/lambda/my-api-handler"
LOG_STREAM=$(aws logs describe-log-streams \
  --log-group-name $LOG_GROUP \
  --order-by LastEventTime \
  --descending \
  --limit 1 \
  --query 'logStreams[0].logStreamName' \
  --output text)

aws logs get-log-events \
  --log-group-name $LOG_GROUP \
  --log-stream-name $LOG_STREAM \
  --limit 20

Step 6: Configure Triggers

A trigger defines what invokes your function. Here are the most common trigger configurations:

API Gateway REST API trigger

# Create a REST API
API_ID=$(aws apigateway create-rest-api \
  --name "MyAPI" \
  --endpoint-configuration types=REGIONAL \
  --query 'id' \
  --output text)

# Get the root resource ID
ROOT_ID=$(aws apigateway get-resources \
  --rest-api-id $API_ID \
  --query 'items[0].id' \
  --output text)

# Create a resource and GET method
RESOURCE_ID=$(aws apigateway create-resource \
  --rest-api-id $API_ID \
  --parent-id $ROOT_ID \
  --path-part "data" \
  --query 'id' \
  --output text)

aws apigateway put-method \
  --rest-api-id $API_ID \
  --resource-id $RESOURCE_ID \
  --http-method GET \
  --authorization-type NONE

# Set Lambda as the integration target
aws apigateway put-integration \
  --rest-api-id $API_ID \
  --resource-id $RESOURCE_ID \
  --http-method GET \
  --type AWS_PROXY \
  --integration-http-method POST \
  --uri arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:my-api-handler/invocations

# Grant API Gateway permission to invoke the Lambda
aws lambda add-permission \
  --function-name my-api-handler \
  --statement-id apigateway-invoke \
  --action lambda:InvokeFunction \
  --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:us-east-1:123456789012:$API_ID/*/*/*"

# Deploy the API
aws apigateway create-deployment \
  --rest-api-id $API_ID \
  --stage-name prod

echo "API URL: https://${API_ID}.execute-api.us-east-1.amazonaws.com/prod/data"

S3 bucket trigger

# Create a notification configuration on an S3 bucket
aws s3api put-bucket-notification-configuration \
  --bucket my-upload-bucket \
  --notification-configuration '{
    "LambdaFunctionConfigurations": [
      {
        "LambdaFunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:my-api-handler",
        "Events": ["s3:ObjectCreated:*"],
        "Filter": {
          "Key": {
            "FilterRules": [
              {"Name": "prefix", "Value": "uploads/"},
              {"Name": "suffix", "Value": ".json"}
            ]
          }
        }
      }
    ]
  }'

# Grant S3 permission to invoke the Lambda
aws lambda add-permission \
  --function-name my-api-handler \
  --statement-id s3-invoke \
  --action lambda:InvokeFunction \
  --principal s3.amazonaws.com \
  --source-arn arn:aws:s3:::my-upload-bucket

CloudWatch Events (scheduled execution)

# Create a rule that triggers every 5 minutes
aws events put-rule \
  --name "five-minute-trigger" \
  --schedule-expression "rate(5 minutes)" \
  --state ENABLED

# Add Lambda as the target
aws events put-targets \
  --rule "five-minute-trigger" \
  --targets "Id=1,Arn=arn:aws:lambda:us-east-1:123456789012:function:my-api-handler"

# Grant CloudWatch Events permission to invoke the Lambda
aws lambda add-permission \
  --function-name my-api-handler \
  --statement-id cloudwatch-invoke \
  --action lambda:InvokeFunction \
  --principal events.amazonaws.com \
  --source-arn arn:aws:events:us-east-1:123456789012:rule/five-minute-trigger

Step 7: Configure VPC Access (Optional)

By default, Lambda runs in an AWS-managed VPC with internet access. To connect to resources in your own VPC (RDS, ElastiCache, internal APIs), attach the function to your VPC subnets and security groups:

# Update function to run inside your VPC
aws lambda update-function-configuration \
  --function-name my-api-handler \
  --vpc-config '{
    "SubnetIds": ["subnet-abc12345","subnet-def67890"],
    "SecurityGroupIds": ["sg-xyz98765"]
  }'

# Important: When attached to a VPC, Lambda loses internet access
# unless you configure a NAT Gateway/Instance in your VPC
# For outbound internet, ensure your route tables include:
# 0.0.0.0/0 -> NAT Gateway (in a public subnet)

VPC-attached Lambda functions have additional considerations:

Step 8: Configure Concurrency and Provisioned Concurrency

Concurrency controls how many simultaneous executions your function can handle. Without limits, Lambda scales up to account-level quotas (typically 1,000 concurrent executions per region).

# Set reserved concurrency — caps this function and reserves slots
# This also prevents this function from starving other functions in the account
aws lambda put-function-concurrency \
  --function-name my-api-handler \
  --reserved-concurrent-executions 50

# Set provisioned concurrency — pre-warms execution environments
# Eliminates cold starts for the specified number of instances
aws lambda put-provisioned-concurrency-config \
  --function-name my-api-handler \
  --qualifier $LATEST \
  --provisioned-concurrent-executions 10

# Verify configuration
aws lambda get-provisioned-concurrency-config \
  --function-name my-api-handler \
  --qualifier $LATEST

# Expected output shows:
# - Requested: 10
# - Allocated: 10 (when fully provisioned)
# - Status: READY

Provisioned concurrency is billed differently — you pay for the pre-warmed compute regardless of usage. Use it for latency-sensitive production APIs where cold start latency (typically 200ms–2s depending on runtime and package size) is unacceptable.

Step 9: Environment Variables and Secrets

Lambda supports environment variables natively. For secrets, integrate with AWS Secrets Manager or SSM Parameter Store:

# Set environment variables directly
aws lambda update-function-configuration \
  --function-name my-api-handler \
  --environment-variables '{
    "Variables": {
      "DATABASE_HOST": "mydb.xyz.us-east-1.rds.amazonaws.com",
      "DATABASE_PORT": "5432",
      "DATABASE_NAME": "production",
      "SECRET_NAME": "prod/database/credentials",
      "REGION": "us-east-1"
    }
  }'

# In your function code, retrieve secrets at runtime:
# Python — retrieve secrets from Secrets Manager
import boto3
import os
from botocore.exceptions import ClientError

def get_secret():
    secret_name = os.environ.get('SECRET_NAME')
    region_name = os.environ.get('REGION', 'us-east-1')
    
    session = boto3.session.Session()
    client = session.client(
        service_name='secretsmanager',
        region_name=region_name
    )
    
    try:
        response = client.get_secret_value(SecretId=secret_name)
        return json.loads(response['SecretString'])
    except ClientError as e:
        logger.error(f"Failed to retrieve secret: {e}")
        raise

# Use in handler
def lambda_handler(event, context):
    db_creds = get_secret()
    # db_creds['username'], db_creds['password'], etc.
    # ... rest of handler logic

For better performance, cache secrets in memory across invocations (outside the handler). Lambda execution environments are reused for warm starts, so initialization code runs once per container:

# Python — cache secrets outside handler for reuse across invocations
import json
import os
import boto3
from botocore.exceptions import ClientError

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

# Cached variables — initialized once per execution environment
_secret_cache = None
_secret_cache_time = None
CACHE_TTL_SECONDS = 300  # Refresh every 5 minutes

def get_secret_cached():
    global _secret_cache, _secret_cache_time
    now = datetime.utcnow()
    
    if _secret_cache is not None and _secret_cache_time is not None:
        if (now - _secret_cache_time).total_seconds() < CACHE_TTL_SECONDS:
            return _secret_cache
    
    # Fetch fresh secret
    secret_name = os.environ.get('SECRET_NAME')
    region = os.environ.get('REGION', 'us-east-1')
    client = boto3.client('secretsmanager', region_name=region)
    
    try:
        response = client.get_secret_value(SecretId=secret_name)
        _secret_cache = json.loads(response['SecretString'])
        _secret_cache_time = now
        logger.info("Secret cache refreshed")
        return _secret_cache
    except ClientError as e:
        logger.error(f"Secret retrieval failed: {e}")
        # Fall back to stale cache if available
        if _secret_cache is not None:
            logger.warning("Using stale cached secret")
            return _secret_cache
        raise

def lambda_handler(event, context):
    db_creds = get_secret_cached()
    # Use db_creds['username'], db_creds['password'], etc.
    return {'statusCode': 200, 'body': 'Success'}

Step 10: Configure Logging and Monitoring

Lambda automatically sends logs to CloudWatch. To gain deeper observability, configure advanced logging and integrate with monitoring tools:

# Enable JSON structured logging in your function
# Python example with structured logging
import json
import logging
import sys
from datetime import datetime

class StructuredFormatter(logging.Formatter):
    def format(self, record):
        log_entry = {
            'timestamp': datetime.utcnow().isoformat(),
            'level': record.levelname,
            'message': record.getMessage(),
            'module': record.module,
            'function': record.funcName,
            'line': record.lineno,
            'request_id': getattr(record, 'request_id', 'unknown')
        }
        if record.exc_info:
            log_entry['exception'] = self.formatException(record.exc_info)
        return json.dumps(log_entry)

logger = logging.getLogger()
logger.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(StructuredFormatter())
logger.handlers = [handler]  # Replace default handler

# Set log retention policy (logs persist forever by default — you'll want a limit)
aws logs put-retention-policy \
  --log-group-name /aws/lambda/my-api-handler \
  --retention-in-days 14

# Enable Lambda Insights for enhanced metrics (CPU, memory, cold starts)
aws lambda update-function-configuration \
  --function-name my-api-handler \
  --tracing-config Mode=Active \
  --monitoring-config '{"MonitoringMode": "Active"}'

Advanced Configuration

Dead Letter Queues and Destinations

When asynchronous invocations fail (after exhausting retries), Lambda can send the event to a dead letter queue (DLQ) or a destination. Destinations are the newer, more capable mechanism:

# Configure a destination for successful and failed async invocations
aws lambda put-function-event-invoke-config \
  --function-name my-api-handler \
  --maximum-retry-attempts 2 \
  --destination-config '{
    "OnSuccess": {
      "Destination": "arn:aws:sqs:us-east-1:123456789012:success-queue"
    },
    "OnFailure": {
      "Destination": "arn:aws:sns:us-east-1:123456789012:failure-topic"
    }
  }'

# For older-style DLQ (SQS or SNS only for failures):
aws lambda update-function-configuration \
  --function-name my-api-handler \
  --dead-letter-config '{
    "TargetArn": "arn:aws:sqs:us-east-1:123456789012:dlq-queue"
  }'

File Systems and EFS Integration

For workloads requiring persistent shared storage or large file access, mount an EFS filesystem:

# Create an EFS filesystem and mount target in your VPC
# Then attach it to the Lambda
aws lambda update-function-configuration \
  --function-name my-api-handler \
  --file-system-configs '[
    {
      "Arn": "arn:aws:elasticfilesystem:us-east-1:123456789012:file-system/fs-abc12345",
      "LocalMountPath": "/mnt/data"
    }
  ]' \
  --vpc-config '{
    "SubnetIds": ["subnet-abc12345"],
    "SecurityGroupIds": ["sg-xyz98765"]
  }'

# In your code, read/write to /mnt/data as a normal filesystem
# Python example:
# with open('/mnt/data/processed_files.txt', 'a') as f:
#     f.write(f"{filename}\n")

Layers

Layers let you share code and dependencies across multiple functions. Extract common utilities, SDK wrappers, or large dependencies into layers:

# Create a layer ZIP with shared utilities
mkdir python
cp shared_utils.py python/
pip install requests -t python/
zip -r shared-layer.zip python/

# Publish the layer
LAYER_ARN=$(aws lambda publish-layer-version \
  --layer-name shared-utilities \
  --description "Shared utilities and requests library" \
  --compatible-runtimes python3.10 python3.11 \
  --zip-file fileb://shared-layer.zip \
  --query 'LayerVersionArn' \
  --output text)

# Attach the layer to your function
aws lambda update-function-configuration \
  --function-name my-api-handler \
  --layers "$LAYER_ARN"

# Now your function can import shared_utils directly
# from shared_utils import format_response

Environment Variable Encryption

Lambda encrypts environment variables at rest by default using an AWS-managed KMS key. For sensitive variables, use a customer-managed key:

# Create a KMS key
KMS_KEY_ID=$(aws kms create-key \
  --description "Lambda environment variable encryption key" \
  --key-usage ENCRYPT_DECRYPT \
  --customer-managed \
  --query 'KeyMetadata.KeyId' \
  --output text)

# Create an alias for easier reference
aws kms create-alias \
  --alias-name alias/lambda-env-key \
  --target-key-id $KMS_KEY_ID

# Update function to use the custom key
aws lambda update-function-configuration \
  --function-name my-api-handler \
  --kms-key-arn "arn:aws:kms:us-east-1:123456789012:key/$KMS_KEY_ID"

# Grant Lambda service permission to use the key
# (This is typically handled automatically, but verify if you encounter access issues)

Best Practices

1. Optimize for cold starts

2. Handle failures gracefully

3. Secure your functions

4. Monitor and debug effectively

5. Right-size your configuration

6. Design for idempotency

Common Configuration Patterns

Pattern 1: Web API Backend

# Function configuration for a typical REST API handler
aws lambda create-function \
  --function-name api-handler \
  --runtime python3.11 \
  --handler lambda_function.lambda_handler \
  --timeout 5 \
  --memory-size 512 \
  --reserved-concurrent-executions 100 \
  --provisioned-concurrent-executions 5 \
  --environment-variables "ENVIRONMENT=production,LOG_LEVEL=INFO" \
  --tracing-config Mode=Active \
  --role arn:aws:iam::123456789012:role/api-handler-role

Pattern 2: Event Processor (SQS/SNS triggered)

# Function configuration for asynchronous event processing
aws lambda create-function \
  --function-name event-processor \
  --runtime python3.11 \
  --handler event_processor.lambda_handler \
  --timeout 60 \
  --memory-size 1024 \
  --reserved-concurrent-executions 20 \
  --environment-variables "QUEUE_URL=https://sqs.us-east-1.amazonaws.com/123456789012/tasks" \
  --dead-letter-config '{"TargetArn":"arn:aws:sqs:us-east-1:123456789012:dlq"}' \
  --role arn:aws:iam::123456789012:role/processor-role

Pattern 3: Scheduled Job

# Function configuration for a nightly batch job
aws lambda create-function \
  --function-name nightly-report-generator \
  --runtime python3.11 \
  --handler batch.lambda_handler \
  --timeout 900 \
  --memory-size 3008 \
  --reserved-concurrent-executions 1 \
  --environment-variables "REPORT_BUCKET=reports-bucket,REGION=us-east-1" \
  --role arn:aws:iam::123456789012:role/batch-role

Local Development and Testing

While you can iterate directly with the AWS CLI, local tooling accelerates development:

# Install AWS SAM CLI for local testing
# https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html

# Initialize a SAM project
sam init --runtime python3.11 --name my-lambda-app

# Local invoke with a test event
sam local invoke MyFunction --event events/test

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles