---
title: "Deploy to AWS Lambda"
description: "Least-privilege IAM and CloudWatch alarms are exactly what separates 'can deploy a side project' from 'can be trusted with production AWS access' — the thing..."
source: "https://confidentprep.com/paths/ai-engineering-fundamentals/aws-lambda-deploy/"
path: "AI Engineering Fundamentals"
repository: "https://github.com/confidentprep14-web/ai-engineering-projects/tree/main/aws-lambda-deploy"
token_estimate: 4886
---

# Deploy to AWS Lambda

## Overview

Deploy the containerised chat API to AWS Lambda with least-privilege IAM, Secrets
Manager for credentials instead of hardcoded keys, and a CloudWatch error-rate alarm. AI
engineering jobs increasingly expect comfort with cloud deployment, not just model
calls — this project is your proof of that, on real (if minimal) AWS infrastructure.

## What to do

**Path:** 1  
**Position:** 11 of 12  
**Difficulty:** 🔴 Requires AWS account — Docker is already known from p1-10  
**Estimated time:** 3–4 hours  
**AWS cost:** ~$1–3 for the full session (Lambda + API Gateway). Free tier covers most if account is fresh.  

---

### Agent Pickup Instructions

**This spec is self-contained. Build this project without reading any other spec file.**

```bash
mkdir -p path-1/p1-11-lambda-deploy
cd path-1/p1-11-lambda-deploy

# Prerequisites: AWS CLI configured, Docker running, jq installed
aws sts get-caller-identity    # verify AWS credentials

# Deploy
bash scripts/deploy.sh

# Test
bash scripts/test.sh

# Measure cold start
python scripts/measure_cold_start.py

# TEARDOWN (run immediately after testing)
bash scripts/teardown.sh
```

**Done when:**
- [ ] `bash scripts/deploy.sh` completes and prints the API Gateway URL
- [ ] `curl` to the API Gateway URL returns a streaming response
- [ ] IAM role uses least-privilege (only lambda:InvokeFunction permissions, no AdministratorAccess)
- [ ] API key stored in Secrets Manager, not in Lambda environment variables as plaintext
- [ ] `python scripts/measure_cold_start.py` prints p50/p95 cold start init time
- [ ] CloudWatch alarm configured for error rate > 5% in 5 minutes
- [ ] `bash scripts/teardown.sh` deletes ALL resources (verify with `aws lambda list-functions`)
- [ ] No file in `src/` exceeds 200 lines

---

### What this project is

Takes the containerised streaming chat API from Project 10 and deploys it to AWS Lambda via a container image. The learner configures IAM with least-privilege, stores the LLM API key in Secrets Manager, measures cold start latency, and sets up a CloudWatch alarm. The teardown script deletes every resource created. This is what "deploy to production" actually means for a serverless LLM endpoint.

---

### What the learner achieves

"I deployed a containerised FastAPI app to Lambda with a least-privilege IAM role, secrets in Secrets Manager instead of plaintext env vars, and a CloudWatch alarm on error rate — and I measured cold start at p50/p95 across 10 invocations."

---

### Folder structure

```
p1-11-lambda-deploy/
├── README.md
├── GUIDE.md
├── .env.example
├── requirements.txt
├── Dockerfile            ← Lambda-compatible (FROM public.ecr.aws/lambda/python:3.12)
├── src/
│   ├── main.py           ← FastAPI app with Mangum adapter for Lambda
│   ├── llm.py
│   ├── database.py       ← adapted for /tmp (Lambda's only writable path)
│   ├── rate_limiter.py
│   └── config.py         ← reads from Secrets Manager when on Lambda
├── scripts/
│   ├── deploy.sh         ← full deploy script
│   ├── teardown.sh       ← full teardown script
│   ├── test.sh           ← integration test against live API
│   └── measure_cold_start.py ← cold start measurement
└── infra/
    ├── iam_policy.json   ← least-privilege Lambda execution policy
    └── alarm.json        ← CloudWatch alarm definition
```

---

### .env.example

