Skip to content
Cloud & Infra Advanced Tutorial

Build and Deploy a Serverless REST API with AWS Lambda, API Gateway, and SAM CLI

Scaffold, run locally, and ship a typed Python REST API to AWS in under 30 minutes using SAM templates and a single deploy command.

Emeka Okafor
Emeka Okafor
Security Editor · Jun 25, 2026 · 8 min read
Build and Deploy a Serverless REST API with AWS Lambda, API Gateway, and SAM CLI

What You'll Build

A typed Python REST API with two GET endpoints, running on AWS Lambda behind an API Gateway stage named v1, defined in a SAM template and testable locally before a one-command deploy.

Prerequisites

  • AWS account with IAM permissions covering CloudFormation, Lambda, API Gateway, IAM role creation, and S3
  • AWS CLI v2 configured with credentials (aws configure or an SSO profile)
  • SAM CLI 1.100 or later
  • Docker Desktop (required by sam local)
  • Python 3.12 available locally is helpful but not strictly required; Lambda runs its own runtime

Install SAM CLI:

macOS (Homebrew):

brew install aws/tap/aws-sam-cli

Linux x86_64 (official installer):

curl -Lo aws-sam-cli.zip https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip
unzip aws-sam-cli.zip -d sam-installation
sudo ./sam-installation/install

Verify:

sam --version
# SAM CLI, version 1.x.x

1. Scaffold the Project

Skip sam init and its boilerplate. Create the structure manually:

mkdir items-api && cd items-api
mkdir src
touch template.yaml src/app.py src/requirements.txt
items-api/
├── template.yaml
└── src/
    ├── app.py
    └── requirements.txt

2. Write the SAM Template

The ItemsApi resource pins the stage name to v1, which becomes part of the invoke URL. Referencing it explicitly with RestApiId: !Ref ItemsApi in each event ties the function to your named API rather than the implicit one SAM creates when you omit that field. That distinction matters once you need to attach auth, CORS, or request validators.

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Items REST API

Globals:
  Function:
    Runtime: python3.12
    Timeout: 10
    MemorySize: 128

Resources:
  ItemsApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: v1
      Description: Items API v1

  ItemsFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.handler
      CodeUri: src/
      Architectures:
        - x86_64
      Description: Get items collection and single item by ID
      Events:
        GetItems:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items
            Method: GET
        GetItem:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items/{id}
            Method: GET

Outputs:
  ApiUrl:
    Description: Base API URL
    Value: !Sub "https://${ItemsApi}.execute-api.${AWS::Region}.amazonaws.com/v1"

3. Write the Python Handler

API Gateway's Lambda proxy integration requires the handler to return a dict with statusCode (integer), headers, and body (a JSON-encoded string). Using TypedDict makes that contract explicit and catches shape mistakes at type-check time rather than at runtime.

import json
from typing import Any, TypedDict


class LambdaResponse(TypedDict):
    statusCode: int
    headers: dict[str, str]
    body: str


_JSON_HEADERS = {"Content-Type": "application/json"}

_ITEMS: dict[str, dict[str, str]] = {
    "1": {"id": "1", "name": "Widget"},
    "2": {"id": "2", "name": "Gadget"},
}


def handler(event: dict[str, Any], context: Any) -> LambdaResponse:
    path_params: dict[str, str] = event.get("pathParameters") or {}
    item_id = path_params.get("id")

    if item_id:
        item = _ITEMS.get(item_id)
        if not item:
            return {
                "statusCode": 404,
                "headers": _JSON_HEADERS,
                "body": json.dumps({"error": "not found"}),
            }
        return {
            "statusCode": 200,
            "headers": _JSON_HEADERS,
            "body": json.dumps(item),
        }

    return {
        "statusCode": 200,
        "headers": _JSON_HEADERS,
        "body": json.dumps({"items": list(_ITEMS.values())}),
    }

Leave src/requirements.txt empty. No third-party dependencies here.

4. Build and Run Locally

