AWS-IaC
AWS-SAM
Intro
AWS Serverless Application Model (AWS SAM) is an open-source framework for building serverless applications using infrastructure as code (IaC). With AWS SAM's shorthand syntax, developers declare CloudFormation resources and specialized serverless resources that are transformed to infrastructure during deployment. When working with AWS SAM, you will interact with:
-
AWS SAM CLI - A command-line tool that helps you develop, locally test, and deploy your serverless applications.
-
AWS SAM Template - An extension of CloudFormation that provides simplified syntax for defining serverless resources.
AWS SAM is an ideal IaC solution for scenarios where you want simplified serverless development with the full power of CloudFormation. For example, you can use SAM for:
-
Serverless applications: You can use SAM to quickly define AWS Lambda functions, Lambda durable functions, Amazon API Gateway APIs, Amazon DynamoDB tables, and other serverless resources with minimal code.
-
CloudFormation enhancement: You can combine SAM with existing CloudFormation templates to add serverless components to traditional infrastructure. SAM resources work alongside standard CloudFormation resources in the same template.
-
Local development and testing: You can use the SAM CLI to test Lambda functions locally, simulate API Gateway endpoints, and debug serverless applications on your development machine before deploying to AWS.
-
CI/CD for serverless: You can build deployment pipelines using SAM templates that automatically generate the CloudFormation infrastructure needed for staging and production environments.
-
Migration from console-created resources: You can convert Lambda functions and API Gateway resources created in the AWS Management Console into infrastructure as code using SAM templates.
Comparing AWS SAM with other IaC tools
-
Use SAM instead of CloudFormation to simplify serverless resource definitions while maintaining template compatibility.
-
Use SAM instead of AWS CDK if you prefer a declarative approach to describing your infrastructure rather than a programmatic one.
-
Combine SAM with AWS CDK by using SAM CLI's local testing features to enhance your CDK applications.
Installation
Linux installation
You can install this package via brew:
brew install aws-sam-cli
Basics
- Run the
sam initcommand to create your serverless application and create a project directory. - Modify the
template.yamlfile, which is your AWS SAM template. - Run the
sam buildcommand to update the changes you made in thetemplate.yamlto produce a new CloudFormation output. When you build, the AWS SAM CLI creates a.aws-samdirectory and organizes your function dependencies, project code, and project files there.
WARNING
The installed Python version must match the Runtime property specified in template.yaml. For production workloads, we recommend using Python 3.12 or earlier versions that are fully supported in AWS environments. If the Python versions don't match, you'll encounter build errors.
To resolve version compatibility issues:
- Specify a compatible runtime during initialization:
sam init --runtime python3.9 - Modify the
Runtimeproperty intemplate.yamlafter initialization
File structure
.
├── README.md
├── events // contain sample events
│ └── event.json
├── hello-world // contain lambda image code
│ ├── Dockerfile
│ ├── app.mjs
│ ├── package.json
│ └── tests // for testing lambda with sample event
│ └── unit
│ └── test-handler.mjs
├── samconfig.toml
└── template.yaml
Here are the important top-level files:
template.yaml: A template that defines the application's AWS resources.
And here are the dedicated folders:
hello-world/tests: folder containing nit tests for the application code.events: folder containing sample JSON events that you can use to invoke the function and test it out.
Building and deploying
building
Run the sam build command to build the CloudFormation template from your template.yaml
NOTE
If you don't have Python on your local machine, use the sam build --use-container command instead. The AWS SAM CLI will create a Docker container that includes your function's runtime and dependencies. This command requires Docker on your local machine.
The following is a shortened example of the .aws-sam directory created by the AWS SAM CLI:
.aws-sam
├── build
│ ├── HelloWorldFunction
│ │ ├── __init__.py
│ │ ├── app.py
│ │ └── requirements.txt
│ └── template.yaml
└── build.toml
Some important files to highlight:
-
build/HelloWorldFunction– Contains your Lambda function code and dependencies. The AWS SAM CLI creates a directory for each function in your application. -
build/template.yaml– Contains a copy of your AWS SAM template that is referenced by CloudFormation at deployment. -
build.toml– Configuration file that stores default parameter values referenced by the AWS SAM CLI when building and deploying your application.
deploying
You are now ready to deploy your application to the AWS Cloud.
In this step, you use the AWS SAM CLI to deploy your application to the AWS Cloud. The AWS SAM CLI will do the following:
-
Guide you through configuring your application settings for deployment.
-
Upload your application files to Amazon Simple Storage Service (Amazon S3).
-
Transform your AWS SAM template into an CloudFormation template. It then uploads your template to the CloudFormation service to provision your AWS resources.
To deploy your application, follow these steps:
- Run the
sam deploycommand and walk through the TUI.
sam deploy --guided
Here is what happens behind the scenes:
- The AWS SAM CLI creates an Amazon S3 bucket and uploads your
.aws-samdirectory. - The AWS SAM CLI transforms your AWS SAM template into CloudFormation and uploads it to the CloudFormation service.
- CloudFormation provisions your resources.
building and deploying all together
sam build
sam deploy --guided
The first command will build a docker image from a Dockerfile and then the source of your application inside the Docker image. The second command will package and deploy your application to AWS, with a series of prompts:
- Stack Name: The name of the stack to deploy to CloudFormation. This should be unique to your account and region, and a good starting point would be something matching your project name.
- AWS Region: The AWS region you want to deploy your app to.
- Confirm changes before deploy: If set to yes, any change sets will be shown to you before execution for manual review. If set to no, the AWS SAM CLI will automatically deploy application changes.
- Allow SAM CLI IAM role creation: Many AWS SAM templates, including this example, create AWS IAM roles required for the AWS Lambda function(s) included to access AWS services. By default, these are scoped down to minimum required permissions. To deploy an AWS CloudFormation stack which creates or modifies IAM roles, the
CAPABILITY_IAMvalue forcapabilitiesmust be provided. If permission isn't provided through this prompt, to deploy this example you must explicitly pass--capabilities CAPABILITY_IAMto thesam deploycommand. - Save arguments to
samconfig.toml: If set to yes, your choices will be saved to a configuration file inside the project, so that in the future you can just re-runsam deploywithout parameters to deploy changes to your application.
NOTE
Deployment configuration settings are stored in samconfig.toml so you can run sam deploy without any flags or arguments on subsequent deployments.
Local testing
After building the function with sam build, you have these options available to you:
Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
Local function invocation
- Build the app with the
sam buildcommand - Run functions locally and invoke them with the
sam local invokecommand:
Here is a manual invocation of a function, where you use the sam local invoke command and pass the function name to invoke (pulled from template.yaml) and the specific sample event to pass using the --event flag.
sam local invoke HelloWorldFunction --event events/event.json
The SAM CLI can also emulate your application's API. Use the sam local start-api to run the API locally on port 3000.
sam local start-api
curl http://localhost:3000/
You can also override or supplement variables locally with an env.json file:
sam local start-api --env-vars env.json
This lets you use local database URLs or API keys without modifying template.yaml.
Logs
To simplify troubleshooting, SAM CLI has a command called sam logs. sam logs lets you fetch logs generated by your deployed Lambda function from the command line. In addition to printing the logs on the terminal, this command has several nifty features to help you quickly find the bug.
sam logs -n HelloWorldFunction --stack-name sam-lambda-course --tail
Debugging in VSCode
- Create a VSCode launch profile for SAM tasks
- Run
sam local invokebut attach the Node inspector to a specific port.
sam local invoke -d 9229
Then VS Code can attach directly to the Lambda process, allowing you to:
- Set breakpoints
- Inspect variables
- Step through your TypeScript (with source maps)
- Evaluate expressions
Deployment in depth
Deleting stacks
To delete the sample application that you created, use the AWS CLI. Assuming you used your project name for the stack name, you can run the following:
sam delete --stack-name sam-lambda-course
To delete all resources provisioned from the CloudFormation template, just run the sam delete command to delete all stacks and their resources.
sam delete
Deployment information
You can list all the currently deployed endpoints with the sam list endpoints command
sam list endpoints --output json
Watching deployments development
If you want local changes to your SAM tempalte or lambda source code to be immediately deployed, run this command:
sam sync --watch
template.yaml primer
Let's examine the basic hello world example for template.yaml:
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: |
sam-lambda-course
Sample SAM Template for sam-lambda-course
# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
Function:
Timeout: 3
Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
Architectures:
- x86_64
Events:
HelloWorld:
Type: Api
Properties:
Path: /hello
Method: get
Metadata:
DockerTag: nodejs24.x-v1
DockerContext: ./hello-world
Dockerfile: Dockerfile
RollDieFunction:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
ImageUri: roll-die/ # this is the path to the folder where the Dockerfile is located
Architectures:
- x86_64
Events:
RollDieGet:
Type: Api
Properties:
Path: /roll
Method: get
RollDiePost:
Type: Api
Properties:
Path: /roll
Method: post
Metadata:
DockerTag: nodejs24.x-v1
DockerContext: ./roll-die
Dockerfile: Dockerfile
Outputs:
# ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
# Find out more about other implicit resources you can reference within SAM
# https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
HelloWorldApi:
Description: API Gateway endpoint URL for Prod stage for Hello World function
Value: !Sub https://${ServerlessRestApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/Prod/hello/
HelloWorldFunction:
Description: Hello World Lambda Function ARN
Value: !GetAtt HelloWorldFunction.Arn
HelloWorldFunctionIamRole:
Description: Implicit IAM Role created for Hello World function
Value: !GetAtt HelloWorldFunctionRole.Arn
Your file has this structure
AWSTemplateFormatVersion
Transform
Description
Globals
Resources
Outputs
These are the major sections.
Think of it like
Metadata
Global defaults
Actual AWS resources
Useful information after deployment
Metadata section
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: |
sam-lambda-course
Sample SAM Template for sam-lambda-course
Pretty standard, these settings don't change.
NOTE
The | means register it as a multiline string, maintain new line breaks verbatim.
Global defaults
The Global top level provides global configuration across all AWS resources of a specific type, like for lambda functions, a shared timeout, environment variables, etc.
This below example gives all lambda functions a timeout of 30 seconds.
Globals:
Function:
Timeout: 30
Lambda function globals
Global configuration across all lambda functions live under the Globals.Function key.
LoggingConfig globals
LoggingConfig:
LogFormat: JSON
Without it
hello world
user logged in
finished
With JSON
{
"level":"INFO",
"message":"user logged in",
"timestamp":"..."
}
Structured logs are much easier to search.
AWS resources
SAM is not just for lambda. It's IaC for all AWS resources.
Under the Resources top level key, you define the resources you want to provision and their individual configuration. Here are some examples of resources you can provision:
AWS::Serverless::Function: represents a lambda function, which you can create either with containerization or zipping the source code folder, both of which SAM handles for you.
Here is an example resource, where we create a lambda function resource by specifying the type as AWS::Serverless::Function under the logical ID HelloWorldFunction.
Resources:
HelloWorldFunction: # logical ID of resource
Type: AWS::Serverless::Function # define as lambda
Properties: # configuration for the lambda
...
Implicit resources
Certain keys can create implicit resources behind the scenes, which are not resources you explicitly define under the Resources key, but rather resources that SAM manages and makes for you, like IAM roles, API gateways, etc.
Resources:
RollDieFunction:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
ImageUri: roll-die/
Architectures:
- x86_64
Events:
RollDieGet:
Type: Api # creates implicit API gateway
Properties:
Path: /roll
Method: get
For example, when you create the above API gateway trigger for the RollDieFunction, here are the resources that implicitly get created:
RollDieFunctionRole: the IAM role attached to the lambda that provides basic lambda execution permissions
Also, whenever you create an API gateway event type, it implicitly creates a API gateway resource with the logical ID as ServerlessRestApi behind the scenes that acts as an API gateway for all API gateway proxy lambdas you set in the app.
NOTE
Implicit resources can be accessed as variables through their logical ID and then combined with functions like !GetAtt or !Sub to dynamically retrieve values from them.
Explicit Resources
Explicit resources can often be much easier to reason about, so you can define APIs and other resources explicitly and then reference them with the !Ref function whenever you need to:
Resources:
MyApi: # define API gateway with logical ID MyApi
Type: AWS::Serverless::Api
Now when creating API gateway events you can reference the explicit API gateway reference to use through the !Ref function.
Events:
CreateUser:
Type: Api
Properties:
RestApiId: !Ref MyApi
Path: /users
Method: post
Deployment outputs, functions, Variables implicit vs explicit APIs
Deployment outputs are just like CfnOutput instances in AWS CDK, where important info is printed to the console.
Outputs:
# ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
HelloWorldApi:
Description: API Gateway endpoint URL for Prod stage for Hello World function
Value: !Sub https://${ServerlessRestApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/Prod/hello/
HelloWorldFunction:
Description: Hello World Lambda Function ARN
Value: !GetAtt HelloWorldFunction.Arn
HelloWorldFunctionIamRole:
Description: Implicit IAM Role created for Hello World function
Value: !GetAtt HelloWorldFunctionRole.Arn
Outputs
Outputs live under the Outputs key and define the outputs for each AWS resource created in the SAM build.
Here is the basic syntax:
Outputs:
<ResourceLogicalId>:
Description: some description of the output
Value: some value of the output
However, for outputs to do anything useful we need to use functions and variables to dynamically read properties of created resources.
Functions and variables
functions
The !GetAtt, !Ref, and !Sub functions are important for retrieving data and creating dynamic values.
- fetching resource properties via logical ID: the
!GetAttfunction allows you to reference an AWS resource by its logical ID and then extract a property from that resource.
!GetAtt HelloWorldFunction.Arn # Retrieve the ARN attribute of HelloWorldFunction.
- string interpolation: The
!Subfunction allows you to perform string interpolation by using the${varname}syntax.
!Sub Hello ${AWS::Region} # returns "Hello us-east-1"
- fetching AWS ID of a resource: You can fetch the AWS ID of a resource through the
!Reffunction and passing the logical ID of the resource to it:
Events:
CreateUser:
Type: Api
Properties:
RestApiId: !Ref MyApi
Path: /users
Method: post
global variables
Here are the global variables that are always available:
AWS::Region: returns the current AWS regionAWS::URLSuffix: always returnsamazonaws.com
implicit variables
You also have variables that come from implicitly created resources or APIs, which makes this:
https://${ServerlessRestApi}.executeapi.${AWS::Region}.${AWS::URLSuffix}/Prod/hello
Become this:
https://abc123.execute-api.us-east-1.amazonaws.com/Prod/hello/
Lambda resources
Serverless functions in SAM are defined by the AWS::Serverless::Functionresource type, but there is one very important thing to keep in mind:
A SAM Function is not a Lambda function. It's actually a template that SAM expands into multiple CloudFormation resources.
For example, this:
Resources:
MyFunction:
Type: AWS::Serverless::Function
is approximately expanded into something like:
MyFunction (SAM)
│
▼
AWS::Lambda::Function
AWS::IAM::Role
AWS::Logs::LogGroup
AWS::Lambda::Permission
AWS::ApiGateway::Integration (if API)
AWS::ApiGateway::Method
AWS::ApiGateway::Deployment
Intro
Here are the important top-level configuration keys that live under the Properties key:
Timeout: the max timeout in seconds of the lambda functionReservedConcurrentExecutions: the max concurrency for the lambdas, meaning the maximum number of lambdas that can run at one time.Memory: the max memory in megabytes to let the lambda function execution environment have.- An important thing to remember is Higher memory == faster execution.
Architectures: which architectures to build the lambda for, which accept eitherx86_64orarm64as values.- An important thing to remember is your Docker image architecture must match this value.
And here are the top-level keys that require a bit more configuration and determine a large portion of lambda behavior:
PackageType:Zipto zip up lambda source code orImageto use docker to build lambda source code.Events: the triggers to define for the lambdaEnvironment: provides configuration to set environment variablesPolicies: policies to attach to the lambda roleMetadata: metadata that helps containerized images find their Dockerfile path, etc.
Resources:
# name of resource
HelloWorldFunction:
Type: AWS::Serverless::Function # define as lambda
Properties:
# use Zip method and target lambda source handler() at hello-world/app.mjs
PackageType: Zip
CodeUri: hello-world/
Handler: app.lambdaHandler
ReservedConcurrentExecutions: 5
Architectures:
- x86_64
# define lambda triggers
Events:
HelloWorld: # logical ID for trigger
Type: Api # API gateway trigger that executes lambda on GET /hello
Properties:
Path: /hello
Method: get
RollDieFunction:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
ImageUri: roll-die/ # folderpath where the Dockerfile is located
Architectures:
- x86_64
Events:
RollDieGet:
Type: Api
Properties:
Path: /roll
Method: get
RollDiePost:
Type: Api
Properties:
Path: /roll
Method: post
Metadata:
DockerTag: nodejs24.x-v1
DockerContext: ./roll-die
Dockerfile: Dockerfile
Here's a more complete example:
Resources:
CreateUserFunction:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
Timeout: 30
MemorySize: 512
Architectures:
- arm64
Environment:
Variables:
DATABASE_URL: postgres://...
LOG_LEVEL: info
Policies:
- DynamoDBCrudPolicy:
TableName: Users
Events:
CreateUser:
Type: Api
Properties:
Path: /users
Method: post
Metadata:
DockerContext: ./functions/create-user
Dockerfile: Dockerfile
DockerTag: latest
zip vs dockerfile method
When choosing to either use a DockerFile to package up your lambdas or let SAM manage the lambda source code by zipping it up, you have different configurations you need to provide.
For a single lambda resource, to control whether using zip method or docker method, you specify the Properties.PackageType either with the Zip value to specify zip or Image value to specify docker.
PackageType: Zip: Uses zip mode for lambda packaging and requires these properties:CodeUri: the path to the folder (relative from project root) containing the lambda source code.Handler: follows the syntax<file-basename>.<handler-method-name>, which specifies the specific function to register as the lambda handler function.- For example,
app.handlerrefers to the exportedhandler()method inapp.mjs
- For example,
PackageType: Image: Uses zip mode for lambda packaging and requires these properties:ImageUri: the path to the folder (relative from project root) containing the Dockerfile.
Environment variables
The Properties.Environment key on a resource lets you set environment variables which can be accessed in code.
Environment:
Variables:
DATABASE_URL: xxx
NODE_ENV: production
API_KEY: abc
However, to load secrets that should not be exposed within the template.yaml, you would have to references secrets inside Secrets manager or SSM parameter store like so:
DATABASE_URL: !Ref DatabaseSecret
SAM supports CloudFormation parameters:
Parameters:
Environment:
Type: String
Then
Environment:
Variables:
STAGE: !Ref Environment
Now
sam deploy \
--parameter-overrides Environment=prod
sets the environment.
Great for
- dev
- staging
- prod
without changing code.
Policies and roles
Without SAM creating IAM permissions is incredibly verbose. Instead you can simply write something like this, creating policies under the Properties.Policies key, which will then attach that policy to the implicitly created lambda execution role.
Policies:
- DynamoDBCrudPolicy: # grants READ/WRITE to a specific ddb table
TableName: Users
By default, the implicitly created role will have the AWSLambdaBasicExecutionRole permissions attached to it, but if you want to add additional policies, here are some examples:
Policies:
# grants the lambda READ objects permission to the uploads bucket
- S3ReadPolicy:
BucketName: uploads
# grants the lambda READ/WRITE to the Users ddb table
- DynamoDBCrudPolicy:
TableName: Users
# allows the lambda to send messages to the Orders SQS queue
- SQSSendMessagePolicy:
QueueName: Orders
You can also use any of the 1,482 AWS managed policies, like the AWSLambdaBasicExecutionRole which grants basic lambda execution permissions to the implicitly created lambda role.
Policies:
- AWSLambdaBasicExecutionRole
Overall the steps for giving your lambda the right permissions are as follows:
- Either you create your own role, use an existing role, or just do nothing and use the implicit role attached to the lambda.
- Specify additional policies
Override implicit role
Instead of Policies, you can provide your own IAM Role that will override the implicitly created lambda role. There are two ways you can do this:
- use existing role: point the lambda to use an already existing role and thus set of permissions via the
Properties.Rolekey
Role: arn:aws:iam::123456789:role/MyRole
- create new role: Create a new role that is basically you creating a JSON policy statement that defines permissions for the lambda role. This requires both the
Properties.AssumeRolePolicyDocumentand theProperties.Policieskeys
Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function
Properties:
# 1. create the role
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
# 2. define the policies to attach to the role
Policies:
- AWSLambdaBasicExecutionRole
NOTE
Generally,
- use
Policiesfor most projects - use
Roleonly when you need full control or must reuse an existing role
Events
The Properties.Events key defines the triggers for the lambda and creates all the necessary infrastructure behind the scene to create the trigger wiring successfully.
| Event Type | Trigger |
|---|---|
| Api | HTTP request through API Gateway |
| HttpApi | HTTP request through API Gateway HTTP APIs |
| Schedule | Cron or rate expression |
| S3 | File uploaded to a bucket |
| SQS | Message arrives in a queue |
| SNS | Topic publishes a message |
| DynamoDB | Table stream record changes |
| EventBridgeRule | Custom or AWS events |
| CloudWatchLogs | Log subscription |
| Kinesis | Stream records |
For example:
Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
Events:
Hello: # logical ID for trigger
Type: Api
Properties:
Path: /hello # triggers lambda on GET /hello to API gateway
Method: get
This tiny amount of YAML creates:
Lambda
↓
API Gateway
↓
Permission allowing API Gateway to invoke Lambda
↓
Method
↓
Integration
↓
Deployment
↓
Stage
REST API Gateway events
You specify a REST API gateway through specifying the Events.<Event-ID>.Type to be of value Api.
Here are the different keys that live under the Properties configuration for an API gateway event:
Path: the routing pattern to match for the eventMethod: the lowercase HTTP method to match for the event.
routing
This is how to match a dynamic route to GET /users/:id:
Events:
Hello: # logical ID for trigger
Type: Api
Properties:
Path: /users/{id} # triggers lambda on GET /users/:id to API gateway
Method: get
Then inside the lambda you can access it through the event.pathParameters.id property:
/**
* @param {import('aws-lambda').APIGatewayEvent} event - The API Gateway event object.
* @param {import('aws-lambda').Context} context - The Lambda execution context.
* @returns {Promise<import('aws-lambda').APIGatewayProxyResult>} The response object.
*/
export const lambdaHandler = async (event, context) => {
/**
* @type {import('aws-lambda').APIGatewayProxyResult}
*/
const response = {
statusCode: 200,
body: JSON.stringify({
message: `route id is ${event.pathParameters.id}`,
}),
};
return response;
};
http method
Here are the allowed HTTP method values you can pass to the Properties.Method property for an API Gateway Event:
get
post
put
delete
patch
head
options
any
implicit event
When specifying an API gateway event, SAM creates an implicit API gateway called ServerlessRestApi which you can then access as a variable.
HTTP API Gateway event
You specify a HTTP (newer version) API gateway through specifying the Events.<Event-ID>.Type to be of value HTTPApi.
NOTE
You should always prefer HTTPApi now to just Api, because HTTP API gateway version is not only newer but also faster and simpler. Most new APIs should use HTTP APIs unless you specifically need a REST API feature such as API keys, request validation, usage plans, or certain advanced integrations.
Events:
Hello: # logical ID for trigger
Type: HTTPApi # define HTTP API gateway trigger
Properties:
Path: /users/{id} # triggers lambda on GET /users/:id
Method: get
code
Now in the code, since you're using the HTTP API, then you should use the V2 versions.
import {
APIGatewayProxyEventV2,
APIGatewayProxyResultV2,
} from "aws-lambda";
export const handler = async (
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
// code here
};
S3 event
Events:
Upload: # logical ID of trigger
Type: S3 # S3-based trigger
Properties:
Bucket: !Ref UploadBucket # reference specific bucket resource
Events: s3:ObjectCreated:* # listen for any object created evenbts
SQS event
Events:
Message: # logical ID of trigger
Type: SQS # SQS based trigger that forwards messages to lambda
Properties:
Queue: !GetAtt OrdersQueue.Arn # reference queue
BatchSize: 10 # batch size (max concurrent messages)
Cron event
Events:
Cleanup: # logical ID of trigger
Type: Schedule # cron based trigger that runs lambda on schedule
Properties:
Schedule: rate(5 minutes) # runs every 5 minutes
You can also use normal cron syntax to describe the schedule:
Schedule: cron(0 3 * * ? *)
EventBridge
Think of EventBridge as AWS's central event bus.
Example
Order Created
↓
EventBridge
↓
Inventory Lambda
↓
Email Lambda
↓
Analytics Lambda
One event.
Multiple consumers.
SAM
Type: EventBridgeRule
Very popular in microservices.
SNS
SNS is pub/sub.
Publisher
↓
Topic
↓
Subscriber A
Subscriber B
Subscriber C
SAM
Type: SNS
Good for broadcasting notifications to multiple systems.
DynamoDB streams
Suppose someone inserts
New User
into DynamoDB.
Automatically
DynamoDB
↓
Stream
↓
Lambda
Great for
- auditing
- analytics
- cache invalidation
- search indexing
Layers
Lambda Layers are shared code or binaries.
Imagine
20 Lambdas
all need
FFmpeg
Instead of embedding FFmpeg into every image or ZIP,
you can create
FFmpeg Layer
Every Lambda mounts it.
Layer
↓
Lambda A
Lambda B
Lambda C
This reduces duplication for ZIP-based deployments. For container image Lambdas, you'll often bake shared dependencies into a common base image instead of using layers.
VPC
If your lambda needs access to private AWS resources that live inside VPCs, like RDS, then you need to specify which VPC a lambda should live in.
VpcConfig:
SecurityGroupIds:
- sg-123
SubnetIds:
- subnet-1
- subnet-2
Pros
- Access to private resources (RDS, internal services)
Cons
- More networking complexity
- Historically slower cold starts (much improved today)
Function URLs
Not every Lambda needs API Gateway.
Lambda URLs provide a built-in HTTPS endpoint.
FunctionUrlConfig:
AuthType: NONE
Now AWS creates
https://abc.lambda-url.us-east-1.on.aws
Much simpler than API Gateway for lightweight services.
Metadata
The Properties.Metadata field describes how to look for the Dockerfile and prepare the build context.
Metadata:
DockerTag: nodejs24.x-v1
DockerContext: ./roll-die # the folder of content the Dockerfile can access
Dockerfile: Dockerfile # the path to DockerFile within the DockerCOntext
Metadata.DockerContext: specifies the build path which contains all the files the Dockerfile can access. The Dockerfile cannot access any files or folders outside of what you provide to theDockerContext.Metadata.Dockerfile: the path to DockerFile relative to the folderpath you provided forDockerContext.
SAM with TypeScript and Docker
Basic Docker
This is the basic dockerfile for a containerized lambda.
FROM public.ecr.aws/lambda/nodejs:24
COPY app.mjs package*.json ./
RUN npm install
# If you are building your code for production, instead include a package-lock.json file on this directory and use:
# RUN npm ci --production
# Command can be overwritten by providing a different command in the template directly.
CMD ["app.lambdaHandler"]
FROM public.ecr.aws/lambda/nodejs:24: uses a dedicated lambda imageCOPY app.mjs package*.json ./: copy all filesRUN npm install: install dependencies, but you should really runnpm ciwhen deploying to production.CMD ["app.lambdaHandler"]: says to run the specific handler destination for the lambda.
Then SAM just runs docker build when building the application.
Docker with TypeScript
To use docker with typescript, it involves just two simple steps:
- Setup the node code to use typescript with a
tsconfig.json, install typescript as a dev dependency, and include a build script to compile TS to JS. - In the docker file, run the build script to compile TS to JS.
Here is an appropriate dockerfile:
FROM public.ecr.aws/lambda/nodejs:22
# 1. equivalent to cd /var/task
WORKDIR ${LAMBDA_TASK_ROOT}
# 2. copy over package.json and package-lock.json to take advantage of layers
COPY package*.json ./
# 3. npm ci for production builds
RUN npm ci
# 4. copy over rest of files
COPY . .
# 5. compile TS to JS
RUN npm run build
# 6. run outputted lambda
CMD ["dist/app.handler"]
But you could also use a multi-stage build so you can get rid of bloat like dev dependencies (TypeScript) and output just the barebones lambda distributable at the end.
# Stage 1 - Build
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2 - Runtime
FROM public.ecr.aws/lambda/nodejs:22
WORKDIR ${LAMBDA_TASK_ROOT}
COPY /app/dist ./dist
COPY /app/node_modules ./node_modules
CMD ["dist/app.handler"]
File structure
my-api/
├── template.yaml
├── samconfig.toml
├── package.json # workspace root
├── tsconfig.base.json
├── packages/
│ └── shared/ # shared types, utilities, zod schemas
├── functions/
│ ├── create-user/
│ │ ├── Dockerfile
│ │ ├── package.json
│ │ └── src/
│ └── get-user/
│ ├── Dockerfile
│ ├── package.json
│ └── src/
└── events/
packages/shared: shared utilities (types, zod schemas, utils) that you use themaketool to copy into each docker build context for the lambda functions you're creatingfunctions: group all lambda function folders into this directory
And then for an individual function, here is how you should define your code:
functions/
└── create-user/
├── src/
│ ├── handler.ts # actual handler
│ ├── service.ts # business logic
│ ├── repository.ts # abstract and concrete DB following repository pattern
│ ├── schema.ts # zod schemas
│ └── types.ts # typescript types
├── Dockerfile
├── package.json
└── tsconfig.json
Responsibilities become clear:
handler.ts– Lambda entry point, parse request, return response.schema.ts– Zod validation.service.ts– Business logic.repository.ts– Database access.types.ts– Shared types for the function.
Here's a simplified production flow:
API Gateway
│
▼
handler.ts
│
├── Parse JSON
├── Validate with Zod
├── Call service
├── Catch errors
└── Return HTTP response
│
▼
service.ts
│
▼
repository.ts
│
▼
Database
Notice that only handler.ts knows anything about API Gateway or Lambda. Everything below it is plain TypeScript and can be reused, unit tested, or even moved into another application.
This is the final architecture to use:
API Gateway
│
▼
withApiHandler()
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Parse JSON Validate Zod Authenticate
│ │ │
└───────────────┼───────────────┘
▼
Build Request Context
(logger, config, AWS clients, user)
│
▼
Business Service
│
▼
Repository Layer
│
▼
DynamoDB / PostgreSQL / S3
│
▼
Response Helper (ok, created)
│
▼
API Gateway Response
Useful Utilities
Here are the http utilities that will help with providing standardized HTTP responses:
export function ok<T>(body: T) {
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
};
}
export function created<T>(body: T) {
return {
statusCode: 201,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
};
}
export function noContent() {
return {
statusCode: 204,
body: "",
};
}
export class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
toResponse(): { statusCode: number; body: string } {
return {
statusCode: this.status,
body: JSON.stringify({ error: this.message }),
};
}
}
export class NotFoundError extends HttpError {
constructor(msg = "Not Found") {
super(404, msg);
}
}
export class BadRequestError extends HttpError {
constructor(msg = "Bad Request") {
super(400, msg);
}
}
export class UnauthorizedError extends HttpError {
constructor(msg = "Unauthorized") {
super(401, msg);
}
}
export function handleError(error: unknown) {
if (error instanceof HttpError) {
return error.toResponse();
}
console.error(error);
return {
statusCode: 500,
body: JSON.stringify({
message: "Internal Server Error",
}),
};
}
Here are the zod schema and handler wrapper utilities:
import { ZodSchema } from "zod";
import { handleError } from "./httpUtils";
export function validateBody<T>(schema: ZodSchema<T>, body: string | null) {
return schema.parse(JSON.parse(body ?? "{}"));
}
type HandlerOptions<TBody> = {
body?: ZodSchema<TBody>;
};
export function withApiHandler<TBody>(
options: HandlerOptions<TBody>,
fn: (ctx: {
body: TBody;
event: APIGatewayProxyEventV2;
context: Context;
}) => Promise<APIGatewayProxyResultV2>,
) {
return async (event: APIGatewayProxyEventV2, context: Context) => {
try {
const body = options.body
? validateBody(options.body, event.body)
: undefined;
return await fn({
body: body as TBody,
event,
context,
});
} catch (err) {
return handleError(err);
}
};
}
The goal is to make this fully typesafe, handling dynamic query parameters and route parameters as well.
const CreateUserBody = z.object({
name: z.string(),
email: z.email(),
});
export const handler = withApiHandler(
{
body: CreateUserBody,
query: z.object({
sendWelcome: z.coerce.boolean().default(false),
}),
path: z.object({
organizationId: z.uuid(),
}),
},
async ({ body, query, path, user, db }) => {
// body, query, and path are fully inferred from the Zod schemas.
// user, db, logger, and config are already available.
return created(await createUser(body, path.organizationId, query.sendWelcome));
}
);
Production grade SAM architecture
Different types of architecture
When building serverless apps with SAM, it's important to reason about the best architecture choice for your app:
- monolith lambda: One lambda handling all business logic and endpoints.
- pro: easy to deploy and reason about routing and is very simple.
- con: leads to a bloated, large function with large cold starts and difficult to scale.
- generic services lambda: By dedicating one lambda per resource, you approach the realm of microservices, where you dedicate one single lambda per resource (URL path)
- example: 1 lambda handling all HTTP methods for the resource
/users
- example: 1 lambda handling all HTTP methods for the resource
- microservices lambda: By dedicating one lambda per endpoint (resource + method combination), you attain ultimate decoupling.
- example: 1 lambda for
GET /users, another forPOST /users, etc.
- example: 1 lambda for
NOTE
The best choice is a generic services lambda where it's one lambda per resource.
Here would be the full architecture stack:
Here's the full folder structure:
SAM
│
├── Docker Image Lambdas
├── HTTP API (not REST API unless I needed advanced features)
├── Node.js 22 or newer
├── TypeScript
├── Zod
├── npm or pnpm Workspaces
├── Shared "core" package
├── Shared "database" package
├── Shared "auth" package
├── PostgreSQL (or DynamoDB where appropriate)
├── AWS SDK v3
├── Structured JSON logging
├── CloudWatch metrics
├── X-Ray tracing
├── GitHub Actions
└── Multi-stage Docker builds
Folder structure
Here is a good folder structure that emphasizes reusability and shared code:
project/
├── template.yaml
├── packages/
│ ├── core/
│ ├── database/
│ ├── auth/
│ ├── config/
│ └── shared/
├── functions/
│ ├── auth/
│ ├── meals/
│ ├── orders/
│ ├── payments/
│ └── insurance/
└── infrastructure/
packages: reusable helpers
packages/
auth/
logging/
database/
validation/
aws/
functions: folders containing the handlers and docker images to build those handlers.
Environment variables, secrets, and staging
basic env vars
One mistake beginners make is scattering environment variables everywhere.
Instead:
.env
↓
config.ts
↓
Everything imports config
Example:
export const config = {
supabaseUrl: process.env.SUPABASE_URL!,
supabaseKey: process.env.SUPABASE_KEY!,
resendApiKey: process.env.RESEND_API_KEY!,
};
Now your application has one source of truth.
using cloudformation parameters
SAM supports CloudFormation parameters:
Parameters:
Environment:
Type: String
Then
Environment:
Variables:
STAGE: !Ref Environment
Now
sam deploy \
--parameter-overrides Environment=prod
sets the environment.
Great for
- dev
- staging
- prod
without changing code.
secrets
Never
Environment:
Variables:
DATABASE_PASSWORD: password123
Instead
Secrets Manager
↓
Lambda
↓
Environment Variable references or SDK lookup
SAM can wire these in using dynamic references, and your code can also retrieve them with the AWS SDK when appropriate.
staging
Eventually you'll have multiple different environments, like dev, staging, and prod.
Each environment has different
- database
- bucket
- secrets
- API URLs
Never hardcode these.
Faster docker builds
One Docker image per Lambda?
Not always.
Suppose
Orders
Payments
Meals
all use
- Node
- Zod
- PostgreSQL
- AWS SDK
Those can share a common base image.
Example:
FROM my-company/node-lambda-base
Then each function only copies its own code.
Benefits:
- Faster builds
- Smaller Dockerfiles
- Consistent runtime
- Easier dependency updates
base image
Build a base image with this architecture:
AWS Lambda Base
│
▼
Company Base Image
(Node, shared OS packages)
│
▼
Service Image
(orders, auth, payments...)
Logging and metrics
console.log() invocations in your lambda handlers push logs to their respective log group in Cloudwatch, so it's important to make them informative and structured for a better debugging experience:
{
"requestId": "...",
"userId": "...",
"route": "...",
"duration": "...",
"level": "INFO"
}
Also record metrics. CloudWatch Metrics (or Amazon EMF), combined with alarms, are often more actionable than logs because they let you detect trends automatically.
LocalStack
Installation and authentication
There are 6 ways to use localstack:
- standalone docker image
- localstack operator with kubernetes
- docker compose
- localstack CLI
- localstack desktop
- localstack VSCode extension
All the different ways to use LocalStack require the same thing: an auth token.
There are two ways to supply an auth token:
- env var method: export the
LOCALSTACK_AUTH_TOKENenvironment variable into the shell session before interacting with the CLI - CLI way: use the
localstackCLI to run thelocalstack auth set-tokencommand to set your auth token for localstack and gain permanent authentication:
localstack auth set-token <YOUR_AUTH_TOKEN>
localstack start
To debug if the localstack process is currently running, you can make a curl request to localhost:4566, which is the port the localhost process runs on.
curl http://localhost:4566/_localstack/info | jq
Connecting to Localstack
There are two ways to programmatically use LocalStack with AWS:
- CLI: use the
awslocalCLI or theawsCLI and point environment variables to localstack. - AWS local profile: create a dedicated "localstack" profile in your
~/.aws/configand~/.aws/credentialsfiles. Once this is set up, all IaC solutions like Cloudformation, SAM, and AWS CDK will pull the localstack credentials from the localstack profile and be able to work.
method 1: aws way with --endpoint-url
The localstack process runs on a dedicated URL and has its own access keys for programmatic access, so all we have to do is change the endpoint url and some environment variables.
export AWS_ACCESS_KEY_ID="test"
export AWS_SECRET_ACCESS_KEY="test"
export AWS_DEFAULT_REGION="us-east-1"
# example where we just point to localstack endpoint URL and dummy access keys
aws --endpoint-url=http://localhost.localstack.cloud:4566 kinesis list-streams
method 2: aws way with --profile
This method is a tad more convenient than the first because it works with IaC solutions for AWS automatically pulling from the currently authenticated AWS profile, so if you set the currently authenticated AWS profile to a localstack profile, then all IaC actions will automatically connect to LocalStack.
- Add the following profile to your AWS configuration file (by default, this file is at
~/.aws/config):
[profile localstack]
region=us-east-1
output=json
endpoint_url = http://localhost.localstack.cloud:4566
- Add the
localstackprofile to your AWS credentials file witht he exact dummy access keys being the value"test"(by default, this file is at~/.aws/credentials):
[localstack]
aws_access_key_id=test
aws_secret_access_key=test
You can now use the localstack profile with the aws CLI by specifying the --profile localstack flag on every single command:
aws s3 mb s3://test --profile localstack
aws s3 ls --profile localstack
NOTE
Alternatively, you can also set the AWS_PROFILE=localstack environment variable, in which case the --profile localstack parameter can be omitted in the commands above.
method 3: awslocal way
awslocal is the official LocalStack AWS CLI and serves as a thin wrapper and a substitute for the standard aws command, enabling you to run AWS CLI commands within the LocalStack environment without specifying the --endpoint-url parameter or a profile.
Here is how to install it:
pip install awscli-local[ver1] # installs version compatiable of v1 of AWS CLI
Localstack CLI
Here are the basic localstack CLI commands:
localstack start: starts localstack onlocalhost:4566localstack logs: views the logs on localstack
Localstack VSCode extension development
Read this for more info:
- Install AWS toolkit
- Install the localstack VSCode extension
- Install the localstack CLI with brew
- Go to the command palette and then run Localstack: Run LocalStack setup Wizard. This will automatically authenticate with your account to use the localstack auth token for localstack actions.
- Go to the command palette and then run Localstack: Configure LocalStack profile
The 4th step adds a dummy login and credentials to your ~/.aws/config and ~/.aws/credentials file that you can use so you can AWS through the context of localstack.
As part of the setup, a new localstack profile will have been added to your ~/.aws/config file. If you examine the file, you’ll see the following entry:
[profile localstack]
region = us-east-1
output = json
endpoint_url = http://localhost.localstack.cloud:4566
and the corresponding entry in ~/.aws/credentials:
[localstack]
aws_access_key_id = test
aws_secret_access_key = test
NOTE
Note that the installer will add these entries to the end of your existing files, but only if you don’t already have a localstack profile. Nothing else in these files will be modified.
Localstack with CDK
To run localstack with CDK, use the cdklocal command as a drop-in replacement for the cdk package.
npm install -g aws-cdk-local aws-cdk
cdklocal --version
Connecting to CDK
Before doing anything with CDK and starting a new project, you must have a fresh slate. You can do this by stopping localstack and then restarting the cloud instance of localstack.
To connect to CDK, you can follow these patterns:
method 1: connect to AWS localstack profile
- Run the
cdklocal init app --language typescriptto scaffold the boilerplate. - Export these environment variables:
export AWS_PROFILE=localstack
export AWS_ACCESS_KEY_ID="test"
export AWS_SECRET_ACCESS_KEY="test"
export AWS_DEFAULT_REGION="us-east-1"
- Now run the
cdklocal bootstrapcommand to setup resources.
LocalStack with SAM
Examples
Creating Lambdas and SNS with aws CLI
The 000000000000 is the AWS account ID for localstack, because since you're developing locally,
#!/bin/bash
export AWS_DEFAULT_REGION=us-east-1
# 1. create S3 buckets
awslocal s3 mb s3://localstack-thumbnails-app-images
awslocal s3 mb s3://localstack-thumbnails-app-resized
# 2. create an SNS topic
awslocal sns create-topic --name failed-resize-topic
awslocal sns subscribe \
--topic-arn arn:aws:sns:us-east-1:000000000000:failed-resize-topic \
--protocol email \
--notification-endpoint my-email@example.com
# 3. create a python lambda function
# --role: for lambdas, set this to arn:aws:iam::000000000000:role/lambda-role
#c
awslocal lambda create-function \
--function-name presign \
--runtime python3.11 \
--timeout 10 \
--zip-file fileb://lambdas/presign/lambda.zip \
--handler handler.handler \
--role arn:aws:iam::000000000000:role/lambda-role \
--environment Variables="{STAGE=local}"
awslocal lambda wait function-active-v2 --function-name presign
awslocal lambda create-function-url-config \
--function-name presign \
--auth-type NONE