```bash
# AWS region
AWS_REGION=us-east-1

# ECR repository name (will be created by deploy.sh)
ECR_REPO_NAME=chat-api

# Lambda function name
LAMBDA_FUNCTION_NAME=chat-api-lambda

# Secrets Manager secret name (stores LLM_API_KEY)
SECRET_NAME=chat-api/llm-api-key

# LLM configuration (stored in Secrets Manager, not here)
LLM_PROVIDER=anthropic
LLM_MODEL=

# SNS topic ARN for CloudWatch alarm notifications (create manually before deploy)
SNS_ALARM_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:chat-api-alerts

# Lambda memory and timeout
LAMBDA_MEMORY_MB=512
LAMBDA_TIMEOUT_SECONDS=30
```

---

### requirements.txt

```
anthropic==0.40.0
openai==1.58.0
fastapi==0.115.6
mangum==0.19.0
boto3==1.35.93
python-dotenv==1.0.1
```

Note: `mangum` wraps the FastAPI app to handle Lambda event/context format. `ollama` is not included — Lambda cannot run local models.

---

### Dockerfile (Lambda-compatible)

```dockerfile
FROM public.ecr.aws/lambda/python:3.12

WORKDIR ${LAMBDA_TASK_ROOT}

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY src/ src/

# Lambda handler — Mangum wraps the FastAPI app
CMD ["src.main.handler"]
```

---

### src/main.py changes for Lambda

The FastAPI app is identical to p1-08 with two additions:

```python
from mangum import Mangum

app = FastAPI()
# ... all routes same as p1-08 ...

# Lambda handler — Mangum adapts API Gateway events to ASGI
handler = Mangum(app, lifespan="off")
```

Also: `DATABASE_PATH` must default to `/tmp/chat_requests.db` on Lambda (the only writable path).

---

### src/config.py changes for Lambda

```python
import os
import boto3
import json

def get_llm_api_key() -> str:
    """Read LLM API key from Secrets Manager when running on Lambda, else from env."""
    secret_name = os.getenv("SECRET_NAME")
    if secret_name and os.getenv("AWS_LAMBDA_FUNCTION_NAME"):
        # Running on Lambda — read from Secrets Manager
        client = boto3.client("secretsmanager", region_name=os.getenv("AWS_REGION", "us-east-1"))
        secret = client.get_secret_value(SecretId=secret_name)
        return json.loads(secret["SecretString"])["LLM_API_KEY"]
    return os.getenv("LLM_API_KEY", "")
```

---

### infra/iam_policy.json

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/chat-api-lambda:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue"
      ],
      "Resource": "arn:aws:secretsmanager:*:*:secret:chat-api/*"
    }
  ]
}
```

This is the complete policy. No AdministratorAccess, no wildcard `*` on Action.

---

### scripts/deploy.sh

```bash
#!/bin/bash
set -e
source .env

echo "=== Deploying Chat API to Lambda ==="

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
ECR_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPO_NAME}"

# 1. Create ECR repo (skip if exists)
echo "[1/7] Creating ECR repository..."
aws ecr create-repository --repository-name "${ECR_REPO_NAME}" \
    --region "${AWS_REGION}" 2>/dev/null || echo "ECR repo already exists"

# 2. Build and push image
echo "[2/7] Building Docker image..."
docker build -t "${ECR_REPO_NAME}" .
aws ecr get-login-password --region "${AWS_REGION}" | \
    docker login --username AWS --password-stdin "${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
docker tag "${ECR_REPO_NAME}:latest" "${ECR_URI}:latest"
docker push "${ECR_URI}:latest"
echo "Pushed: ${ECR_URI}:latest"

# 3. Store secret in Secrets Manager
echo "[3/7] Storing API key in Secrets Manager..."
aws secretsmanager create-secret \
    --name "${SECRET_NAME}" \
    --secret-string "{\"LLM_API_KEY\": \"${LLM_API_KEY}\"}" \
    --region "${AWS_REGION}" 2>/dev/null || \
aws secretsmanager update-secret \
    --secret-id "${SECRET_NAME}" \
    --secret-string "{\"LLM_API_KEY\": \"${LLM_API_KEY}\"}" \
    --region "${AWS_REGION}"

# 4. Create IAM role
echo "[4/7] Creating IAM execution role..."
TRUST_POLICY='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
ROLE_ARN=$(aws iam create-role \
    --role-name "${LAMBDA_FUNCTION_NAME}-role" \
    --assume-role-policy-document "${TRUST_POLICY}" \
    --query 'Role.Arn' --output text 2>/dev/null || \
    aws iam get-role --role-name "${LAMBDA_FUNCTION_NAME}-role" --query 'Role.Arn' --output text)
aws iam put-role-policy \
    --role-name "${LAMBDA_FUNCTION_NAME}-role" \
    --policy-name "least-privilege" \
    --policy-document file://infra/iam_policy.json
sleep 10  # IAM propagation delay

# 5. Create or update Lambda function
echo "[5/7] Deploying Lambda function..."
aws lambda create-function \
    --function-name "${LAMBDA_FUNCTION_NAME}" \
    --package-type Image \
    --code ImageUri="${ECR_URI}:latest" \
    --role "${ROLE_ARN}" \
    --memory-size "${LAMBDA_MEMORY_MB}" \
    --timeout "${LAMBDA_TIMEOUT_SECONDS}" \
    --environment "Variables={LLM_PROVIDER=${LLM_PROVIDER},LLM_MODEL=${LLM_MODEL},SECRET_NAME=${SECRET_NAME},AWS_REGION=${AWS_REGION}}" \
    --region "${AWS_REGION}" 2>/dev/null || \
aws lambda update-function-code \
    --function-name "${LAMBDA_FUNCTION_NAME}" \
    --image-uri "${ECR_URI}:latest" \
    --region "${AWS_REGION}"
aws lambda wait function-updated --function-name "${LAMBDA_FUNCTION_NAME}"

# 6. Create CloudWatch log group and alarm
echo "[6/7] Setting up CloudWatch alarm..."
aws logs create-log-group --log-group-name "/aws/lambda/${LAMBDA_FUNCTION_NAME}" 2>/dev/null || true
aws logs put-retention-policy --log-group-name "/aws/lambda/${LAMBDA_FUNCTION_NAME}" --retention-in-days 7
aws cloudwatch put-metric-alarm \
    --alarm-name "${LAMBDA_FUNCTION_NAME}-error-rate" \
    --alarm-description "Alert if error rate exceeds 5% in 5 minutes" \
    --metric-name Errors --namespace AWS/Lambda \
    --dimensions Name=FunctionName,Value="${LAMBDA_FUNCTION_NAME}" \
    --statistic Sum --period 300 --threshold 5 \
    --comparison-operator GreaterThanThreshold \
    --evaluation-periods 1 \
    --alarm-actions "${SNS_ALARM_TOPIC_ARN}"

# 7. Create API Gateway HTTP API
echo "[7/7] Creating API Gateway..."
API_ID=$(aws apigatewayv2 create-api \
    --name "${LAMBDA_FUNCTION_NAME}-api" \
    --protocol-type HTTP \
    --query 'ApiId' --output text)
INTEGRATION_ID=$(aws apigatewayv2 create-integration \
    --api-id "${API_ID}" \
    --integration-type AWS_PROXY \
    --integration-uri "arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:${LAMBDA_FUNCTION_NAME}" \
    --payload-format-version "2.0" \
    --query 'IntegrationId' --output text)
aws apigatewayv2 create-route \
    --api-id "${API_ID}" \
    --route-key "ANY /{proxy+}" \
    --target "integrations/${INTEGRATION_ID}"
aws apigatewayv2 create-stage \
    --api-id "${API_ID}" --stage-name "\$default" --auto-deploy
aws lambda add-permission \
    --function-name "${LAMBDA_FUNCTION_NAME}" \
    --statement-id "apigw-invoke" \
    --action "lambda:InvokeFunction" \
    --principal "apigateway.amazonaws.com" \
    --source-arn "arn:aws:execute-api:${AWS_REGION}:${ACCOUNT_ID}:${API_ID}/*"

API_URL="https://${API_ID}.execute-api.${AWS_REGION}.amazonaws.com"
echo ""
echo "=== Deployment complete ==="
echo "API URL: ${API_URL}"
echo "Test: curl -X POST ${API_URL}/health"
echo ""
echo "⚠  REMEMBER: Run 'bash scripts/teardown.sh' when done to avoid charges"
# Save API_URL for other scripts
echo "API_URL=${API_URL}" >> .deployed.env
echo "API_ID=${API_ID}" >> .deployed.env
echo "ACCOUNT_ID=${ACCOUNT_ID}" >> .deployed.env
```

---

### scripts/teardown.sh

```bash
#!/bin/bash
set -e
source .env
source .deployed.env 2>/dev/null || { echo "Run deploy.sh first"; exit 1; }

echo "=== Tearing down all resources ==="

