Build a Serverless AI Weather API

Build a serverless AI weather API with Ollama, Prometheus metrics, and GitHub CI/CD on Windows.

Introduction

30 Second Summary

Every weather app you use relies on APIs that fetch data, process it, and serve it back in milliseconds. Behind the scenes, someone had to design that architecture, secure it, and deploy it without a single server to manage.

In this project, you will build an AI-powered weather API on AWS serverless infrastructure. You'll deploy a function that fetches live weather data, generates an AI briefing via Groq, and serves it through an HTTP endpoint you can call from anywhere.

What You'll Build

You send a city name to your API endpoint, and seconds later get back a natural-language weather briefing written by an AI, complete with clothing recommendations and activity suggestions.

By the end of this project, you'll have:

  • A live HTTP endpoint you can curl with any city name and get back an AI-generated weather briefing powered by real-time data from OpenWeather and a natural-language summary from Groq.
  • A real IAM permissions story where you watched your Lambda fail with AccessDenied, diagnosed the error, and fixed it by writing a least-privilege policy from scratch.
  • A whiteboard-ready architecture (Lambda + API Gateway + S3 + IAM) you can explain in a Solutions Architect interview, with a genuine debugging story about why each component exists.
  • Secret Mission: Secure your API with API key authentication so only authorized clients can call your endpoint.

Are there any prerequisites?

This project assumes you already have Docker Desktop with a WSL2 backend installed on Windows, a free LocalStack Hobby tier account, a Groq API key (free), and an OpenWeather API key (free).

You'll install the AWS CLI, LocalStack CLI, and the awslocal wrapper in Step 1. No AWS account or credit card is needed.

Before We Start

Before any hands-on work, let's commit to what you're building and why it matters for your interview preparation. This step is about locking in the architecture and the story behind it.

Set Up Your Local AWS Environment

To build and deploy AWS infrastructure, you normally need an AWS account, billing credentials, and the risk of unexpected charges.

LocalStack eliminates all of that. It runs a full AWS emulator inside a Docker container on your machine. In this step, you will install the AWS CLI, LocalStack, and the awslocal wrapper so you can run real AWS commands locally at zero cost.

In this step, get ready to:
  • Install AWS CLI v2 for interacting with AWS services.
  • Install and start LocalStack to emulate AWS locally.
  • Configure credentials so all CLI commands route to LocalStack.
Install AWS CLI v2 in WSL2

The AWS CLI is the command-line tool you use to create and manage AWS resources. This project requires version 2.

  • Check whether the AWS CLI is already installed by running this command in your WSL2 terminal:
aws --version

✔️ I see aws-cli/2.x.x

Your output starts with aws-cli/2. followed by version details and your OS information. AWS CLI v2 is ready.

Skip ahead to the next substep.

ⓧ I see aws-cli/1.x.x

You have version 1 installed. Version 2 has breaking changes from version 1 and must replace it.

  • Uninstall AWS CLI v1 by running:
  • Then install AWS CLI v2 using the commands in the next tab (Command not found).
sudo rm -rf /usr/local/aws
sudo rm /usr/local/bin/aws

ⓧ Command not found

The AWS CLI is not installed. Install it now with the official bundled installer.

  • Download, unzip, and install the AWS CLI v2 by running these commands:
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install

What do these commands do?

  • curl downloads the official installer zip from AWS.
  • unzip extracts the installer files.
  • sudo ./aws/install runs the installer, which places the aws binary in /usr/local/bin.

The sudo command will prompt for your password. Type it and press Enter (you will not see characters as you type).

  • Verify the installation by running:
aws --version

You should see output starting with aws-cli/2. followed by Python and OS details.

Still seeing command not found?

  • Make sure unzip is installed. Run sudo apt install unzip if the unzip step failed.
  • Try opening a new terminal tab so your shell picks up the updated PATH.

Help me troubleshoot my AWS CLI installation in WSL2.

Install LocalStack CLI and awslocal wrapper

LocalStack is the emulator that runs AWS services inside Docker. The awslocal wrapper is a convenience tool that routes all your aws commands to LocalStack automatically.

  • Check whether the LocalStack CLI is already installed by running:
localstack --version

✔️ I see 2026.6.0 or higher

Your LocalStack CLI is up to date. Skip ahead to the auth token step below.

ⓧ I see an older version

You have an older LocalStack CLI installed. The following commands will replace it with the version this project uses.

  • Download and install LocalStack CLI v2026.6.0 by running:
curl --output localstack-cli-2026.6.0-linux-amd64-onefile.tar.gz --location https://github.com/localstack/localstack-cli/releases/download/v2026.6.0/localstack-cli-2026.6.0-linux-amd64-onefile.tar.gz
sudo tar xvzf localstack-cli-2026.6.0-linux-*-onefile.tar.gz -C /usr/local/bin
  • Confirm the new version by running:
localstack --version

You should see 2026.6.0 in the output. This replaces your previous version.

ⓧ Command not found

LocalStack CLI is not installed. Install it now.

  • Download and install LocalStack CLI v2026.6.0 by running:
curl --output localstack-cli-2026.6.0-linux-amd64-onefile.tar.gz --location https://github.com/localstack/localstack-cli/releases/download/v2026.6.0/localstack-cli-2026.6.0-linux-amd64-onefile.tar.gz
sudo tar xvzf localstack-cli-2026.6.0-linux-*-onefile.tar.gz -C /usr/local/bin

What do these commands do?

  • curl downloads the pre-built LocalStack binary from GitHub.
  • tar xvzf extracts the binary directly into /usr/local/bin so it is available system-wide.
  • Confirm it installed by running:
localstack --version

You should see 2026.6.0 printed.

Still seeing command not found?

  • Make sure /usr/local/bin is on your PATH. Run echo $PATH and check it appears in the list.
  • Try opening a new terminal tab so the shell picks up the new binary.

Help me debug my LocalStack CLI installation in WSL2.

Now that the LocalStack CLI is installed, you need to authenticate it and start the emulator.

  • Authenticate LocalStack with your Hobby tier token by running:
localstack auth set-token your-localstack-auth-token
  • Replace your-localstack-auth-token with the auth token from your LocalStack account dashboard.

Where do I find my auth token?

Log in to your LocalStack account at app.localstack.cloud. Your auth token is on the dashboard or under the API keys section. Copy the full token string.

  • Start LocalStack in detached mode by running:
localstack start -d

LocalStack pulls its Docker image (if needed) and starts the container in the background. This may take 30-60 seconds on the first run.

What does detached mode mean?

The -d flag runs the container in the background so you keep control of your terminal. LocalStack accepts API calls on localhost:4566 once it finishes starting.

  • Install the awslocal wrapper by running:
pip install awscli-local[ver1]

You should see pip download and install the package. The awslocal command now works identically to aws but routes every request to LocalStack automatically.

LocalStack failed to start?

  • Make sure Docker Desktop is running. Open Docker Desktop and confirm you see the dashboard.
  • Verify Docker is accessible from WSL2 by running docker ps. If that fails, restart Docker Desktop.
  • If you see a license or auth error, double-check you ran localstack auth set-token with the correct token.

Help me troubleshoot LocalStack failing to start in WSL2.

Configure AWS CLI credentials for LocalStack

LocalStack accepts any credentials. It does not validate them against real AWS. You still need to export environment variables because the AWS CLI refuses to make calls without credentials configured.

  • Export the required environment variables by running these commands:
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1

Why test as the value?

LocalStack does not check your credentials against AWS. The value test is the LocalStack convention for dummy credentials. The region us-east-1 tells the CLI which region to target inside the emulator.

Before you run the verification command, what do you think you will see if LocalStack is running and accepting API calls?

  • Verify everything works end-to-end by running:
awslocal sts get-caller-identity

You should see a JSON response containing Account and Arn fields. This confirms LocalStack is running and accepting AWS API calls through the awslocal wrapper.

Seeing a connection error?

  • LocalStack may still be starting up. Wait 30 seconds and try again.
  • Run docker ps to confirm a LocalStack container is listed as running.
  • Make sure you exported all three environment variables in the same terminal session where you run awslocal.

Help me fix my awslocal connection to LocalStack.

Your local AWS environment is fully operational. Next up, you will write a Lambda function, deploy it, and invoke it to get a live AI weather briefing.

Deploy Your Lambda Function and See It Work

Your local AWS environment is ready and accepting API calls. Now it is time to get something working fast.

The fastest path to a visible win is to write the Lambda handler, deploy it, and invoke it directly. Within minutes you will have a working function returning a real AI-generated weather briefing. This proves the core concept works before you add more infrastructure around it.

In this step, get ready to:
  • Write the Lambda handler that calls OpenWeather and Groq APIs.
  • Package and deploy the Lambda to LocalStack via the CLI.
  • Invoke the function and see a live AI weather briefing.
Write the Lambda handler

Your Lambda function will accept a city name, fetch live weather data from OpenWeather, send that data to Groq's LLM for a natural-language briefing, and return the result as JSON.

  • Move to your Desktop by running this command:
  • Create your project folder and move into it by running these commands:
cd ~/Desktop
mkdir weather-briefing-api
cd weather-briefing-api
  • Confirm the folder exists by running:
pwd

You should see /home/<your-user>/Desktop/weather-briefing-api in the output.

  • Create handler.py by running:
touch handler.py
  • Open handler.py in your preferred text editor and paste the following code that sets up the function signature and extracts the city from the request:
import json
import os
import urllib.request
import urllib.parse
from datetime import datetime, timezone


def lambda_handler(event, context):
    # Extract city from query parameters or direct invocation
    if "queryStringParameters" in event and event["queryStringParameters"]:
        city = event["queryStringParameters"].get("city", "London")
    elif "city" in event:
        city = event["city"]
    else:
        city = "London"

What does this code do?

The imports pull in Python's standard library modules for HTTP requests, JSON parsing, and URL encoding. No third-party packages are needed here.

The lambda_handler function checks two places for the city: queryStringParameters (used when API Gateway calls the function) and the top-level event payload (used when you invoke it directly from the CLI). It defaults to London if no city is provided.

  • Below the city extraction code, paste this block that fetches weather data from the OpenWeather API and builds a summary string for the LLM:
    # Fetch weather data from OpenWeather
    openweather_key = os.environ["OPENWEATHER_API_KEY"]
    weather_url = (
        f"https://api.openweathermap.org/data/2.5/weather"
        f"?q={urllib.parse.quote(city)}&appid={openweather_key}&units=metric"
    )
    weather_req = urllib.request.Request(weather_url)
    with urllib.request.urlopen(weather_req) as response:
        weather_data = json.loads(response.read().decode())

    # Build weather summary for the LLM
    weather_summary = (
        f"City: {weather_data['name']}, "
        f"Temperature: {weather_data['main']['temp']}C, "
        f"Feels like: {weather_data['main']['feels_like']}C, "
        f"Humidity: {weather_data['main']['humidity']}%, "
        f"Conditions: {weather_data['weather'][0]['description']}, "
        f"Wind speed: {weather_data['wind']['speed']} m/s"
    )

What does this code do?

The function reads the OPENWEATHER_API_KEY from the Lambda's environment variables and constructs the API URL with the city name URL-encoded for safety.

After fetching the JSON response, it builds a human-readable summary string containing temperature, humidity, conditions, and wind speed. This summary is what gets sent to the LLM as context.

  • Below the weather summary, paste this block that sends the data to Groq's LLM and extracts the briefing:
    # Call Groq API for AI briefing
    groq_key = os.environ["GROQ_API_KEY"]
    groq_url = "https://api.groq.com/openai/v1/chat/completions"
    groq_payload = json.dumps({
        "model": "llama-3.1-8b-instant",
        "messages": [
            {
                "role": "system",
                "content": "You are a concise weather briefing assistant. Given weather data, provide a 2-3 sentence briefing suitable for someone planning their day. Include clothing or activity recommendations."
            },
            {
                "role": "user",
                "content": f"Give me a weather briefing based on this data: {weather_summary}"
            }
        ]
    }).encode("utf-8")
    groq_req = urllib.request.Request(
        groq_url,
        data=groq_payload,
        headers={
            "Authorization": f"Bearer {groq_key}",
            "Content-Type": "application/json"
        }
    )
    with urllib.request.urlopen(groq_req) as response:
        groq_data = json.loads(response.read().decode())

    briefing = groq_data["choices"][0]["message"]["content"]

What does this code do?

This sends the weather summary to Groq's llama-3.1-8b-instant model using the OpenAI-compatible chat completions endpoint. The system prompt instructs the model to produce a concise 2-3 sentence briefing with practical recommendations.

The response is parsed and the briefing text is extracted from the standard choices[0].message.content path.

  • Finally, paste this block that assembles and returns the JSON response:
    # Return response (S3 logging not yet added)
    body = json.dumps({
        "city": weather_data["name"],
        "briefing": briefing,
        "temperature_c": weather_data["main"]["temp"],
        "conditions": weather_data["weather"][0]["description"]
    })

    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": body
    }

What does this code do?

The return value follows the format API Gateway expects: a statusCode, headers, and a JSON-serialized body. This structure works both for direct CLI invocation and for API Gateway integration later.

  • Save handler.py.
  • Verify the file is syntactically valid by running:
python3 -c "import ast; ast.parse(open('handler.py').read()); print('Syntax valid')"

You should see Syntax valid printed with no errors.

Seeing a SyntaxError?

Check that each code block was pasted in order, with proper indentation (4 spaces inside the function body). Python is sensitive to indentation. Make sure there are no extra or missing spaces at the start of each line.

help me fix a SyntaxError in my Lambda handler.py

✔️ Awesome, I've got everything!

Great. Double check you have saved handler.py with all four sections: imports and city extraction, weather fetch, Groq call, and response return.

ⓧ I'd like to double check the full code

Your handler.py should contain, in order:

  • Imports (json, os, urllib.request, urllib.parse, datetime) and the lambda_handler function definition with city extraction logic.
  • Weather data fetch from OpenWeather and the weather_summary string builder.
  • Groq API call constructing the payload, sending the request, and extracting briefing.
  • Response assembly returning statusCode, headers, and body.

Scroll up and confirm each chunk is present in order. Run the syntax check again to verify everything parses correctly.

Package the Lambda and deploy it to LocalStack

Before you can deploy, your Lambda needs an IAM execution role. This is the identity your function assumes at runtime, and it controls what AWS services the function can access.

  • Create a trust policy file that allows Lambda to assume the role by running:
cat > trust-policy.json << 'EOF'
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
EOF
  • Create the IAM execution role using the trust policy by running:
awslocal iam create-role \
  --role-name lambda-execution-role \
  --assume-role-policy-document file://trust-policy.json

You should see a JSON response containing the role's ARN: arn:aws:iam::000000000000:role/lambda-execution-role.

  • Attach the basic Lambda execution policy by running:
awslocal iam attach-role-policy \
  --role-name lambda-execution-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

What is this role doing?

The trust policy tells AWS "Lambda is allowed to assume this role." The attached policy (AWSLambdaBasicExecutionRole) grants minimal permissions for CloudWatch logging.

Right now, this role has no S3 permissions. That becomes important in a later step when you add logging.

  • Package your handler into a zip file by running:
zip function.zip handler.py

You should see adding: handler.py in the output confirming the file was added to the archive.

  • Deploy the Lambda function to LocalStack by running the following command. Replace your-openweather-key and your-groq-key with your actual API keys:
awslocal lambda create-function \
  --function-name weather-briefing \
  --runtime python3.11 \
  --handler handler.lambda_handler \
  --zip-file fileb://function.zip \
  --role arn:aws:iam::000000000000:role/lambda-execution-role \
  --timeout 30 \
  --environment "Variables={OPENWEATHER_API_KEY=your-openweather-key,GROQ_API_KEY=your-groq-key,AWS_ENDPOINT_URL=http://localhost:4566}"

You should see a JSON response with FunctionName set to weather-briefing and State showing Active.

What do these flags mean?

  • --runtime python3.11 tells Lambda which language runtime to use.
  • --handler handler.lambda_handler points to the lambda_handler function inside handler.py.
  • --zip-file fileb://function.zip uploads your packaged code. The fileb:// prefix tells the CLI to read the file as binary.
  • --environment passes your API keys and the LocalStack endpoint URL so the function can reach other LocalStack services internally.

Seeing an error about the role or zip file?

If you see a "role not found" error, make sure the create-role command completed successfully above. Run awslocal iam get-role --role-name lambda-execution-role to confirm it exists.

If you see a "file not found" error for the zip, make sure you are still in the weather-briefing-api folder. Run ls function.zip to confirm the zip file is there.

help me debug my Lambda create-function command in LocalStack

Invoke the Lambda and confirm it works

Your function is deployed. Time to call it and see real AI-generated weather data come back.

  • Before you run the invoke command, predict what you expect to see. Will the response contain a weather briefing for London, or will something go wrong?
  • Invoke the Lambda directly by running:
awslocal lambda invoke \
  --function-name weather-briefing \
  --payload '{"city": "London"}' \
  output.json && cat output.json

You should see a JSON response containing a briefing field with an AI-generated weather summary for London, along with temperature_c and conditions fields showing the live weather data.

What should the response look like?

The output.json file contains the Lambda's return value. You should see something like:

{
  "statusCode": 200,
  "headers": {"Content-Type": "application/json"},
  "body": "{\"city\": \"London\", \"briefing\": \"Currently 15C with light rain...\", ...}"
}

The exact briefing text will vary since the LLM generates it fresh each time. The important thing is that statusCode is 200 and the body contains a natural-language weather briefing.

Seeing an error in the response?

If you see a timeout or connection error, your Lambda may not be able to reach the external APIs. Make sure your WSL2 network has internet access by running curl https://api.groq.com from the terminal.

If you see an authentication error mentioning your API key, double-check the --environment values you passed to create-function. You can update them by running awslocal lambda update-function-configuration with the corrected keys.

help me debug my Lambda invocation error

You just deployed a working serverless AI function using nothing but CLI commands. Next up, you will expose it as an HTTP endpoint so any client can call it with a simple curl command.

Expose Your Lambda via API Gateway

Your Lambda function works when you invoke it directly from the CLI. That is great for testing, but enterprise customers need HTTP endpoints they can reach with any standard HTTP client.

In this step, you will turn your function into a REST API using API Gateway. By the end, you will curl your endpoint with a city name and get back an AI weather briefing over HTTP.

In this step, get ready to:
  • Create a REST API and a /weather resource in API Gateway.
  • Wire the resource to your Lambda using AWS_PROXY integration.
  • Deploy the API to a dev stage and test it with curl.
Create the REST API and /weather resource

API Gateway organizes endpoints as a hierarchy: an API contains resources (URL paths), and each resource has methods (GET, POST, etc). You need to create the API itself, find its root resource, then add a child resource for /weather.

  • Create a new REST API and save its ID into a shell variable by running this command:
API_ID=$(awslocal apigateway create-rest-api --name 'weather-api' --query 'id' --output text)
  • Confirm the API was created by echoing the variable:
echo $API_ID

You should see a short alphanumeric string printed. This is your API ID, used in every API Gateway command that follows.

Why save IDs in shell variables?

Every subsequent API Gateway command requires the API ID, resource IDs, etc. Storing them in variables like $API_ID means you can copy-paste the remaining commands directly without hunting through JSON output each time.

  • Get the root resource ID and save it by running:
ROOT_ID=$(awslocal apigateway get-resources --rest-api-id $API_ID --query 'items[0].id' --output text)
  • Create a /weather resource under the root by running:
RESOURCE_ID=$(awslocal apigateway create-resource --rest-api-id $API_ID --parent-id $ROOT_ID --path-part weather --query 'id' --output text)
  • Confirm all three IDs are populated by running:
echo "API: $API_ID | Root: $ROOT_ID | Resource: $RESOURCE_ID"

You should see three non-empty values separated by pipes. If any value is blank, re-run the corresponding command above.

What does each command do?

  • create-rest-api creates the top-level API container with a name you choose.
  • get-resources lists all URL paths. A brand-new API starts with just the root / path.
  • create-resource adds a new path segment under a parent. You added weather under /, giving you /weather.

Seeing an empty variable or error?

  • Make sure LocalStack is still running. Run localstack status to confirm.
  • If $API_ID is empty, the create-rest-api command may have failed silently. Run it again without the variable wrapper to see the full output: awslocal apigateway create-rest-api --name 'weather-api'.

Help me debug API Gateway commands failing in LocalStack.

Wire the /weather resource to your Lambda

A resource on its own does nothing. You need to attach an HTTP method (GET) and tell API Gateway where to forward the request. The AWS_PROXY integration type passes the entire HTTP request straight to Lambda and returns Lambda's response directly to the caller.

  • Add a GET method to the /weather resource by running:
awslocal apigateway put-method \
  --rest-api-id $API_ID \
  --resource-id $RESOURCE_ID \
  --http-method GET \
  --authorization-type "NONE"

You should see JSON output confirming httpMethod is GET and authorizationType is NONE.

  • Connect the GET method to your Lambda function using a proxy integration by running:
awslocal 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:000000000000:function:weather-briefing/invocations

What does this integration URI mean?

The URI follows a specific ARN pattern that API Gateway uses to locate Lambda functions. It includes the region (us-east-1), the LocalStack account ID (000000000000), and your function name (weather-briefing).

The --integration-http-method POST tells API Gateway to call Lambda's invoke API using POST. This is always POST regardless of the client-facing HTTP method.

  • Grant API Gateway permission to invoke your Lambda function by running:
awslocal lambda add-permission \
  --function-name weather-briefing \
  --statement-id apigateway-invoke \
  --action lambda:InvokeFunction \
  --principal apigateway.amazonaws.com

You should see JSON output containing a Statement field confirming the permission was added.

Why is this permission needed?

Lambda uses resource-based policies to control who can invoke a function. Without this permission, API Gateway's request would be rejected even though the integration is configured correctly. This is the same add-permission command you would use in production AWS.

Seeing a ResourceConflict error?

This means a statement with the same ID already exists (you may have run the command twice). This is harmless. The permission is already in place and you can continue.

Help me understand the Lambda ResourceConflict error when adding permissions.

Deploy the API and test it with curl

API Gateway changes are not live until you deploy them to a stage. A stage is a named snapshot of your API configuration (like dev, staging, or prod).

  • Deploy your API to the dev stage by running:
awslocal apigateway create-deployment --rest-api-id $API_ID --stage-name dev

You should see JSON output confirming the deployment was created.

  • Print the full endpoint URL by running:
echo "http://localhost:4566/restapis/$API_ID/dev/_user_request_/weather"

This is the URL pattern LocalStack uses for API Gateway endpoints. In production AWS, you would get a domain like https://<API_ID>.execute-api.<region>.amazonaws.com/<stage>/<path>. The CLI commands you ran are identical in both environments.

  • Before you run the next command, predict what you expect to see. Will it return a JSON weather briefing, or will something go wrong?
  • Test your live API with curl by running:
curl "http://localhost:4566/restapis/$API_ID/dev/_user_request_/weather?city=Tokyo"

You should see a JSON response containing an AI-generated weather briefing for Tokyo, with fields like city, briefing, temperature_c, and conditions. Your Lambda is now a live HTTP API.

What is the LocalStack URL pattern?

LocalStack serves API Gateway endpoints at http://localhost:4566/restapis/<API_ID>/<stage>/_user_request_/<path>. The _user_request_ segment is a LocalStack routing marker. The commands and configuration are identical to production AWS.

Getting an empty response or 500 error?

  • Run echo $API_ID to confirm the variable is still set. If your terminal session restarted, you need to re-run all the commands from the top of this step.
  • If you made any changes after deploying, you must re-deploy. Run the create-deployment command again.
  • Verify your Lambda still works directly by running awslocal lambda invoke --function-name weather-briefing --payload '{"city": "Tokyo"}' output.json && cat output.json.

Help me debug a 500 error from my API Gateway endpoint in LocalStack.

Your Lambda is now a real HTTP API that any client can call. Next up, you will add S3 logging to create an audit trail of every request.

Add S3 Logging and Fix IAM Permissions

Your API returns live AI weather briefings via API Gateway. It works. But in an enterprise architecture review, the first question is: where is your audit trail?

In this step, you'll add S3 logging so every request gets recorded. When you deploy the update, your Lambda will crash with an AccessDenied error. That failure is the lesson. You'll diagnose it, write a least-privilege IAM policy from scratch, and attach it to your Lambda's execution role.

In this step, get ready to:
  • Create an S3 bucket and add logging code to your Lambda function.
  • Invoke the Lambda and observe the IAM AccessDenied failure.
  • Write a least-privilege IAM policy and fix the permissions.
Create the S3 bucket and add logging to your Lambda

Every weather briefing your API generates should be saved as a JSON log file in S3. First, you need a bucket to hold them.

  • Create the S3 bucket by running this command:
awslocal s3 mb s3://weather-briefing-logs
  • Confirm the bucket exists by running:
awslocal s3 ls

You should see weather-briefing-logs listed in the output with a creation date.

Don't see the bucket?

Make sure LocalStack is still running. Run localstack status to check.

help me troubleshoot why my S3 bucket wasn't created in LocalStack

Now you need to update your Lambda code to write a log entry to this bucket after every briefing. Open handler.py in your editor.

  • Find this comment near the bottom of your lambda_handler function:
    # Return response (S3 logging not yet added)
    body = json.dumps({
  • Replace that comment line with the following two lines, so the code now reads:
    # Log to S3
    log_to_s3(city, briefing)

    # Return response
    body = json.dumps({

What does this change do?

You're inserting a function call that saves the city and briefing to S3 before returning the response. The log_to_s3 function doesn't exist yet. You'll add it next.

Now add the log_to_s3 function at the very bottom of handler.py, after the closing return statement of lambda_handler.

  • Add this function at the end of the file:
def log_to_s3(city, briefing):
    try:
        import boto3
        endpoint_url = os.environ.get("AWS_ENDPOINT_URL")
        if endpoint_url:
            s3 = boto3.client("s3", endpoint_url=endpoint_url)
        else:
            s3 = boto3.client("s3")

        timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
        key = f"{city}/{timestamp}.json"
        log_entry = json.dumps({
            "city": city,
            "briefing": briefing,
            "timestamp": timestamp
        })
        s3.put_object(
            Bucket="weather-briefing-logs",
            Key=key,
            Body=log_entry.encode("utf-8"),
            ContentType="application/json"
        )
    except Exception as e:
        print(f"S3 logging failed: {e}")
        raise

What does this code do?

  • It creates a boto3 S3 client, using the AWS_ENDPOINT_URL environment variable so the Lambda talks to LocalStack's S3 (not real AWS).
  • It builds a timestamped key like London/2026-07-13T10-30-45.json so logs are organized by city.
  • If the S3 write fails, the exception is re-raised so you can see the error in the Lambda response.
  • Save handler.py.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before continuing.

ⓧ I'd like to double check the full code

Your handler.py is now over 30 lines, so confirm each section is present in order:

  • Imports at the top (json, os, urllib.request, urllib.parse, datetime).
  • The lambda_handler function with city extraction, OpenWeather call, Groq call, and the new log_to_s3(city, briefing) call before the return.
  • The log_to_s3 function at the very bottom of the file.

Now repackage and redeploy the Lambda with the updated code.

  • Repackage and update the Lambda function by running these commands:
zip function.zip handler.py
awslocal lambda update-function-code --function-name weather-briefing --zip-file fileb://function.zip

What does this command do?

The zip command repackages your updated handler into a deployment archive. The update-function-code command uploads the new zip to LocalStack, replacing the Lambda's code in place.

You don't need to include boto3 in the zip because it's already available in the Lambda runtime.

You should see a JSON response confirming the function was updated, including the new CodeSha256 value.

Invoke the Lambda and observe the AccessDenied error

Your Lambda now has code that tries to write to S3. But does it have permission to do so? The execution role lambda-execution-role only has AWSLambdaBasicExecutionRole attached. That policy covers CloudWatch Logs, not S3.

Before you run this, what do you think will happen when the Lambda tries to call s3.put_object?

  • Invoke the Lambda by running:
awslocal lambda invoke --function-name weather-briefing --payload '{"city": "London"}' output.json && cat output.json

The CLI output shows "FunctionError": "Unhandled" and the output.json contains an error mentioning that S3 access was denied.

This error is expected

Your Lambda tried to write to S3 but its execution role has no S3 permissions. In production AWS, this is the exact same error you'd see. IAM denies any action not explicitly allowed.

This is the core lesson of least-privilege access: every AWS service starts with zero permissions, and you grant only what's needed.

Write a least-privilege IAM policy and fix the permissions

You've diagnosed the problem: the Lambda's role needs permission to write to S3. But you're not going to grant broad S3 access. Enterprise IAM follows the principle of least privilege. You'll grant only the exact action (s3:PutObject) on the exact resource (your specific bucket).

  • Create a file called s3-policy.json with the following IAM policy document:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::weather-briefing-logs/*"
        }
    ]
}

What does this policy do?

  • It allows only the s3:PutObject action. Your Lambda can write objects but cannot read, list, or delete them.
  • The resource is scoped to arn:aws:s3:::weather-briefing-logs/* only. It cannot write to any other bucket.
  • This is least-privilege in practice: one action, one resource, nothing more.
  • Create the IAM policy in LocalStack by running:
awslocal iam create-policy --policy-name s3-put-logs-policy --policy-document file://s3-policy.json

You should see a JSON response with the policy ARN arn:aws:iam::000000000000:policy/s3-put-logs-policy.

  • Attach the policy to your Lambda's execution role by running:
awslocal iam attach-role-policy --role-name lambda-execution-role --policy-arn arn:aws:iam::000000000000:policy/s3-put-logs-policy

What just happened?

You attached your custom policy to lambda-execution-role. The role now has two policies: the original AWSLambdaBasicExecutionRole for CloudWatch Logs, plus s3-put-logs-policy for writing to your specific S3 bucket.

Now the moment of truth. Do you think the Lambda will succeed this time?

  • Invoke the Lambda again by running:
awslocal lambda invoke --function-name weather-briefing --payload '{"city": "London"}' output.json && cat output.json

This time you should see a successful JSON response with a city, briefing, temperature_c, and conditions field. No more errors.

  • Verify the log file was written to S3 by running:
awslocal s3 ls s3://weather-briefing-logs/ --recursive

You should see a file path like London/2026-07-13T10-30-45.json with today's date and time.

Still seeing an error on invoke?

  • Make sure the policy ARN in your attach command matches exactly: arn:aws:iam::000000000000:policy/s3-put-logs-policy.
  • Confirm the bucket name in your log_to_s3 function matches weather-briefing-logs exactly.
  • Check that you ran zip function.zip handler.py and update-function-code after saving your file changes.

help me debug why my Lambda still can't write to S3 after attaching the IAM policy

Why this matters in an SA interview

You just lived the exact debugging loop that production AWS developers go through daily. The story you can tell in an interview is: I deployed a Lambda, it failed at runtime because IAM denied the S3 write, I diagnosed the error, and I wrote a policy granting only s3:PutObject on the specific bucket ARN.

That story demonstrates least-privilege thinking, which is a core pillar of the AWS Well-Architected Framework's Security pillar.

You now have a fully working serverless architecture: Lambda processes requests, API Gateway exposes the HTTP endpoint, S3 stores the audit trail, and IAM enforces least-privilege boundaries. Next up, you'll lock down the API itself with authentication in the Secret Mission.

Secret mission

Add API Key Authentication to Your Endpoint

Your API is currently open to the world. Anyone who discovers the URL can call it without restriction. In this secret mission, you'll add API Gateway's built-in API key authentication so only authorized clients can access your weather briefing endpoint.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely inside a LocalStack Docker container. There are no ongoing cloud costs. All AWS resources (Lambda, API Gateway, S3, IAM) exist only in memory and disappear when the container stops.

Resources you used:

  • LocalStack Docker container (contains your Lambda function, API Gateway, S3 bucket, IAM roles, and API keys).
  • Local project files (handler.py, function.zip, and any policy JSON files).
  • AWS CLI v2 (installed in WSL2).
  • LocalStack CLI and awslocal wrapper (installed via pip).

Keep everything running

No action needed. Choose this if you are still actively experimenting with your serverless architecture or want to add more features.

  • LocalStack stays running and your Lambda, API Gateway, S3 bucket, and IAM configuration remain accessible.
  • AWS CLI v2, LocalStack CLI, and awslocal remain installed for future AWS projects.
  • Your local project files stay on disk for reference.

Pause - I'll come back to this later

Shut down the LocalStack container to free up memory and Docker resources. Your local files and installed tools stay in place so you can re-run the deployment commands later.

  • Stop LocalStack by running:
localstack stop
  • All AWS resources inside the container (Lambda, API Gateway, S3, IAM) disappear automatically. LocalStack does not persist data across restarts.
  • To resume later, run localstack start -d and re-run your deployment commands from Steps 2-4.

Delete - I don't want to use this again

Remove all project resources, reclaim disk space, and uninstall the tools you installed during this project.

  • Stop LocalStack and remove all AWS resources by running:
localstack stop
  • Reclaim Docker disk space used by the LocalStack image by running:
docker system prune
  • Delete your local project files by running:
rm handler.py function.zip
  • Uninstall the awslocal wrapper by running:
pip uninstall awscli-local
  • Remove the LocalStack CLI binary by running:
sudo rm /usr/local/bin/localstack
  • Uninstall AWS CLI v2 by running:
sudo rm /usr/local/bin/aws
sudo rm /usr/local/bin/aws_completer
sudo rm -rf /usr/local/aws-cli
  • Unset the environment variables you exported by running:
unset AWS_ACCESS_KEY_ID
unset AWS_SECRET_ACCESS_KEY
unset AWS_DEFAULT_REGION

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade serverless AI weather API on AWS infrastructure, deployed entirely through the CLI.

You've learned how to:

  • Deployed a working serverless AI API using Lambda + API Gateway + S3, entirely via the AWS CLI.
  • Diagnosed and fixed an IAM AccessDenied error by writing a least-privilege policy from scratch.
  • Built an architecture you can whiteboard in an SA interview with a real story about each component and why it exists.
  • Secret Mission: Secured your API with API Gateway's native API key authentication, understanding the key + usage plan + stage model.

Ready to quiz yourself?