Setting Up the Environment: AWS SAM and Your First Lambda Function

K
Kai··8 min read

The previous article stopped at architecture on paper. Before building each of its pieces, we need a working loop that runs: write code, push it to AWS, call it, then delete it without leaving junk behind. This article builds exactly that loop with AWS SAM, and ends with a real Lambda function running on AWS and then vanishing cleanly.

Goal

Install the SAM CLI, understand the structure of a minimal SAM project, deploy a function that returns JSON via a Function URL, call it with curl, run it right on your machine with Docker, then sam delete to clean up. All resources in this article are in the free tier, actual cost zero.

What SAM is and why we use it

AWS SAM (Serverless Application Model) is an extension of CloudFormation aimed specifically at serverless. You declare resources in a template.yaml file, but instead of writing hundreds of lines of raw CloudFormation for one Lambda function plus its role, log group and permissions, you use shorthand resource types like AWS::Serverless::Function. The line Transform: AWS::Serverless-2016-10-31 at the top of the template tells CloudFormation it needs to "expand" those shorthand types into full resources before creating them.

The SAM CLI is the accompanying command-line tool: sam build packages the code, sam deploy pushes it up, sam local invoke runs the function in a Docker container that mimics the real Lambda environment, sam delete removes the whole stack. We'll use all four right in this article.

Installing the SAM CLI — and a lesson about versions

On macOS, the fastest way to install is via Homebrew:

brew install aws-sam-cli

The version is worth watching. On the first build of this project, an old SAM CLI build (1.119.0) errored out right at the build step:

Build Failed
Error: 'nodejs22.x' runtime is not supported

The reason is simple: that SAM CLI build predates the nodejs22.x runtime being added to the esbuild build flow. Runtimes on AWS change faster than the tools on your machine, so before blaming the template, check the tool version. Upgrading to a newer build (1.161.0) made the build run immediately:

$ sam --version
SAM CLI, version 1.161.0

This is a class of error you'll hit repeatedly with serverless: the error message talks about the runtime, but the root cause is the tool not knowing about that runtime yet. Article 02 will look up the current runtime list directly from the docs so we don't guess.

Project skeleton

The minimal project for the URL shortener has four files. This is the structure we start from, and it grows over the articles:

serverless-url-shortener-aws/
├── template.yaml          # infrastructure declaration (SAM)
├── package.json           # build tooling (esbuild) + types
├── tsconfig.json          # TypeScript configuration
└── src/
    └── handlers/
        └── hello.ts       # Lambda function code

package.json only needs the build and type packages, no runtime dependency in this article (the Lambda runtime already ships with the AWS SDK):

{
  "name": "serverless-url-shortener-aws",
  "devDependencies": {
    "@types/aws-lambda": "^8.10.145",
    "@types/node": "^22.10.0",
    "esbuild": "^0.24.0",
    "typescript": "^5.7.2"
  }
}

A Lambda function is a TypeScript file that exports a handler function. AWS calls this function whenever there's an event, passing in event (here an HTTP request from the Function URL) and returning a response:

import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";

export const handler = async (
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
  const now = new Date().toISOString();
  return {
    statusCode: 200,
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      message: "Hello from a serverless URL shortener",
      path: event.rawPath,
      time: now,
    }),
  };
};

The infrastructure declaration lives in template.yaml. The Globals block sets defaults for every function (runtime, CPU architecture, memory, timeout), so you don't repeat them per function:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Runtime: nodejs22.x
    Architectures:
      - arm64
    MemorySize: 128
    Timeout: 5

Resources:
  HelloFunction:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: es2022
        EntryPoints:
          - hello.ts
    Properties:
      CodeUri: src/handlers
      Handler: hello.handler
      FunctionUrlConfig:
        AuthType: NONE

Outputs:
  HelloFunctionUrl:
    Value: !GetAtt HelloFunctionUrl.FunctionUrl

A few points worth pausing on. Architectures: [arm64] selects the Graviton CPU, cheaper than x86 and usually faster for Node code; article 02 covers this in detail. The Metadata.BuildMethod: esbuild block tells SAM to use esbuild to bundle the TypeScript into a single JavaScript file at build time. Handler: hello.handler means "in the file hello, call the export named handler". FunctionUrlConfig.AuthType: NONE creates a public HTTPS endpoint that calls straight into the function, no API Gateway needed; it's the simplest way to call a function over HTTP, enough for the first article (in later articles we move to API Gateway for proper routes, auth, and CORS).

Build

sam build reads the template, runs esbuild to bundle the code, and puts the result in the .aws-sam/build directory:

$ sam build
Building codeuri: .../src/handlers runtime: nodejs22.x architecture: arm64 functions: HelloFunction
 Running NodejsNpmEsbuildBuilder:EsbuildBundle
Build Succeeded
Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

From here on, every deploy or invoke command works on the built artifact in .aws-sam/build, not the source code.

Deploy

sam deploy \
  --stack-name url-shortener \
  --resolve-s3 \
  --capabilities CAPABILITY_IAM \
  --no-confirm-changeset \
  --region ap-southeast-1

Each flag here does something specific. --stack-name names the CloudFormation stack, the bag that holds every resource we create (delete the stack, delete it all). --resolve-s3 tells SAM to create and use a managed S3 bucket to hold the packaged code, since CloudFormation pulls code from S3 rather than taking it directly. --capabilities CAPABILITY_IAM is you confirming that the stack is allowed to create IAM roles (Lambda needs an execution role to run). --no-confirm-changeset skips the confirmation prompt so it runs non-interactively.

SAM computes a changeset (the list of changes), then applies it. The final output prints the values in Outputs:

CloudFormation outputs from deployed stack
-------------------------------------------------------------
Key                 HelloFunctionUrl
Value               https://vmriafelvovzbhuzwnqmggyflu0innum.lambda-url.ap-southeast-1.on.aws/

Successfully created/updated stack - url-shortener in ap-southeast-1

One command, and there's a Lambda function with an IAM role, a log group, and a public HTTPS endpoint running on AWS.

   your machine                         AWS
  ┌──────────────┐   sam build   ┌──────────────────┐
  │ template.yaml │─────────────▶│ .aws-sam/build   │
  │ src/hello.ts  │   (esbuild)   │ (bundled code)   │
  └──────────────┘               └────────┬─────────┘
                                          │ sam deploy --resolve-s3
                                          ▼
                                  ┌──────────────┐  CloudFormation
                                  │  S3 (artifact)│  reads code, then creates:
                                  └──────┬───────┘
                                         ▼
                          ┌──────────────────────────────┐
                          │ Lambda HelloFunction          │
                          │  + IAM execution role         │
                          │  + Function URL (public HTTPS) │
                          │  + CloudWatch log group       │
                          └──────────────────────────────┘

Calling the function

The Function URL is a real HTTPS endpoint, called with curl like any other API:

$ curl https://vmriafelvovzbhuzwnqmggyflu0innum.lambda-url.ap-southeast-1.on.aws/
{"message":"Hello from a serverless URL shortener","path":"/","time":"2026-05-25T16:05:15.130Z"}

$ curl https://vmriafelvovzbhuzwnqmggyflu0innum.lambda-url.ap-southeast-1.on.aws/abc
{"message":"Hello from a serverless URL shortener","path":"/abc","time":"2026-05-25T16:05:15.304Z"}

The path field changes with the URL we call because the handler reads event.rawPath. This is evidence that AWS packaged the HTTP request into event and passed it to the function.

Running it right on your machine

You don't always want to deploy to AWS just to test a small change. sam local invoke runs the function in a Docker container using the exact Lambda runtime image, so behavior is close to the real thing:

$ sam local invoke HelloFunction --event <(echo '{"rawPath":"/local-test"}')
Using local image: public.ecr.aws/lambda/nodejs:22-rapid-arm64.
START RequestId: 4ddde017-185f-479f-84a7-bb891a7986b5 Version: $LATEST
END RequestId: 4ddde017-185f-479f-84a7-bb891a7986b5
REPORT RequestId: 4ddde017-185f-479f-84a7-bb891a7986b5  Init Duration: 0.04 ms  Duration: 51.37 ms  Billed Duration: 52 ms  Memory Size: 128 MB  Max Memory Used: 128 MB
{"statusCode": 200, "headers": {"content-type": "application/json"}, "body": "{\"message\":\"Hello from a serverless URL shortener\",\"path\":\"/local-test\",\"time\":\"2026-05-25T16:05:41.803Z\"}"}

The REPORT line is worth noting because it's how Lambda reports each run, and we'll see it again throughout the series. Duration is how long the code ran, Billed Duration is the billed time (rounded up to the millisecond), Max Memory Used tells you how much the function actually used out of the allocated memory. Init Duration is the environment initialization time, the part related to cold start that articles 02 and 15 dissect in detail.

🧹 Cleanup

Since every resource sits in one CloudFormation stack, deleting it all is just one command:

$ sam delete --stack-name url-shortener --no-prompts --region ap-southeast-1
    - Deleting S3 object with key 8164374de92f3fab02f3c529558b49a7
    - Deleting Cloudformation stack url-shortener
Deleted successfully

Verify the stack is really gone:

$ aws cloudformation describe-stacks --stack-name url-shortener --region ap-southeast-1
An error occurred (ValidationError) ... Stack with id url-shortener does not exist

The "does not exist" error here is good news: no resources left, nothing still incurring cost. The habit of deleting the stack after each practice session is the surest way to never get a surprise bill.

Wrap-up

We now have a full working loop: write TypeScript code, sam build to bundle, sam deploy to push it up as a stack, call it via a Function URL or run it locally with sam local invoke, then sam delete to clean up. We also drew a lesson we'll keep using: when a runtime reports "not supported", suspect the tool version first.

The next article goes down one layer: how Lambda actually runs your code internally. We'll look at the execution environment lifecycle (init, invoke, shutdown), why cold start exists, the relationship between memory and CPU, and why we choose arm64. Once you understand this part, the performance and cost decisions in later articles have a basis.