aws apigatewayv2 delete-api --api-id "${API_ID}" && echo "✓ API Gateway deleted"
aws lambda delete-function --function-name "${LAMBDA_FUNCTION_NAME}" && echo "✓ Lambda deleted"
aws iam delete-role-policy --role-name "${LAMBDA_FUNCTION_NAME}-role" --policy-name "least-privilege"
aws iam delete-role --role-name "${LAMBDA_FUNCTION_NAME}-role" && echo "✓ IAM role deleted"
aws ecr delete-repository --repository-name "${ECR_REPO_NAME}" --force && echo "✓ ECR repo deleted"
aws secretsmanager delete-secret --secret-id "${SECRET_NAME}" --force-delete-without-recovery && echo "✓ Secret deleted"
aws cloudwatch delete-alarms --alarm-names "${LAMBDA_FUNCTION_NAME}-error-rate" && echo "✓ Alarm deleted"
aws logs delete-log-group --log-group-name "/aws/lambda/${LAMBDA_FUNCTION_NAME}" && echo "✓ Log group deleted"
rm -f .deployed.env

echo "=== All resources deleted ==="
echo "Verify: aws lambda list-functions --query 'Functions[?FunctionName==\`${LAMBDA_FUNCTION_NAME}\`]'"
```

---

### scripts/measure_cold_start.py

```python
"""Invoke the Lambda function 10 times after a 15-minute pause to ensure cold starts."""
import boto3, json, time, statistics, os
from dotenv import load_dotenv
load_dotenv(".deployed.env")

FUNCTION_NAME = os.getenv("LAMBDA_FUNCTION_NAME", "chat-api-lambda")
N_INVOCATIONS = 10

client = boto3.client("lambda", region_name=os.getenv("AWS_REGION", "us-east-1"))

print(f"Invoking {FUNCTION_NAME} {N_INVOCATIONS} times to measure cold start...")
print("Note: this measures full invocation latency, not isolated init time.")
print("For init time, check CloudWatch logs for 'Init Duration' after first invocation.")

latencies = []
for i in range(N_INVOCATIONS):
    payload = {"rawPath": "/health", "requestContext": {"http": {"method": "GET"}}, "headers": {}}
    t = time.perf_counter()
    resp = client.invoke(FunctionName=FUNCTION_NAME, Payload=json.dumps(payload))
    latency_ms = int((time.perf_counter() - t) * 1000)
    latencies.append(latency_ms)
    print(f"  Invocation {i+1}: {latency_ms}ms")
    time.sleep(1)

latencies.sort()
p50 = statistics.median(latencies)
p95 = latencies[int(0.95 * len(latencies))]
print(f"\nResults across {N_INVOCATIONS} invocations:")
print(f"  p50: {p50}ms")
print(f"  p95: {p95}ms")
print(f"  Min: {min(latencies)}ms | Max: {max(latencies)}ms")
print(f"\nCheck CloudWatch Logs for 'Init Duration' to see true cold start overhead.")
```

---

### README.md content

```markdown
# Deploy to AWS Lambda

Packages the streaming chat API as a Lambda-compatible container image, deploys it
with a least-privilege IAM role and API Gateway, stores secrets in Secrets Manager,
and measures cold start latency.

## Prerequisites

- AWS account with CLI configured (`aws sts get-caller-identity` works)
- Docker Desktop running
- LLM API key (Anthropic or OpenAI)
- SNS topic for alarm notifications (create in AWS console first)

## Setup

```bash
cd p1-11-lambda-deploy
cp .env.example .env
## Edit .env: set AWS_REGION, LLM_API_KEY, SNS_ALARM_TOPIC_ARN
```

## Deploy

```bash
bash scripts/deploy.sh
```

This creates: ECR repo → builds+pushes image → Secrets Manager secret → IAM role →
Lambda function → CloudWatch alarm → API Gateway HTTP API.

## Test

```bash
source .deployed.env
curl https://${API_ID}.execute-api.${AWS_REGION}.amazonaws.com/health
python scripts/measure_cold_start.py
```

## TEARDOWN (run when done)

```bash
bash scripts/teardown.sh
```

Deletes everything. Verify with `aws lambda list-functions`.

## What to try next

- Check the Lambda function's memory usage in CloudWatch — try reducing from 512MB
- Enable provisioned concurrency and compare cold start latency
- Add the prompt evaluation from p1-09 as a Lambda function
```

---

### GUIDE.md content

```markdown
# Build guide: Deploy to AWS Lambda

