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 --versionshould return 2.15+ - Node.js 22+ or Python 3.12+
Your AI-generated service should:
- Have a single entry point (e.g.,
handler.pyorindex.js) - Run locally with
npm startorpython 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) orpackage.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.jsonhas 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-codeinstead - AccessDeniedException: Your AWS CLI user needs
lambda:CreateFunctionpermission
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-permissioncommand - 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:
- Go to your repo → Settings → Secrets → Actions
- Add
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY - Create IAM user with
lambda:UpdateFunctionCodeandlambda:PublishVersionpermissions
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>