Deploy Your First AI Microservice on AWS in 45 Minutes

Go from AI-generated code to production AWS deployment with Lambda, API Gateway, and automated CI/CD using Claude and GitHub Actions.

Problem: AI Generated Code Without Deployment Strategy

You used Claude or ChatGPT to build a microservice, but it's sitting in your local repo with no clear path to production. AWS feels overwhelming with 200+ services, and you need it live today.

You'll learn:

  • Deploy a Python/Node.js microservice to AWS Lambda
  • Set up API Gateway with custom domains
  • Automate deployments with GitHub Actions
  • Monitor with CloudWatch (the parts that matter)

Time: 45 min | Level: Intermediate


Why This Happens

AI tools generate working code but skip infrastructure. Most tutorials assume you know CloudFormation, IAM roles, and VPCs. You just need a public API endpoint that scales.

Common symptoms:

  • "Works on my machine" syndrome
  • Manual deployment through AWS Console (doesn't scale)
  • No rollback strategy when things break
  • Credentials hardcoded in source code

Prerequisites

Tools you need:

  • AWS account (free tier works)
  • GitHub account
  • AWS CLI installed: aws --version should return 2.15+
  • Node.js 22+ or Python 3.12+

Your AI-generated service should:

  • Have a single entry point (e.g., handler.py or index.js)
  • Run locally with npm start or python app.py
  • Accept HTTP requests and return JSON

Cost: Free tier covers ~1M requests/month. After that, ~$0.20 per 1M requests.


Solution

Step 1: Prepare Your Code for Lambda

Lambda expects a specific handler signature. Here's how to adapt your AI-generated code.

For Python:

# handler.py
import json

def lambda_handler(event, context):
    # Your AI-generated logic goes here
    # Lambda passes HTTP request as 'event'
    
    body = json.loads(event.get('body', '{}'))
    
    # Example: sentiment analysis microservice
    result = analyze_sentiment(body.get('text', ''))
    
    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'  # Enable CORS
        },
        'body': json.dumps({
            'sentiment': result,
            'timestamp': context.request_id
        })
    }

def analyze_sentiment(text):
    # Your AI-generated code (unchanged)
    return "positive"  # Placeholder

For Node.js:

// index.js
export const handler = async (event, context) => {
    // Lambda event contains HTTP request data
    const body = JSON.parse(event.body || '{}');
    
    // Your AI-generated logic
    const result = await processRequest(body);
    
    return {
        statusCode: 200,
        headers: {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
        },
        body: JSON.stringify({
            result,
            requestId: context.requestId
        })
    };
};

async function processRequest(data) {
    // Your AI-generated code (unchanged)
    return { success: true };
}

Why this works: Lambda runtime invokes your handler function with event (HTTP request data) and context (metadata). You return a response object with statusCode, headers, and body.

Expected: Code runs locally with test event:

# Test locally (Python)
echo '{"body": "{\"text\": \"test\"}"}' | python -c "
import handler, json, sys
event = json.load(sys.stdin)
print(handler.lambda_handler(event, type('obj', (object,), {'request_id': 'local-test'})))
"

If it fails:

  • ImportError: Create requirements.txt (Python) or package.json (Node.js) with dependencies
  • JSON decode error: Wrap event body parsing in try/except

Step 2: Package Dependencies

Lambda needs dependencies bundled with your code.

Python (using Docker for exact Lambda environment):

# Create deployment package
mkdir package
pip install -r requirements.txt --target ./package --platform manylinux2014_x86_64 --only-binary=:all:

# Add your code
cp handler.py package/
cd package
zip -r ../deployment.zip .
cd ..

Why Docker matters: Some Python packages (like numpy, PIL) have C extensions. Installing on macOS/Windows creates incompatible binaries. Use --platform flag or build in Docker:

docker run --rm -v $(pwd):/app -w /app public.ecr.aws/lambda/python:3.12 \
    pip install -r requirements.txt -t package/

Node.js:

# Install production dependencies only
npm install --production

# Create deployment package
zip -r deployment.zip . -x "*.git*" "node_modules/aws-sdk/*"

Expected: deployment.zip under 50MB (uncompressed). Lambda limit is 250MB uncompressed.

If it fails:

  • File too large: Use Lambda Layers for large dependencies (see Step 6)
  • Missing dependencies: Check package.json has all imports listed

Step 3: Deploy to AWS Lambda (CLI Method)

Create IAM role, Lambda function, and initial deployment.