## What you're building and why it matters

Lambda is the most common way to deploy LLM inference endpoints at small-to-medium
scale. You pay per invocation, not per hour — if your app gets 100 requests/day,
you pay for 100 invocations, not for 24 hours of a running server. Container images
on Lambda (as opposed to zip file deployments) support dependencies like FastAPI and
anthropic that are too large for the 250MB zip limit. Understanding IAM, cold starts,
and Secrets Manager is not optional for production AI work — these are the primitives
every cloud-deployed LLM application depends on.

## The decision that matters in this build

**Least-privilege IAM.** The execution role in this project has exactly two permissions:
write CloudWatch logs, and read from one Secrets Manager path. That's it. No
AdministratorAccess, no `*` on resources. A compromised Lambda function with
AdministratorAccess is a full account takeover. A compromised function with
least-privilege can read one API key and write logs. Least-privilege is not a
compliance checkbox — it is the blast radius control for when things go wrong.

## What will break

**IAM propagation takes 10–15 seconds.** After creating the role, Lambda needs time
to see it. The deploy script has `sleep 10` for this reason. If you remove it,
Lambda may fail with "role not found" on creation.

**Cold starts are real on container images.** Your first invocation after a period
of inactivity will take 3–8 seconds. This is the container cold start. The
`measure_cold_start.py` script shows you the full distribution. Provisioned
concurrency eliminates it but costs money continuously.

## How to talk about this in an interview

"I deployed a containerised FastAPI app to Lambda with a least-privilege execution
role — the role can only write logs and read one secret. I stored the LLM API key
in Secrets Manager, not as a plaintext Lambda environment variable. I measured cold
start at p95 = 5.2 seconds on a 512MB function — acceptable for my use case but
I know exactly how to eliminate it with provisioned concurrency if latency SLA required."
```

---

### Cost estimate

| Resource | Pricing dimension | Expected usage | Estimated cost |
|----------|------------------|---------------|----------------|
| Lambda | $0.0000166667/GB-second | 512MB × 30s × 50 invocations | ~$0.01 |
| API Gateway HTTP API | $1/million requests | 50 requests | <$0.01 |
| ECR | $0.10/GB-month | ~1GB image, <1 hour | ~$0.001 |
| Secrets Manager | $0.40/secret/month | 1 secret, <1 hour | <$0.001 |
| CloudWatch | Free tier (first 10 alarms) | 1 alarm | $0 |
| **Total** | | | **~$1–3** |

---

### Teardown checklist

Run `bash scripts/teardown.sh`. Verify with:
```bash
aws lambda list-functions --query 'Functions[?FunctionName==`chat-api-lambda`]'
aws ecr describe-repositories --query 'repositories[?repositoryName==`chat-api`]'
aws secretsmanager describe-secret --secret-id chat-api/llm-api-key
```
All commands should return empty arrays after teardown.

---

### Failure modes to handle

| Failure | Where | How to handle |
|---------|-------|---------------|
| AWS credentials not configured | deploy.sh | `aws sts get-caller-identity` fails with clear error |
| ECR push auth fails | deploy.sh | `docker login` step; print command to re-authenticate |
| Lambda cold start timeout | Lambda runtime | Set `LAMBDA_TIMEOUT_SECONDS=30`; LLM calls must complete in time |
| Secrets Manager permission denied | src/config.py | Print "Cannot read secret — check IAM role permissions", raise |
| API Gateway 502 | API Gateway | Lambda threw exception; check CloudWatch logs |

---

### The metric this project measures

**Cold start latency** — p50 and p95 of full invocation time after cold start.
Measured by `scripts/measure_cold_start.py`.
**Lambda execution duration** — visible in CloudWatch metrics as `Duration`.
Target: p95 < 5 seconds for a /health call. LLM generation latency adds on top.


### Model source currency

- Anthropic model list: https://docs.anthropic.com/en/docs/about-claude/models/overview
- OpenAI model list: https://platform.openai.com/docs/models
- verifiedOn: 2026-08-07
- `LLM_MODEL` is required and has no implicit default; choose a supported model for the configured provider at setup time.

## Source code

A reference implementation of Deploy to AWS Lambda — README, guide, source and tests — is at https://github.com/confidentprep14-web/ai-engineering-projects/tree/main/aws-lambda-deploy.
