Tarek Cheikh
Founder & AWS Cloud Architect
Level: Complete Beginner to Confident Builder
What You'll Build: A real serverless API that you can show off to friends
Most AWS tutorials throw code at you without explaining what's happening or why you need it. By the end of this guide, you'll not only have a working serverless API, but you'll understand every piece of it.
You'll learn by building something real: an API that tells visitors their location based on their IP address. It's simple enough to understand, but complex enough to teach you the important concepts.
Most importantly, you'll understand WHY we do each step, not just HOW.
Imagine you're running a food truck. Traditional web hosting is like renting a restaurant:
Serverless is like having a magical food truck that:
AWS Lambda is Amazon's serverless service. Your code sits dormant until someone triggers it (like visiting your website), then it springs to life, does its job, and goes back to sleep.
AWS SAM (Serverless Application Model) is like having a smart assistant who:
Perfect for:
Not great for:
Don't worry — I'll explain what each thing is and why you need it.
python --version
What is this? Python is the programming language we'll use. AWS Lambda supports several Python versions, and 3.9+ gives us access to modern features that make our code cleaner.
Why not just any version? AWS Lambda runs on specific versions. Using an old version means your code might behave differently when deployed.
docker --version
What is this? Docker is like a virtual computer inside your computer. SAM uses it to create an exact copy of the AWS environment on your laptop.
Why do I need this? Testing locally with Docker means no surprises when you deploy. If it works on your laptop, it'll work on AWS.
How to get it: Download Docker Desktop from docker.com and install it.
pip install awscli
aws --version
What is this? A tool that lets your computer talk to AWS services.
Why do I need this? SAM uses it to deploy your code to AWS. Think of it as the bridge between your computer and Amazon's servers.
What is this? Your account with Amazon Web Services. It's free to create.
Why do I need this? This is where your serverless function will live and run.
What about costs? AWS has a generous free tier. For learning, you'll likely pay nothing or just a few cents.
aws configure
This asks for four things:
jsonWhere do I get the keys?
# Option 1: Using pip (works on all systems)
pip install aws-sam-cli
# Option 2: On macOS with Homebrew
brew tap aws/tap
brew install aws-sam-cli
# Verify it worked
sam --version
What is this? The main tool we'll use. It's like having a smart assistant that knows how to build and deploy serverless applications.
Quick check that everything works:
python --version && docker --version && aws --version && sam --version
If all four commands show version numbers, you're ready!
Let's create your first serverless application. We'll start with AWS's built-in template, then customize it to do something interesting.
sam init
This starts an interactive setup. Let me walk you through each choice and explain what it means:
First, SAM shows you a welcome message explaining that it collects telemetry (usage data) to improve the tool. This is optional and helps AWS make SAM better.
Template source options:
Which template source would you like to use?
1 - AWS Quick Start Templates
2 - Custom Template Location
Choice: 1
Choose 1. AWS Quick Start Templates are pre-built project structures for common use cases. They're battle-tested and follow best practices.
Package type:
Use the most popular runtime and package type? (python3.13 and zip) [y/N]: y
Type y. This chooses Python 3.13 (the latest) and "zip" packaging (simpler than container images for beginners).
Template selection:
Choose an AWS Quick Start application template
1 - Hello World Example
2 - Data processing
3 - Hello World Example with Powertools for AWS Lambda
(... more options)
Template: 1
Choose 1 — Hello World Example. This gives us a simple API that we can understand and modify.
What are the other templates for?
Project name:
Project name [sam-app]: my-first-serverless-api
Give it a descriptive name. I'll use my-first-serverless-api.
Additional options:
Would you like to enable X-Ray tracing on the function(s) in your application? [y/N]: N
Would you like to enable monitoring using CloudWatch Application Insights? [y/N]: N
Would you like to set Structured Logging in JSON format on your function? [y/N]: N
Type N to all three. These are advanced features that add complexity. We'll keep things simple for learning.
What just happened? SAM downloaded a template from GitHub and created a new folder with all the files you need for a serverless application.
Navigate to your new project:
cd my-first-serverless-api
ls
Let's see what SAM created for us:
my-first-serverless-api/
├── hello_world/ # Your Python code goes here
├── events/ # Test data for local testing
├── tests/ # Automated tests (we'll add some later)
├── template.yaml # The blueprint for your infrastructure
├── .gitignore # Tells Git which files to ignore
└── README.md # Documentation
Let's look at the key files and understand what each one does.
This file is like an architect's blueprint. It tells AWS exactly what to build for you.
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Timeout: 3
MemorySize: 128
Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: hello_world/
Handler: app.lambda_handler
Runtime: python3.13
Architectures:
- x86_64
Events:
HelloWorld:
Type: Api
Properties:
Path: /hello
Method: get
Outputs:
HelloWorldApi:
Description: "API Gateway endpoint URL for Prod stage for Hello World function"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
Let me explain this in plain English:
The header section:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
This tells AWS: "This is a CloudFormation template (AWS's infrastructure language), but use SAM to make it simpler."
Global settings:
Globals:
Function:
Timeout: 3
MemorySize: 128
This says: "For all functions in this project, kill them if they run longer than 3 seconds, and give them 128MB of memory." These are starting points — you can change them later.
The actual function:
HelloWorldFunction:
Type: AWS::Serverless::Function
This creates an AWS Lambda function named "HelloWorldFunction". SAM automatically creates the IAM role (permissions) and CloudWatch logs (for debugging) that go with it.
Where to find your code:
CodeUri: hello_world/
Handler: app.lambda_handler
This tells AWS: "The code is in the hello_world/ folder, and when someone triggers this function, call the lambda_handler function inside the app.py file."
What triggers your function:
Events:
HelloWorld:
Type: Api
Properties:
Path: /hello
Method: get
This creates an API Gateway that listens for GET requests to /hello. When someone visits yourapi.com/hello, it triggers your Lambda function.
What you get back after deployment:
Outputs:
HelloWorldApi:
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
After deployment, SAM will tell you the URL where your API is available. The !Sub part means "substitute variables" — AWS fills in the actual API ID and region.
This is where your actual logic lives:
import json
def lambda_handler(event, context):
"""Sample pure Lambda function"""
return {
"statusCode": 200,
"body": json.dumps({
"message": "hello world",
}),
}
Let's understand this step by step:
The function signature:
def lambda_handler(event, context):
This function name (lambda_handler) must match what you specified in template.yaml. AWS will call this function when your API receives a request.
The event parameter contains information about the request:
The context parameter contains information about the Lambda environment:
What your function returns:
return {
"statusCode": 200,
"body": json.dumps({
"message": "hello world",
}),
}
This is the HTTP response format that API Gateway expects:
statusCode: HTTP status (200 = success, 404 = not found, 500 = error)body: The actual response data (must be a string, that's why we use json.dumps())Why json.dumps()? Your Python dictionary needs to be converted to a JSON string because HTTP responses are always text.
This file contains sample data for testing your function locally:
{
"body": "{\"message\": \"hello world\"}",
"resource": "/{proxy+}",
"path": "/path/to/resource",
"httpMethod": "POST",
"queryStringParameters": {
"foo": "bar"
},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"User-Agent": "Custom User Agent String",
"X-Forwarded-For": "127.0.0.1, 127.0.0.2"
}
}
What is this? This is what the event parameter looks like when API Gateway calls your function. It's a lot of information about the HTTP request.
Why is this useful? You can use this file to test your function locally without deploying it to AWS first.
The default "hello world" is boring. Let's modify our function to do something useful: tell visitors their public IP address and location.
Why this example?
First, let's add a dependency. Edit hello_world/requirements.txt:
requests
What is this file? It tells AWS which Python packages to install alongside your code. requests is a popular library for making HTTP calls.
Now, let's update our function. Replace the contents of hello_world/app.py:
import json
import requests
import logging
# Set up logging so we can debug issues
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
"""
A function that tells visitors their public IP address.
This demonstrates:
- Making external API calls
- Error handling
- Logging for debugging
- Proper API response format
"""
logger.info("Someone called our API!")
try:
# Get the visitor's public IP address
logger.info("Fetching public IP address...")
ip_response = requests.get("http://checkip.amazonaws.com/", timeout=3)
# Check if the request was successful
ip_response.raise_for_status()
# Extract the IP address (and remove any whitespace)
public_ip = ip_response.text.strip()
logger.info(f"Got IP address: {public_ip}")
# Create our response
response_data = {
"message": "Hello! Here's your information:",
"your_ip": public_ip,
"request_id": context.aws_request_id,
"served_by": "AWS Lambda"
}
# Return success response
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json",
# Allow browsers to call our API from any website
"Access-Control-Allow-Origin": "*"
},
"body": json.dumps(response_data, indent=2)
}
except requests.exceptions.Timeout:
logger.error("Request timed out while fetching IP")
return create_error_response(504, "The external service took too long to respond", context.aws_request_id)
except requests.exceptions.RequestException as e:
logger.error(f"Request failed: {e}")
return create_error_response(502, "Could not fetch your IP address", context.aws_request_id)
except Exception as e:
logger.error(f"Unexpected error: {e}")
return create_error_response(500, "Something went wrong on our end", context.aws_request_id)
def create_error_response(status_code, message, request_id=None):
"""
Create a standardized error response.
Why separate function? It keeps our code DRY (Don't Repeat Yourself)
and ensures all errors follow the same format.
"""
return {
"statusCode": status_code,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
},
"body": json.dumps({
"error": message,
"request_id": request_id or "unknown"
})
}
Let me explain what we added and why:
Logging:
logger = logging.getLogger()
logger.setLevel(logging.INFO)
Why? When your function runs in AWS, you can't see what's happening. Logging lets you write messages that appear in CloudWatch logs, helping you debug issues.
External API call:
ip_response = requests.get("http://checkip.amazonaws.com/", timeout=3)
Why timeout=3? Without a timeout, your function could wait forever for a response. Since Lambda has a 3-second timeout by default, we need to be faster than that.
Error handling:
try:
# Main logic here
except requests.exceptions.Timeout:
# Handle timeout specifically
except requests.exceptions.RequestException as e:
# Handle other network errors
except Exception as e:
# Handle any other unexpected errors
Why so many exception types? Different errors need different responses. A timeout (504) is different from a general network error (502) or a bug in our code (500).
Response headers:
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
What do these do?
Content-Type: Tells the browser we're sending JSON dataAccess-Control-Allow-Origin: Allows browsers to call our API from any website (CORS)Before deploying to AWS, let's test our function on your computer. This saves time and money.
sam validate
sam build
What is SAM doing?
You should see:
Build Succeeded
Built Artifacts : .aws-sam/build
Built Template : .aws-sam/build/template.yaml
What if it fails? Common issues:
requestssam local invoke HelloWorldFunction --event events/event.json
What happens here?
You should see something like:
{
"statusCode": 200,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
},
"body": "{\n \"message\": \"Hello! Here's your information:\",\n \"your_ip\": \"203.0.113.1\",\n \"request_id\": \"12345-67890\",\n \"served_by\": \"AWS Lambda\"\n}"
}
This is even better — you get a real API you can test with your browser:
sam local start-api
What you'll see:
Mounting HelloWorldFunction at http://127.0.0.1:3000/hello [GET]
You can now browse to the above endpoints to invoke your functions.
Test it in your browser: Go to http://127.0.0.1:3000/hello
Or test with curl:
curl http://127.0.0.1:3000/hello
You should see your actual IP address! This proves your function is working and can make external API calls.
What if it doesn't work?
sam local start-api --port 3001Now let's deploy your function to AWS so anyone on the internet can use it.
For your first deployment, use the guided mode:
sam deploy --guided
SAM will ask you several questions:
Stack Name:
Stack Name [sam-app]: my-first-serverless-api
This is the name AWS uses to group all your resources. Choose something descriptive.
AWS Region:
AWS Region [eu-west-1]: eu-west-1
Where in the world to deploy. eu-west-1 (in my case). us-east-1 is usually cheapest and fastest for most people.
Confirm changes before deploy:
Confirm changes before deploy [Y/n]: Y
Type Y. This shows you what AWS will create before doing it.
Allow SAM CLI IAM role creation:
Allow SAM CLI IAM role creation [Y/n]: Y
Type Y. Your Lambda function needs permissions to run and write logs. SAM creates the minimal permissions needed.
Disable rollback:
Disable rollback [y/N]: y
Type y. If deployment fails, this leaves resources in place for debugging (helpful during development).
HelloWorldFunction may not have authorization:
HelloWorldFunction has no authentication. Is this okay? [y/N]: y
Type y. This means your API is publicly accessible (which is what we want).
Save parameters to configuration file:
Save arguments to configuration file [Y/n]: Y
Type Y. This saves your choices so next time you can just run sam deploy without the questions.
SAM will show you everything it's creating:
CloudFormation outputs from deployed stack
Key HelloWorldApi
Description API Gateway endpoint URL for Prod stage for Hello World function
Value https://119o7zsewj.execute-api.eu-west-1.amazonaws.com/Prod/hello/
What is each thing?
After successful deployment, you'll see:
Key HelloWorldApi
Description API Gateway endpoint URL for Prod stage for Hello World function
Value https://119o7zsewj.execute-api.eu-west-1.amazonaws.com/Prod/hello/
That URL is your live API! Copy it and test it:
curl https://119o7zsewj.execute-api.eu-west-1.amazonaws.com/Prod/hello/
You should see:
{
"message": "Hello! Here's your information:",
"your_ip": "203.0.113.1",
"request_id": "12345-67890",
"served_by": "AWS Lambda"
}
Congratulations! You've deployed a serverless API that's live on the internet!
Let's test your API thoroughly to make sure it works correctly.
Test in your browser: Just paste your API URL into your browser's address bar. You should see JSON response with your IP address.
Test with different tools:
# Using curl (command line)
curl https://your-api-url.amazonaws.com/Prod/hello/
# Using httpie (more user-friendly)
pip install httpie
http GET https://your-api-url.amazonaws.com/Prod/hello/
Your API returns something like:
{
"message": "Hello! Here's your information:",
"your_ip": "203.0.113.1",
"request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"served_by": "AWS Lambda"
}
What each field means:
Want to see what happened behind the scenes?
sam logs -n HelloWorldFunction --tail
This shows:
logger.info() messagesThe good news: Your API will likely cost nothing or just a few cents.
AWS Free Tier includes:
For perspective: If someone calls your API once per second for an entire month, that's only 2.6 million requests. You'd pay about $1.
Let's add a feature to show how easy it is to update your API.
Let's modify our function to accept a name parameter. Edit hello_world/app.py:
def lambda_handler(event, context):
logger.info("Someone called our API!")
# Get query parameters from the request
query_params = event.get('queryStringParameters') or {}
name = query_params.get('name', 'visitor')
try:
# ... existing IP fetching code ...
# Create personalized response
response_data = {
"message": f"Hello {name}! Here's your information:",
"your_ip": public_ip,
"request_id": context.aws_request_id,
"served_by": "AWS Lambda"
}
# ... rest of the function stays the same ...
What did we add?
query_params = event.get('queryStringParameters') or {}
name = query_params.get('name', 'visitor')
Explanation:
event.get('queryStringParameters') gets the query parameters from the URLor {} provides an empty dictionary if there are no parameters.get('name', 'visitor') gets the 'name' parameter, or uses 'visitor' as defaultsam build
sam deploy
Notice: No questions this time! SAM remembers your previous answers.
# Test without name parameter
curl https://your-api-url.amazonaws.com/Prod/hello/
# Test with name parameter
curl "https://your-api-url.amazonaws.com/Prod/hello/?name=Alice"
You should see:
{
"message": "Hello Alice! Here's your information:",
"your_ip": "203.0.113.1",
"request_id": "...",
"served_by": "AWS Lambda"
}
Let's prepare you for common issues and how to fix them.
"Internal Server Error" (HTTP 500)
This usually means there's a bug in your Python code.
# Check the logs
sam logs -n HelloWorldFunction --tail
# Look for Python tracebacks like:
# [ERROR] NameError: name 'unknown_variable' is not defined
"Timeout" errors
Your function took longer than 3 seconds.
Solution: Increase timeout in template.yaml:
Globals:
Function:
Timeout: 10 # 10 seconds instead of 3
"Unable to import module" errors
Usually means missing dependencies.
Solution: Make sure requirements.txt lists all packages you import.
1. Use lots of logging:
logger.info(f"Received event: {event}")
logger.info(f"About to call external API...")
logger.info(f"External API returned: {response.status_code}")
2. Test locally first:
sam local invoke HelloWorldFunction --event events/event.json
3. Check CloudWatch logs in AWS Console:
/aws/lambda/your-function-name4. Use the AWS Console Lambda testing:
Check function metrics:
# See recent invocations
aws logs describe-log-groups --log-group-name-prefix "/aws/lambda/"
# Get function configuration
aws lambda get-function-configuration --function-name your-function-name
In the AWS Console:
When you're done experimenting, clean up to avoid charges:
sam delete
SAM will ask:
Are you sure you want to delete the stack my-first-serverless-api in the region eu-west-1? [y/N]: y
Are you sure you want to delete the folder my-first-serverless-api in S3 which contains the artifacts? [y/N]: y
Type y to both.
What gets deleted:
Verify cleanup:
aws lambda list-functions --query "Functions[?contains(FunctionName, 'my-first-serverless-api')]"
Should return an empty list: []
Let's recap what you've learned and built:
For your career: Serverless is increasingly popular. These skills are valuable.
For your projects: You can now build APIs that:
For learning: You understand the fundamentals. You can now:
You've mastered the basics. Here are natural next steps:
What: Connect your API to a database to store and retrieve data.
How: Add DynamoDB to your template.yaml and use boto3 to interact with it.
Why: Most real applications need to persist data.
What: Create functions that process uploaded files (resize images, parse CSV files, etc.).
How: Use S3 events to trigger your Lambda when files are uploaded.
Why: Very common use case for serverless.
What: Protect your API so only authorized users can access it.
How: Use AWS Cognito or API keys.
Why: Most production APIs need security.
What: Build a complete application with multiple interconnected functions.
How: Create multiple functions in one SAM template, each with different responsibilities.
Why: Real applications are composed of many small services.
Essential Documentation
Hands-On Learning
Community
Books and Courses
You've just built and deployed your first serverless API! More importantly, you understand how it all works.
Remember:
You're now equipped to:
The future is serverless, and you're ready for it!
Found this helpful? Share it with fellow developers who want to learn serverless!
Happy building!
This article is just the start. Get the full picture with our free whitepaper - 8 chapters covering IAM, S3, VPC, monitoring, agentic AI security, compliance, and a prioritized action plan with 50+ CLI commands.
How I built a secure, scalable file sharing solution using AWS Lambda, API Gateway, and Cognito with zero infrastructure to manage and 91% cost reduction.
How I built CognitoApi, an open-source serverless authentication API on AWS Cognito that handles 50,000 users for free with MFA, token management, and complete user lifecycle support.
Stop sending your IAM policies, CloudTrail logs, and infrastructure code to third-party APIs. Run LLMs locally with Ollama on Apple Silicon — private, offline, fast. Complete setup guide with AWS security use cases.