# Create execution role (Lambda needs permission to write logs)
aws iam create-role \
    --role-name lambda-microservice-role \
    --assume-role-policy-document '{
        "Version": "2012-10-17",
        "Statement": [{
            "Effect": "Allow",
            "Principal": {"Service": "lambda.amazonaws.com"},
            "Action": "sts:AssumeRole"
        }]
    }'

# Attach basic execution policy (CloudWatch Logs access)
aws iam attach-role-policy \
    --role-name lambda-microservice-role \
    --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

# Wait 10 seconds for IAM propagation
sleep 10

# Create Lambda function
aws lambda create-function \
    --function-name ai-microservice \
    --runtime python3.12 \
    --role arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):role/lambda-microservice-role \
    --handler handler.lambda_handler \
    --zip-file fileb://deployment.zip \
    --timeout 30 \
    --memory-size 512

For Node.js: Change --runtime nodejs22.x and --handler index.handler

Why 512MB memory: AWS allocates CPU proportional to memory. 512MB = ~1 vCPU share, good balance for most APIs.

Expected output:

{
    "FunctionName": "ai-microservice",
    "FunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:ai-microservice",
    "Runtime": "python3.12",
    "State": "Active"
}

If it fails:

  • InvalidParameterValueException: Role ARN wrong. Get it with aws iam get-role --role-name lambda-microservice-role
  • ResourceConflictException: Function exists. Use update-function-code instead
  • AccessDeniedException: Your AWS CLI user needs lambda:CreateFunction permission

Step 4: Set Up API Gateway

Lambda isn't publicly accessible. API Gateway creates an HTTP endpoint.

# Create REST API
API_ID=$(aws apigatewayv2 create-api \
    --name ai-microservice-api \
    --protocol-type HTTP \
    --target arn:aws:lambda:us-east-1:$(aws sts get-caller-identity --query Account --output text):function:ai-microservice \
    --query 'ApiId' \
    --output text)

# Grant API Gateway permission to invoke Lambda
aws lambda add-permission \
    --function-name ai-microservice \
    --statement-id apigateway-invoke \
    --action lambda:InvokeFunction \
    --principal apigateway.amazonaws.com \
    --source-arn "arn:aws:execute-api:us-east-1:$(aws sts get-caller-identity --query Account --output text):${API_ID}/*/*"

# Get API endpoint
API_ENDPOINT=$(aws apigatewayv2 get-api --api-id $API_ID --query 'ApiEndpoint' --output text)
echo "Your API is live at: $API_ENDPOINT"

Why HTTP API not REST API: HTTP APIs are cheaper ($1/million vs $3.50/million) and simpler. Use REST API only if you need API keys or request validation.

Expected: URL like https://abc123.execute-api.us-east-1.amazonaws.com


Step 5: Test Your Deployed API

# Test with curl
curl -X POST $API_ENDPOINT \
    -H "Content-Type: application/json" \
    -d '{"text": "AWS Lambda is awesome!"}'

# Expected response:
# {"sentiment": "positive", "timestamp": "a1b2c3d4-5678-90ab-cdef-1234567890ab"}

If it fails:

  • 502 Bad Gateway: Lambda crashed. Check CloudWatch Logs: aws logs tail /aws/lambda/ai-microservice --follow
  • 403 Forbidden: API Gateway permission missing. Re-run add-permission command
  • Timeout: Lambda timeout too short. Update: aws lambda update-function-configuration --function-name ai-microservice --timeout 60

Step 6: Automate with GitHub Actions

Manual uploads don't scale. Use GitHub Actions for CI/CD.

Create .github/workflows/deploy.yml:

name: Deploy to AWS Lambda

on:
  push:
    branches: [main]
  workflow_dispatch:  # Manual trigger

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Set up Python
      uses: actions/setup-python@v5
      with:
        python-version: '3.12'
    
    - name: Install dependencies
      run: |
        pip install -r requirements.txt --target ./package
        cp handler.py package/
    
    - name: Create deployment package
      run: |
        cd package
        zip -r ../deployment.zip .
        cd ..
    
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: us-east-1
    
    - name: Deploy to Lambda
      run: |
        aws lambda update-function-code \
          --function-name ai-microservice \
          --zip-file fileb://deployment.zip
    
    - name: Wait for update to complete
      run: |
        aws lambda wait function-updated \
          --function-name ai-microservice
    
    - name: Publish new version
      run: |
        VERSION=$(aws lambda publish-version \
          --function-name ai-microservice \
          --query 'Version' \
          --output text)
        echo "Deployed version: $VERSION"

Set up secrets in GitHub:

  1. Go to your repo → Settings → Secrets → Actions
  2. Add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
  3. Create IAM user with lambda:UpdateFunctionCode and lambda:PublishVersion permissions

Why publish versions: Each deployment creates a numbered version ($1, $2, etc). You can rollback by updating the alias.

Security note: Use OpenID Connect (OIDC) instead of long-lived keys in production:

# Better: OIDC (no stored credentials)
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/github-actions-role
    aws-region: us-east-1

Step 7: Add Environment Variables (Secrets)

Never hardcode API keys. Use Lambda environment variables.

# Set environment variables
aws lambda update-function-configuration \
    --function-name ai-microservice \
    --environment "Variables={
        OPENAI_API_KEY=sk-proj-...,
        DATABASE_URL=postgresql://...,
        ENV=production
    }"

Access in code:

import os

openai_key = os.environ.get('OPENAI_API_KEY')
# Falls back to None if not set

For sensitive values: Use AWS Secrets Manager + Lambda extension (auto-decrypts):

# Secrets Manager (more secure)
import boto3
import json

def get_secret(secret_name):
    client = boto3.client('secretsmanager')
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response['SecretString'])

secret = get_secret('prod/api/keys')
openai_key = secret['OPENAI_API_KEY']

Step 8: Monitor with CloudWatch

# View recent logs
aws logs tail /aws/lambda/ai-microservice --follow

# Create metric filter for errors
aws logs put-metric-filter \
    --log-group-name /aws/lambda/ai-microservice \
    --filter-name ErrorCount \
    --filter-pattern "ERROR" \
    --metric-transformations \
        metricName=Errors,metricNamespace=AIService,metricValue=1

# Set up alarm for >10 errors in 5 minutes
aws cloudwatch put-metric-alarm \
    --alarm-name ai-microservice-errors \
    --alarm-description "Alert on high error rate" \
    --metric-name Errors \
    --namespace AIService \
    --statistic Sum \
    --period 300 \
    --threshold 10 \
    --comparison-operator GreaterThanThreshold \
    --evaluation-periods 1

Set up email notifications:

# Create SNS topic
TOPIC_ARN=$(aws sns create-topic --name lambda-alerts --query 'TopicArn' --output text)

# Subscribe your email
aws sns subscribe --topic-arn $TOPIC_ARN --protocol email --notification-endpoint your@email.com
# Check email and confirm subscription

# Link alarm to SNS
aws cloudwatch put-metric-alarm \
    --alarm-name ai-microservice-errors \
    --alarm-actions $TOPIC_ARN \
    # ... rest of alarm config

Verification

Test the complete pipeline:

# 1. Make code change
echo "# Updated $(date)" >> handler.py

# 2. Commit and push
git add handler.py
git commit -m "test: verify CI/CD pipeline"
git push origin main

# 3. Watch GitHub Actions
# Go to github.com/yourname/repo/actions

# 4. Test updated endpoint
curl -X POST $API_ENDPOINT -H "Content-Type: application/json" -d '{"text": "test"}'

You should see:

  • GitHub Actions workflow completes successfully
  • New Lambda version published
  • API returns updated response
  • CloudWatch shows new request logs

Check costs:

# View Lambda invocations this month
aws cloudwatch get-metric-statistics \
    --namespace AWS/Lambda \
    --metric-name Invocations \
    --dimensions Name=FunctionName,Value=ai-microservice \
    --start-time $(date -u -d '1 month ago' +%Y-%m-%dT%H:%M:%S) \
    --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
    --period 86400 \
    --statistics Sum

What You Learned

Core concepts:

  • Lambda functions are stateless - each request gets fresh environment
  • API Gateway handles routing, Lambda handles logic
  • GitHub Actions creates reproducible deployments
  • CloudWatch Logs are essential for debugging production issues

Cost optimization:

  • Lambda free tier: 1M requests + 400K GB-seconds/month
  • After free tier: ~$0.20 per 1M requests at 512MB memory
  • Cold starts add 100-500ms latency (mitigate with Provisioned Concurrency)

When NOT to use this:

  • Requests taking >15 minutes (Lambda max timeout)
  • Need stateful connections (websockets, long-polling)
  • Consistent sub-10ms latency required (use ECS Fargate)
  • Large file uploads >6MB (use S3 presigned URLs)

Limitations:

  • API Gateway timeout: 30 seconds (even if Lambda timeout is 15min)
  • Package size: 250MB uncompressed, 50MB zipped
  • /tmp storage: 10GB (ephemeral)

Troubleshooting Common Issues

"Deployment package too large"

Use Lambda Layers for heavy dependencies:

# Create layer for dependencies
mkdir python
pip install -r requirements.txt -t python/
zip -r layer.zip python/

# Upload layer
LAYER_ARN=$(aws lambda publish-layer-version \
    --layer-name ai-deps \
    --zip-file fileb://layer.zip \
    --compatible-runtimes python3.12 \
    --query 'LayerVersionArn' \
    --output text)

# Attach to function
aws lambda update-function-configuration \
    --function-name ai-microservice \
    --layers $LAYER_ARN

"Rate limit exceeded" on free tier

Add API Gateway throttling:

# Limit to 100 requests/second per IP
aws apigatewayv2 create-stage \
    --api-id $API_ID \
    --stage-name prod \
    --throttle-settings RateLimit=100,BurstLimit=200

Cold starts causing timeouts

Provisioned concurrency keeps functions warm ($$$):

# Keep 2 instances always warm
aws lambda put-provisioned-concurrency-config \
    --function-name ai-microservice \
    --provisioned-concurrent-executions 2 \
    --qualifier $LATEST

Cost: ~$9/month per warm instance


Architecture Diagram

Here's the complete flow:

<svg viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg">
  <!-- Client -->
  <rect x="10" y="120" width="100" height="60" fill="#e3f2fd" stroke="#1976d2" stroke-width="2"/>
  <text x="60" y="150" text-anchor="middle" font-family="system-ui" font-size="14">Client App</text>
  
  <!-- Arrow to API Gateway -->
  <line x1="110" y1="150" x2="180" y2="150" stroke="#666" stroke-width="2" marker-end="url(#arrowhead)"/>
  <text x="145" y="140" font-family="system-ui" font-size="12" fill="#666">HTTPS</text>
  
  <!-- API Gateway -->
  <rect x="180" y="120" width="140" height="60" fill="#fff3e0" stroke="#f57c00" stroke-width="2"/>
  <text x="250" y="145" text-anchor="middle" font-family="system-ui" font-size="14">API Gateway</text>
  <text x="250" y="165" text-anchor="middle" font-family="system-ui" font-size="11" fill="#666">/prod/*</text>
  
  <!-- Arrow to Lambda -->
  <line x1="320" y1="150" x2="390" y2="150" stroke="#666" stroke-width="2" marker-end="url(#arrowhead)"/>
  <text x="355" y="140" font-family="system-ui" font-size="12" fill="#666">invoke</text>
  
  <!-- Lambda -->
  <rect x="390" y="120" width="120" height="60" fill="#e8f5e9" stroke="#4caf50" stroke-width="2"/>
  <text x="450" y="145" text-anchor="middle" font-family="system-ui" font-size="14">Lambda</text>
  <text x="450" y="165" text-anchor="middle" font-family="system-ui" font-size="11" fill="#666">handler()</text>
  
  <!-- Arrow to CloudWatch -->
  <line x1="450" y1="120" x2="450" y2="60" stroke="#666" stroke-width="2" marker-end="url(#arrowhead)"/>
  <text x="470" y="90" font-family="system-ui" font-size="12" fill="#666">logs</text>
  
  <!-- CloudWatch -->
  <rect x="390" y="20" width="120" height="40" fill="#fce4ec" stroke="#e91e63" stroke-width="2"/>
  <text x="450" y="45" text-anchor="middle" font-family="system-ui" font-size="14">CloudWatch</text>
  
  <!-- GitHub Actions -->
  <rect x="580" y="120" width="140" height="60" fill="#f3e5f5" stroke="#9c27b0" stroke-width="2"/>
  <text x="650" y="145" text-anchor="middle" font-family="system-ui" font-size="14">GitHub Actions</text>
  <text x="650" y="165" text-anchor="middle" font-family="system-ui" font-size="11" fill="#666">deploy.yml</text>
  
  <!-- Arrow from GitHub to Lambda -->
  <path d="M 580 150 Q 530 100 510 130" stroke="#666" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
  <text x="540" y="110" font-family="system-ui" font-size="12" fill="#666">update code</text>
  
  <!-- Arrow definitions -->
  <defs>
    <marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
      <polygon points="0 0, 10 3, 0 6" fill="#666"/>
    </marker>
  </defs>
</svg>