AWS SAM: Build and Deploy Serverless Applications the Right Way (2026)
AWS SAM (Serverless Application Model) is the official AWS framework for defining, building, testing, and deploying serverless applications. It extends CloudFormation with higher-level resource types — a single AWS::Serverless::Function replaces dozens of CloudFormation resources. You get local testing with Docker, one-command deployments, an accelerated inner loop with sam sync, and deep integration with the AWS ecosystem. This guide covers everything from project initialization to production CI/CD pipelines with real YAML templates and Python Lambda handlers throughout.
Table of Contents
- SAM vs CDK vs Serverless Framework vs Terraform
- SAM Template Anatomy
- sam init and Project Structure
- Local Development with sam local
- Building and Packaging with sam build
- Deploying with sam deploy
- Real-World SAM Template: REST API + DynamoDB + SQS + SNS
- CI/CD Pipeline with GitHub Actions
- SAM Accelerate: sam sync Watch Mode
- Testing Strategies: Unit, Local, Integration
SAM vs CDK vs Serverless Framework vs Terraform
Choosing the right serverless deployment tool shapes your entire development workflow. Each tool has a different abstraction level, community, and trade-off profile. The decision table below cuts through the noise.
| Tool | Language | Abstraction | AWS Integration | Local Dev | Multi-Cloud |
|---|---|---|---|---|---|
| AWS SAM | YAML/JSON | High (serverless-specific) | Native, first-class | Excellent (sam local) | No (AWS only) |
| AWS CDK | Python/TypeScript/Java | Very high (constructs) | Native, comprehensive | Good (CDK Watch) | No (AWS only) |
| Serverless Framework | YAML | High | Good (plugin-based) | Good (serverless-offline) | Yes (limited) |
| Terraform | HCL | Low (resource-level) | Good | Poor | Yes (excellent) |
When SAM wins: You're building AWS-only serverless workloads and want the simplest possible developer experience backed by official AWS support. SAM is the right choice when your team already knows CloudFormation (SAM templates are a superset), when you need tight local emulation of Lambda and API Gateway, or when you want zero-config deployments with sam deploy --guided. SAM also wins for regulated environments that require pure CloudFormation under the hood — everything SAM does is auditable as CloudFormation stacks.
When to choose CDK instead: Your application spans many AWS services beyond serverless (ECS, RDS, VPC, ALB) and you want type-safe infrastructure code with IDE completion. CDK's construct library is richer and better maintained for non-serverless resources. CDK also wins when your team prefers general-purpose languages over YAML.
When to choose Serverless Framework: You're targeting multiple cloud providers (AWS + Azure + GCP), or you rely heavily on the Serverless Framework plugin ecosystem (offline, prune, warmup). Note that Serverless Framework v4 requires a paid subscription for teams, which has pushed many AWS-only teams back to SAM.
When Terraform is correct: Your platform team manages all cloud infrastructure in Terraform and you need Lambda resources to live in the same state files as VPCs, RDS clusters, and IAM policies. Terraform's remote state, workspace isolation, and plan/apply workflow are unmatched for large organizations.
SAM Template Anatomy
A SAM template is a CloudFormation template with one extra line at the top: Transform: AWS::Serverless-2016-10-31. This tells CloudFormation to expand SAM's shorthand resource types into their full CloudFormation equivalents before deployment. The SAM transformer is an AWS-managed CloudFormation macro — no extra tooling required at deploy time.
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
Order processing service — SAM template anatomy demo
# Globals: apply to ALL functions unless overridden
Globals:
Function:
Runtime: python3.12
MemorySize: 512
Timeout: 30
Tracing: Active # X-Ray tracing on all functions
Environment:
Variables:
LOG_LEVEL: INFO
POWERTOOLS_SERVICE_NAME: order-service
Layers:
- !Sub 'arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPythonV3-python312-x86_64:7'
Api:
Cors:
AllowOrigin: "'https://techoral.com'"
AllowHeaders: "'Content-Type,Authorization'"
AllowMethods: "'GET,POST,OPTIONS'"
Parameters:
Environment:
Type: String
Default: dev
AllowedValues: [dev, staging, prod]
Description: Deployment environment
Resources:
# AWS::Serverless::Function — the core SAM resource
# Expands to: Lambda function + execution role + log group + event source mappings
OrderProcessorFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub 'order-processor-${Environment}'
Handler: src/order_processor.handler
CodeUri: ./
Description: Processes incoming order events from API and SQS
ReservedConcurrentExecutions: 100
DeadLetterQueue:
Type: SQS
TargetArn: !GetAtt OrderDLQ.Arn
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref OrdersTable
- SQSSendMessagePolicy:
QueueUrl: !Ref OrderDLQ
- SNSPublishMessagePolicy:
TopicName: !GetAtt OrderNotificationTopic.TopicName
Events:
# REST API trigger
PostOrder:
Type: Api
Properties:
RestApiId: !Ref OrderApi
Path: /orders
Method: POST
# SQS trigger (async batch processing)
ProcessQueue:
Type: SQS
Properties:
Queue: !GetAtt OrderQueue.Arn
BatchSize: 10
FunctionResponseTypes:
- ReportBatchItemFailures
# AWS::Serverless::Api — API Gateway REST API with SAM shorthand
OrderApi:
Type: AWS::Serverless::Api
Properties:
StageName: !Ref Environment
Auth:
DefaultAuthorizer: CognitoAuthorizer
Authorizers:
CognitoAuthorizer:
UserPoolArn: !GetAtt UserPool.Arn
AccessLogSetting:
DestinationArn: !GetAtt ApiAccessLogGroup.Arn
MethodSettings:
- HttpMethod: '*'
ResourcePath: '/*'
ThrottlingRateLimit: 1000
ThrottlingBurstLimit: 500
# AWS::Serverless::SimpleTable — DynamoDB table with SAM shorthand
OrdersTable:
Type: AWS::Serverless::SimpleTable
Properties:
PrimaryKey:
Name: orderId
Type: String
ProvisionedThroughput:
ReadCapacityUnits: 5
WriteCapacityUnits: 5
SSESpecification:
SSEEnabled: true
# AWS::Serverless::StateMachine — Step Functions with SAM shorthand
OrderWorkflow:
Type: AWS::Serverless::StateMachine
Properties:
DefinitionUri: statemachine/order_workflow.asl.json
DefinitionSubstitutions:
OrderProcessorFunctionArn: !GetAtt OrderProcessorFunction.Arn
Policies:
- LambdaInvokePolicy:
FunctionName: !Ref OrderProcessorFunction
Outputs:
ApiEndpoint:
Description: API Gateway endpoint
Value: !Sub 'https://${OrderApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}'
OrdersTableName:
Description: DynamoDB table name
Value: !Ref OrdersTable
Export:
Name: !Sub '${AWS::StackName}-OrdersTable'
The four primary SAM resource types cover 90% of serverless use cases: AWS::Serverless::Function (Lambda + role + events), AWS::Serverless::Api (API Gateway REST API), AWS::Serverless::SimpleTable (DynamoDB table), and AWS::Serverless::StateMachine (Step Functions). The Globals section eliminates repetition — define runtime, memory, timeout, and shared layers once, override per-function only when needed. SAM also ships with policy templates (like DynamoDBCrudPolicy and SQSSendMessagePolicy) that expand to properly scoped IAM statements, so you're not writing raw IAM JSON for every function.
AWS::Serverless::SimpleTable is a convenience shorthand for single-key DynamoDB tables. For tables with GSIs, LSIs, or complex key schemas, use the full AWS::DynamoDB::Table CloudFormation resource alongside your SAM template — they mix freely.sam init and Project Structure
The SAM CLI bootstraps a fully functional project with a single interactive command. Install the CLI first, then initialize a new project:
# Install SAM CLI (macOS)
brew tap aws/tap && brew install aws-sam-cli
# Install SAM CLI (Linux)
pip install aws-sam-cli
# Install SAM CLI (Windows)
winget install Amazon.SAM-CLI
# Verify installation
sam --version
# SAM CLI, version 1.120.0
# Initialize a new project interactively
sam init
# Or non-interactively for CI/scripting
sam init \
--runtime python3.12 \
--name order-service \
--app-template hello-world \
--no-interactive
The interactive sam init prompts for: (1) template source (AWS Quick Start or custom), (2) runtime (Python, Node.js, Java, .NET, Ruby, Go via custom runtime), (3) package type (Zip or Image), (4) application template (hello-world, event-driven, multi-step, etc.), and (5) project name. After initialization, the project layout looks like this:
order-service/
├── template.yaml # SAM template — your infrastructure definition
├── samconfig.toml # Deployment configuration (generated by sam deploy --guided)
├── events/
│ ├── api_event.json # Sample API Gateway event for local testing
│ └── sqs_event.json # Sample SQS event
├── src/
│ ├── order_processor/
│ │ ├── __init__.py
│ │ ├── app.py # Lambda handler
│ │ └── requirements.txt # Function-specific dependencies
│ └── order_notifier/
│ ├── __init__.py
│ ├── app.py
│ └── requirements.txt
├── tests/
│ ├── unit/
│ │ └── test_order_processor.py
│ └── integration/
│ └── test_api.py
└── .aws-sam/ # Build artifacts (git-ignored)
└── build/
The hello-world starter template ships with a Python handler, sample events for local testing, and a basic unit test. For production projects, the multi-step-pipeline template is a better starting point — it includes GitHub Actions workflow files, environment-specific samconfig, and integration tests. The CodeUri in each function resource points to the source directory; SAM resolves dependencies using the runtime-specific package manager (pip for Python, npm for Node, Maven/Gradle for Java) during sam build.
sam init creates a Maven or Gradle project inside the function directory. SAM invokes the build tool during sam build. Use the java21 runtime and enable SnapStart in Globals to reduce cold starts from 5–10 seconds to under 1 second.Local Development with sam local
SAM's killer feature over raw CloudFormation is local emulation. The sam local commands spin up Docker containers that replicate the Lambda execution environment — same base OS (Amazon Linux 2023), same runtime, same memory limits. You test your functions before deploying anything to AWS, catching integration bugs at the speed of local iteration.
# One-shot invocation with a sample event file
sam local invoke OrderProcessorFunction \
--event events/api_event.json \
--env-vars env.json
# env.json overrides environment variables for local testing
# {
# "OrderProcessorFunction": {
# "TABLE_NAME": "orders-local",
# "LOG_LEVEL": "DEBUG"
# }
# }
# Start a local API Gateway server (hot-reload on code changes)
sam local start-api \
--port 3000 \
--env-vars env.json \
--warm-containers EAGER
# Test the local API
curl -X POST http://localhost:3000/orders \
-H 'Content-Type: application/json' \
-d '{"item": "laptop", "quantity": 1}'
# Start a local Lambda endpoint (for direct invocation, not HTTP)
sam local start-lambda --port 3001
# Invoke via AWS CLI against the local endpoint
aws lambda invoke \
--function-name OrderProcessorFunction \
--endpoint-url http://127.0.0.1:3001 \
--payload file://events/sqs_event.json \
output.json
The --warm-containers EAGER flag pre-warms all function containers when the local API starts, reducing first-invocation latency. The default is LAZY, which initializes containers on first invocation. For teams doing rapid iteration, EAGER mode gives a more realistic warm-start experience. The --watch flag (introduced with SAM Accelerate) adds hot-reload: SAM monitors your source files and automatically re-builds and re-deploys changed functions without restarting the local server.
For local DynamoDB and SQS, use DynamoDB Local (Docker) and LocalStack:
# Start DynamoDB Local
docker run -d -p 8000:8000 amazon/dynamodb-local
# Create a local table matching your SAM template
aws dynamodb create-table \
--table-name orders-local \
--attribute-definitions AttributeName=orderId,AttributeType=S \
--key-schema AttributeName=orderId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--endpoint-url http://localhost:8000
# Connect local Lambda to local DynamoDB via env var override
# env.json:
# { "OrderProcessorFunction": { "AWS_ENDPOINT_URL_DYNAMODB": "http://host.docker.internal:8000" } }
sam local requires Docker Desktop running on your machine. The SAM CLI pulls AWS's official Lambda runtime images (e.g., public.ecr.aws/lambda/python:3.12) on first use. Images are cached locally, so subsequent invocations start in under a second.Building and Packaging with sam build
sam build resolves dependencies for each function, packages the code, and writes build artifacts to .aws-sam/build/. The build output is a CloudFormation-ready directory with self-contained function packages — no zip manipulation needed. SAM uses the runtime's package manager: pip for Python, npm for Node.js, Maven or Gradle for Java.
# Standard build (uses your local runtime/pip)
sam build
# Build inside a Docker container matching the Lambda environment
# Eliminates "works on my machine" issues with native extensions (numpy, psycopg2, etc.)
sam build --use-container
# Build a specific function only
sam build OrderProcessorFunction
# Build with parallel execution (speeds up multi-function stacks)
sam build --parallel
# Combine: container build with caching (faster repeat builds)
sam build --use-container --cached
# Pass extra parameters to the build
sam build \
--use-container \
--container-env-var PIP_INDEX_URL=https://pypi.example.com/simple/ \
--build-image python:3.12-slim
For dependency layers, SAM can build a shared layer during sam build using the AWS::Serverless::LayerVersion resource type with a ContentUri pointing to a requirements file:
Resources:
# Shared dependency layer — built by sam build
CommonDependenciesLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: common-dependencies
ContentUri: layers/common/
CompatibleRuntimes:
- python3.12
RetentionPolicy: Retain
Metadata:
BuildMethod: python3.12 # Tells SAM how to build this layer
OrderProcessorFunction:
Type: AWS::Serverless::Function
Properties:
Handler: app.handler
CodeUri: src/order_processor/
Layers:
- !Ref CommonDependenciesLayer
# layers/common/requirements.txt
boto3==1.34.0
aws-lambda-powertools==2.43.0
pydantic==2.7.0
requests==2.32.0
With Metadata.BuildMethod set, SAM installs the layer's requirements into the correct directory structure (python/lib/python3.12/site-packages/) automatically. The --cached flag stores build results keyed to the source hash — unchanged functions skip rebuilding, cutting build times by 70–80% for large stacks during iterative development.
Deploying with sam deploy
sam deploy packages your build artifacts to S3, then creates or updates the CloudFormation stack. The --guided flag launches an interactive wizard on first deploy and writes your choices to samconfig.toml — subsequent deploys just run sam deploy with no arguments.
# First deploy: interactive guided wizard
sam deploy --guided
# The wizard asks:
# Stack Name: order-service-dev
# AWS Region: us-east-1
# Parameter Environment: dev
# Confirm changes before deploy: y
# Allow SAM CLI IAM role creation: y
# Disable rollback: n
# Save arguments to samconfig.toml: y
# Subsequent deploys (reads samconfig.toml)
sam deploy
# Deploy to a different environment
sam deploy --config-env staging
# Override parameters on the command line
sam deploy \
--parameter-overrides "Environment=prod" \
--confirm-changeset
# Watch CloudFormation events during deploy
sam deploy --resolve-s3 --watch
The generated samconfig.toml stores all deploy configuration in TOML format with named environment sections:
version = 0.1
[default.global.parameters]
stack_name = "order-service-dev"
region = "us-east-1"
confirm_changeset = true
capabilities = "CAPABILITY_IAM CAPABILITY_AUTO_EXPAND"
resolve_s3 = true
[default.deploy.parameters]
parameter_overrides = "Environment=dev"
[staging.deploy.parameters]
stack_name = "order-service-staging"
parameter_overrides = "Environment=staging"
confirm_changeset = false
[prod.deploy.parameters]
stack_name = "order-service-prod"
parameter_overrides = "Environment=prod"
confirm_changeset = true
signing_profiles = "OrderProcessorFunction=order-service-signer"
The capabilities field is critical: CAPABILITY_IAM is required when SAM creates IAM roles (it always does — every Lambda function needs an execution role). CAPABILITY_AUTO_EXPAND is required because SAM uses CloudFormation macros (the Transform). Without these flags, the deploy will fail with an InsufficientCapabilitiesException. The resolve_s3 flag tells SAM to auto-create a managed S3 bucket for deployment artifacts — no manual bucket creation needed.
confirm_changeset = true, SAM shows a diff of what CloudFormation will create, modify, or delete before executing — like terraform plan. Always enable this for production deployments to catch unintended resource replacements (especially DynamoDB tables and S3 buckets, which are destroyed and re-created on certain property changes).Real-World SAM Template: REST API + Lambda + DynamoDB + SQS + SNS
The following is a production-grade SAM template for an order processing service. It wires together an API Gateway REST API, two Lambda functions, a DynamoDB table, an SQS queue for async processing, an SNS topic for notifications, and a dead-letter queue — all with least-privilege IAM policies.
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Order processing service — production SAM template
Parameters:
Environment:
Type: String
Default: dev
AllowedValues: [dev, staging, prod]
Globals:
Function:
Runtime: python3.12
MemorySize: 512
Timeout: 30
Tracing: Active
Environment:
Variables:
ENVIRONMENT: !Ref Environment
POWERTOOLS_SERVICE_NAME: order-service
LOG_LEVEL: INFO
Resources:
# ── API Layer ──────────────────────────────────────────────────
OrderApi:
Type: AWS::Serverless::Api
Properties:
StageName: !Ref Environment
TracingEnabled: true
AccessLogSetting:
DestinationArn: !GetAtt ApiLogGroup.Arn
ApiLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/apigateway/order-service-${Environment}'
RetentionInDays: 30
# ── Lambda Functions ───────────────────────────────────────────
CreateOrderFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub 'create-order-${Environment}'
Handler: src/create_order.handler
CodeUri: ./
Description: Validates and enqueues new orders
Policies:
- DynamoDBWritePolicy:
TableName: !Ref OrdersTable
- SQSSendMessagePolicy:
QueueUrl: !Ref OrderProcessingQueue
Events:
CreateOrder:
Type: Api
Properties:
RestApiId: !Ref OrderApi
Path: /orders
Method: POST
DeadLetterQueue:
Type: SQS
TargetArn: !GetAtt OrderDLQ.Arn
ProcessOrderFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub 'process-order-${Environment}'
Handler: src/process_order.handler
CodeUri: ./
Description: Processes orders from SQS and publishes completion events
ReservedConcurrentExecutions: 50
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref OrdersTable
- SNSPublishMessagePolicy:
TopicName: !GetAtt OrderNotificationTopic.TopicName
- SQSPollerPolicy:
QueueUrl: !Ref OrderProcessingQueue
Events:
SQSEvent:
Type: SQS
Properties:
Queue: !GetAtt OrderProcessingQueue.Arn
BatchSize: 10
MaximumBatchingWindowInSeconds: 5
FunctionResponseTypes:
- ReportBatchItemFailures
# ── Storage & Messaging ────────────────────────────────────────
OrdersTable:
Type: AWS::DynamoDB::Table
DeletionPolicy: Retain
Properties:
TableName: !Sub 'orders-${Environment}'
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
SSESpecification:
SSEEnabled: true
AttributeDefinitions:
- AttributeName: orderId
AttributeType: S
- AttributeName: customerId
AttributeType: S
- AttributeName: createdAt
AttributeType: S
KeySchema:
- AttributeName: orderId
KeyType: HASH
GlobalSecondaryIndexes:
- IndexName: CustomerOrdersIndex
KeySchema:
- AttributeName: customerId
KeyType: HASH
- AttributeName: createdAt
KeyType: RANGE
Projection:
ProjectionType: ALL
StreamSpecification:
StreamViewType: NEW_AND_OLD_IMAGES
OrderProcessingQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub 'order-processing-${Environment}'
VisibilityTimeout: 180 # 6x Lambda timeout
MessageRetentionPeriod: 86400
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrderDLQ.Arn
maxReceiveCount: 3
OrderDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub 'order-dlq-${Environment}'
MessageRetentionPeriod: 1209600 # 14 days
OrderNotificationTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: !Sub 'order-notifications-${Environment}'
Subscription:
- Protocol: sqs
Endpoint: !GetAtt OrderDLQ.Arn # Alert on DLQ via SNS filter
Outputs:
ApiEndpoint:
Value: !Sub 'https://${OrderApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}'
OrdersTableName:
Value: !Ref OrdersTable
Export:
Name: !Sub '${AWS::StackName}-OrdersTable'
ProcessingQueueUrl:
Value: !Ref OrderProcessingQueue
And the corresponding Lambda handlers:
# src/create_order.py
import json
import os
import uuid
from datetime import datetime, timezone
import boto3
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
from aws_lambda_powertools.utilities.typing import LambdaContext
logger = Logger()
tracer = Tracer()
app = APIGatewayRestResolver()
dynamodb = boto3.resource('dynamodb')
sqs = boto3.client('sqs')
table = dynamodb.Table(os.environ['TABLE_NAME'])
queue_url = os.environ['QUEUE_URL']
@app.post("/orders")
@tracer.capture_method
def create_order():
body = app.current_event.json_body
order_id = str(uuid.uuid4())
created_at = datetime.now(timezone.utc).isoformat()
order = {
'orderId': order_id,
'customerId': body['customerId'],
'items': body['items'],
'status': 'PENDING',
'createdAt': created_at,
'totalAmount': sum(i['price'] * i['quantity'] for i in body['items'])
}
# Write to DynamoDB
table.put_item(Item=order)
# Enqueue for async processing
sqs.send_message(
QueueUrl=queue_url,
MessageBody=json.dumps({'orderId': order_id}),
MessageAttributes={
'eventType': {'DataType': 'String', 'StringValue': 'OrderCreated'}
}
)
logger.info("Order created", extra={"orderId": order_id})
return {"orderId": order_id, "status": "PENDING"}, 201
@logger.inject_lambda_context
@tracer.capture_lambda_handler
def handler(event: dict, context: LambdaContext) -> dict:
return app.resolve(event, context)
# src/process_order.py
import json
import os
import boto3
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType, process_partial_response
from aws_lambda_powertools.utilities.typing import LambdaContext
logger = Logger()
tracer = Tracer()
processor = BatchProcessor(event_type=EventType.SQS)
dynamodb = boto3.resource('dynamodb')
sns = boto3.client('sns')
table = dynamodb.Table(os.environ['TABLE_NAME'])
topic_arn = os.environ['TOPIC_ARN']
@tracer.capture_method
def process_record(record):
body = json.loads(record.body)
order_id = body['orderId']
# Simulate order fulfillment logic
table.update_item(
Key={'orderId': order_id},
UpdateExpression='SET #s = :status, processedAt = :ts',
ExpressionAttributeNames={'#s': 'status'},
ExpressionAttributeValues={
':status': 'FULFILLED',
':ts': __import__('datetime').datetime.utcnow().isoformat()
}
)
# Publish completion event
sns.publish(
TopicArn=topic_arn,
Message=json.dumps({'orderId': order_id, 'event': 'ORDER_FULFILLED'}),
Subject='OrderFulfilled'
)
logger.info("Order processed", extra={"orderId": order_id})
@logger.inject_lambda_context
@tracer.capture_lambda_handler
def handler(event: dict, context: LambdaContext) -> dict:
return process_partial_response(
event=event,
record_handler=process_record,
processor=processor,
context=context
)
CI/CD Pipeline with GitHub Actions
A SAM-based CI/CD pipeline runs sam build, executes unit tests, deploys to staging, runs integration tests, then promotes to production on success. The GitHub Actions workflow below uses OIDC (OpenID Connect) for AWS authentication — no long-lived access keys stored as secrets.
# .github/workflows/deploy.yml
name: SAM Deploy Pipeline
on:
push:
branches: [main, staging]
pull_request:
branches: [main]
env:
AWS_REGION: us-east-1
SAM_CLI_TELEMETRY: 0
permissions:
id-token: write # Required for OIDC token
contents: read
jobs:
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- name: Install dependencies
run: |
pip install -r src/requirements-dev.txt
- name: Run unit tests with coverage
run: |
pytest tests/unit/ \
--cov=src \
--cov-report=xml \
--cov-fail-under=80 \
-v
- name: Upload coverage
uses: codecov/codecov-action@v4
build:
name: SAM Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Set up SAM CLI
uses: aws-actions/setup-sam@v2
with:
use-installer: true
- name: Cache SAM build
uses: actions/cache@v4
with:
path: .aws-sam/cache
key: sam-${{ runner.os }}-${{ hashFiles('src/**', 'template.yaml') }}
- name: SAM Build
run: sam build --use-container --cached --parallel
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: sam-build
path: .aws-sam/build/
retention-days: 1
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build
environment: staging
if: github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up SAM CLI
uses: aws-actions/setup-sam@v2
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: sam-build
path: .aws-sam/build/
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_STAGING_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Deploy to staging
run: |
sam deploy \
--config-env staging \
--no-confirm-changeset \
--no-fail-on-empty-changeset
- name: Run integration tests
run: |
API_URL=$(aws cloudformation describe-stacks \
--stack-name order-service-staging \
--query 'Stacks[0].Outputs[?OutputKey==`ApiEndpoint`].OutputValue' \
--output text)
API_ENDPOINT=$API_URL pytest tests/integration/ -v
deploy-prod:
name: Deploy to Production
runs-on: ubuntu-latest
needs: deploy-staging
environment: production
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up SAM CLI
uses: aws-actions/setup-sam@v2
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: sam-build
path: .aws-sam/build/
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_PROD_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Deploy to production (with changeset confirmation)
run: |
sam deploy \
--config-env prod \
--confirm-changeset \
--no-fail-on-empty-changeset
Configure OIDC trust in your AWS account so GitHub Actions can assume the deployment role without static credentials. This is significantly more secure than storing AWS_ACCESS_KEY_ID in GitHub secrets — OIDC tokens are short-lived and scoped to the specific workflow run.
# Create OIDC provider for GitHub Actions (one-time setup)
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
# The IAM role's trust policy:
# {
# "Effect": "Allow",
# "Principal": {"Federated": "arn:aws:iam::ACCOUNT:oidc-provider/token.actions.githubusercontent.com"},
# "Action": "sts:AssumeRoleWithWebIdentity",
# "Condition": {
# "StringEquals": {
# "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
# "token.actions.githubusercontent.com:sub": "repo:your-org/order-service:ref:refs/heads/main"
# }
# }
# }
SAM Accelerate: sam sync Watch Mode
SAM Accelerate (sam sync) dramatically shortens the inner development loop. Instead of running the full CloudFormation update cycle (which can take 2–5 minutes for complex stacks), sam sync uses AWS service APIs to update only what changed — often in under 10 seconds. It bypasses CloudFormation for Lambda function code updates and syncs only the changed functions directly.
# Sync everything to your dev stack (one-time full deploy first)
sam sync --stack-name order-service-dev --watch
# SAM watches for file changes and syncs automatically:
# - Lambda code changes → direct function update (5–8s, no CloudFormation)
# - Template resource changes → CloudFormation update (full cycle)
# - Layer changes → layer version update + function configuration update
# Sync a specific resource only
sam sync \
--stack-name order-service-dev \
--resource-id CreateOrderFunction \
--no-watch
# Sync code changes only (skip infrastructure drift detection)
sam sync \
--stack-name order-service-dev \
--code \
--watch
The --watch mode monitors your project using file system events. When you save a Python file in src/create_order/, SAM detects the change, re-runs sam build for just that function, and pushes the new deployment package directly to Lambda using the UpdateFunctionCode API — bypassing the CloudFormation change set cycle entirely. This reduces the edit-test loop from 3–5 minutes to under 10 seconds for code-only changes.
sam local when you want zero AWS cost and truly offline development (with local DynamoDB/SQS). Use sam sync --watch when your function interacts with real AWS services (Secrets Manager, Bedrock, real DynamoDB tables) and you need to test against actual AWS behavior. The two approaches complement each other.SAM Accelerate also supports partial CloudFormation updates for infrastructure changes. If you add a new SQS queue to the template, sam sync runs a CloudFormation update for just that change rather than a full stack replacement. The result is that even infrastructure changes that previously required a full deploy cycle are faster with sam sync.
Testing Strategies: Unit, Local, and Integration
A robust SAM testing strategy operates at three levels: unit tests that mock AWS SDK calls (fast, zero cost), local invocation tests using sam local invoke (Docker-based, catches runtime issues), and integration tests running against a deployed staging stack (full end-to-end validation).
Unit Tests with moto (mock AWS SDK)
# tests/unit/test_create_order.py
import json
import pytest
import boto3
from moto import mock_aws
from unittest.mock import patch
import os
# Set environment variables before importing the handler
os.environ['TABLE_NAME'] = 'orders-test'
os.environ['QUEUE_URL'] = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue'
os.environ['ENVIRONMENT'] = 'test'
os.environ['POWERTOOLS_SERVICE_NAME'] = 'order-service'
@pytest.fixture
def aws_credentials():
"""Mock AWS credentials for moto."""
os.environ['AWS_ACCESS_KEY_ID'] = 'testing'
os.environ['AWS_SECRET_ACCESS_KEY'] = 'testing'
os.environ['AWS_SECURITY_TOKEN'] = 'testing'
os.environ['AWS_SESSION_TOKEN'] = 'testing'
os.environ['AWS_DEFAULT_REGION'] = 'us-east-1'
@pytest.fixture
def dynamodb_table(aws_credentials):
with mock_aws():
dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
table = dynamodb.create_table(
TableName='orders-test',
KeySchema=[{'AttributeName': 'orderId', 'KeyType': 'HASH'}],
AttributeDefinitions=[{'AttributeName': 'orderId', 'AttributeType': 'S'}],
BillingMode='PAY_PER_REQUEST'
)
yield table
@pytest.fixture
def sqs_queue(aws_credentials):
with mock_aws():
sqs = boto3.client('sqs', region_name='us-east-1')
queue = sqs.create_queue(QueueName='test-queue')
yield queue
@mock_aws
def test_create_order_success(dynamodb_table, sqs_queue):
from src.create_order import handler
event = {
'httpMethod': 'POST',
'path': '/orders',
'body': json.dumps({
'customerId': 'cust-123',
'items': [{'name': 'laptop', 'price': 999.99, 'quantity': 1}]
}),
'headers': {'Content-Type': 'application/json'}
}
context = type('obj', (object,), {'aws_request_id': 'test-req-001'})()
response = handler(event, context)
assert response['statusCode'] == 201
body = json.loads(response['body'])
assert 'orderId' in body
assert body['status'] == 'PENDING'
# Verify DynamoDB write
item = dynamodb_table.get_item(Key={'orderId': body['orderId']})['Item']
assert item['customerId'] == 'cust-123'
assert item['totalAmount'] == 999.99
@mock_aws
def test_create_order_partial_batch_failure(dynamodb_table):
"""Test SQS batch processing with mixed success/failure."""
from src.process_order import handler
event = {
'Records': [
{'messageId': 'msg-001', 'body': json.dumps({'orderId': 'ord-001'})},
{'messageId': 'msg-002', 'body': 'INVALID_JSON'}, # This will fail
]
}
context = type('obj', (object,), {'aws_request_id': 'test-req-002'})()
response = handler(event, context)
# Only the failed message should be in batchItemFailures
failed_ids = [f['itemIdentifier'] for f in response['batchItemFailures']]
assert 'msg-002' in failed_ids
assert 'msg-001' not in failed_ids
Local Invocation Tests
# Run local invocation as part of pre-commit or CI
sam build --cached
# Test happy path
sam local invoke CreateOrderFunction \
--event events/create_order_success.json \
--env-vars env.test.json \
--log-file /tmp/sam-test.log
# Verify exit code and output
if [ $? -eq 0 ]; then
echo "Local invocation passed"
cat /tmp/sam-test.log | python -m json.tool
else
echo "Local invocation FAILED"
cat /tmp/sam-test.log
exit 1
fi
Integration Tests Against Staging
# tests/integration/test_api.py
import json
import os
import time
import pytest
import requests
import boto3
API_ENDPOINT = os.environ['API_ENDPOINT'] # Set from CloudFormation output in CI
TABLE_NAME = os.environ['TABLE_NAME']
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(TABLE_NAME)
def test_create_and_retrieve_order():
"""End-to-end: create order via API, verify DynamoDB state after SQS processing."""
payload = {
'customerId': f'integration-test-{int(time.time())}',
'items': [{'name': 'test-item', 'price': 10.00, 'quantity': 2}]
}
# Create order via REST API
response = requests.post(
f'{API_ENDPOINT}/orders',
json=payload,
headers={'Content-Type': 'application/json'},
timeout=10
)
assert response.status_code == 201
order_id = response.json()['orderId']
# Poll DynamoDB until order is FULFILLED (SQS processing is async)
for _ in range(10):
item = table.get_item(Key={'orderId': order_id}).get('Item')
if item and item.get('status') == 'FULFILLED':
break
time.sleep(2)
else:
pytest.fail(f"Order {order_id} was not fulfilled within 20 seconds")
assert item['customerId'] == payload['customerId']
assert item['totalAmount'] == 20.00