sam build installs dependencies from requirements.txt and stages your code into .aws-sam/build/. Run it before any sam local command.

sam build

For a quick smoke test, generate a minimal API Gateway proxy event, save it to a file, and invoke the function:

sam local generate-event apigateway aws-proxy --method GET > event.json
sam local invoke ItemsFunction --event event.json

The first run pulls the public.ecr.aws/lambda/python:3.12 Docker image, which takes a minute. You should see output ending with:

{"statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": "{\"items\": [{\"id\": \"1\", \"name\": \"Widget\"}, {\"id\": \"2\", \"name\": \"Gadget\"}]}"}

To run the full API Gateway emulation:

sam local start-api --port 3000

In a second terminal:

curl http://localhost:3000/items
curl http://localhost:3000/items/1
curl http://localhost:3000/items/99   # expect 404

Note that sam local start-api serves without the stage prefix, so the path is /items, not /v1/items. Code changes require a fresh sam build before the next request picks them up.

5. Deploy to AWS

sam deploy --guided

Fill in the prompts:

Prompt Recommended answer
Stack Name items-api
AWS Region your target region
Confirm changes before deploy Y
Allow SAM CLI IAM role creation Y
Disable rollback N
GetItems may not have authorization defined, Is this okay? Y
GetItem may not have authorization defined, Is this okay? Y
Save arguments to samconfig.toml Y

The two authorization prompts appear because neither endpoint has an Auth property set. The default answer is N, which aborts the deployment, so you must explicitly answer Y for both. These are open read endpoints and that's intentional here. If you later add auth (a Cognito authorizer, for example), these prompts disappear.

SAM packages build artifacts into a managed S3 bucket, creates a CloudFormation changeset, and streams stack events to the terminal. The final output includes your Outputs block. After this first run, samconfig.toml holds the answers and future deploys need only sam deploy.

Verify It Works

Copy the ApiUrl from the Outputs block and run:

BASE="https://<api-id>.execute-api.<region>.amazonaws.com/v1"

curl -i "${BASE}/items"
curl -i "${BASE}/items/2"
curl -i "${BASE}/items/99"

Expect 200 with the items array, 200 with a single item object, and 404 with {"error": "not found"}. The -i flag lets you confirm Content-Type: application/json is present in response headers.

To tail Lambda logs directly from the SAM CLI:

sam logs --stack-name items-api --tail

Troubleshooting

sam local fails with "Running AWS SAM projects locally requires Docker." Docker Desktop must be running before any sam local command. On Linux, add your user to the docker group (sudo usermod -aG docker $USER) and open a fresh shell session.

sam deploy exits with a CAPABILITY_IAM error. SAM creates an IAM execution role for the Lambda function. Your credentials need iam:CreateRole and iam:AttachRolePolicy. During --guided, answer Y to "Allow SAM CLI IAM role creation" to pass the capability flag automatically.

Lambda returns 502 Bad Gateway after a successful deploy. Almost always means body is a dict instead of a string. API Gateway's proxy integration requires body to be a JSON-encoded string. Verify every return path calls json.dumps(). Check CloudWatch Logs for the raw exception.

ModuleNotFoundError in Lambda but works locally. Confirm CodeUri in the template points to the directory that contains requirements.txt. If you added C-extension packages later and you're developing on macOS Apple Silicon, rebuild with sam build --use-container to compile against the Lambda x86_64 environment.

Next Steps

  • Add a DynamoDB table with AWS::Serverless::SimpleTable and replace the in-memory dict with boto3 resource calls.
  • Configure request validation on the AWS::Serverless::Api resource using Models and RequestModels to reject malformed input before Lambda runs.
  • Run sam pipeline to generate a GitHub Actions or CodePipeline config for automated deploys on merge.
  • Pull in aws-lambda-powertools for structured JSON logging, X-Ray tracing, and strongly typed event models including APIGatewayProxyEvent with full attribute hints.
Emeka Okafor
Written by
Emeka Okafor · Security Editor

Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading