Terraform is one of the most popular IaC tools available and allows you to create almost any AWS resource. As it's not one of AWS's 1st party IaC tools, it often doesn't get the same Developer Experience as other things like CDK and SAM (Serverless Application Model). However, in late 2022 AWS announced some SAM CLI support for Terraform and then in late 2023 this became generally available. In this blog post I will look at how we can write a Typescript Node Lambda Function with an API Gateway, run it locally and debug it.

AWS SAM comprises of two main components, SAM Templates and the SAM CLI. SAM Templates are an abstraction over Cloudformation (AWS's native IaC tool) and the SAM CLI which is a command line interface designed for making it easy to run, test and deploy serverless applications on AWS. Terraform is a cloud agnostic tool for defining and deploying infrastructure. It has a massive following across many clouds and a very active community for AWS resources. I'll assume you have some knowledge of Terraform, Lambda and Typescript as well as having Terraform and SAM CLI installed. The complete repository with a full working example can be found here.

A basic API Gateway with a Lambda integration would require us to define a single resource , method and integration on the API Gateway, as well as a single Lambda Function to handle the request. In order to use SAM and Terraform to run and debug locally you need a typical API Gateway Terraform definition and some small changes to the Lambda definition. The Serverless TF Project's Lambda Module makes it really easy to set up running your Lambda locally, so I'll use that.

module "api_demo" {
  source      = "terraform-aws-modules/lambda/aws"
  version     = "~> 7.2"
  memory_size = 512
  source_path = {
    path             = "../dist/",
    npm_requirements = "../package.json"
  }
  architectures       = ["arm64"]
  function_name       = "test"
  handler             = "index.handler"
  runtime             = "nodejs20.x"
  create_sam_metadata = true //This is important
}

Here I have defined everything I need in the Terraform definition for the Lambda function. The API Gateway definition references this module for the AWS Proxy integration. In the root directory of the Terraform module there is one additional file that I need to support the SAM CLI running this locally. By convention it needs to be called samconfig.yaml .

version: 0.1
default:
  global:
    parameters:
      hook_name: terraform
  build:
    parameters:
      terraform_project_root_path: ../

My Typescript file for the Lambda Function is very basic for the example:

import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-lambda"

export const handler = async (event: APIGatewayProxyEvent, context: Context): Promise<APIGatewayProxyResult> => {
    console.log('Appears in the debug console. It behaves the same as when we invoke the function on AWS');

    return {
        statusCode: 200,
        body: JSON.stringify({ message: 'Return what we want' }),
        headers: {
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Headers": "*",
            "Access-Control-Allow-Methods": "*",
            "Content-Type": "application/json"
        }
    }
}

Note this is a ECMA script module with a .mts extension. I use esbuild to transpile the Typescript down to a ECMA script module that Node will run.

My package.json file has a couple of specific commands in it:

{
  "name": "terraform-sam-typescript-local",
  "version": "1.0.0",
  "type":"module",
  "scripts": {
    "build:ci": "esbuild src/index.mts --bundle --minify --platform=node --format=esm --target=node20 --outfile=dist/index.mjs",
    "build:local": "esbuild src/index.mts --platform=node --format=esm --target=node20 --watch --outfile=dist/index.mjs",
    "start:debug": "cd terraform && sam local start-api -d 5858",
    "start:local": "cd terraform && sam local start-api"
  },
  "author": "Ryan Cormack",
  "devDependencies": {
    "@types/aws-lambda": "^8.10.132",
    "esbuild": "^0.19.12",
    "typescript": "^5.3.3"
  }
}

I've got 2 build commands. One I would use in CI (continuous integration) and one I'm going to use for local development. I'm outputing my index.mjs file into a dist/ folder, which the Lambda definition above was referencing. I'm also adding the --watch flag for esbuild so any changes I make will be auto updated. The SAM tooling supports live reload, which can be really helpful. Then I've got the SAM CLI commands, one to start the API and one to start the API in debug mode. Because building in watch mode and starting the API are both long running Node processes with useful output in the console, I would recommend starting them in separate shells so you can see the output from both.

By running the SAM command, SAM will plan your Terraform and start up the API Gateway and Lambda function inside a Docker container. The Docker container will emulate the API Gateway service and the Lambda service so you can see your Lambda logs in that console.

START RequestId: 56faa3b7-8049-4130-87f1-8c381f549f00 Version: $LATEST
2024-01-27T13:54:40.432Z        b6df72a9-c77f-49d3-bc97-965b9a1b54c8    INFO    Appears in the debug console. It behaves the same as when we invoke the function on AWS
END RequestId: b6df72a9-c77f-49d3-bc97-965b9a1b54c8
REPORT RequestId: b6df72a9-c77f-49d3-bc97-965b9a1b54c8  Init Duration: 0.17 ms  Duration: 109.79 ms     Billed Duration: 110 ms Memory Size: 512 MB     Max Memory Used: 512 MB

2024-01-27 13:54:42 127.0.0.1 - - [27/Jan/2024 13:54:42] "GET /hello HTTP/1.1" 200 -
2024-01-27 13:54:42 127.0.0.1 - - [27/Jan/2024 13:54:42] "GET /favicon.ico HTTP/1.1" 403 -

Here is an example of the Lambda Logs and the HTTP response code that come from the emulated API Gateway. Because esbuild is watching the Typescript file, any time you save a change the updated javascript output will be copied to the Docker container hosting your Lambda Function. This allows you to develop your function locally and start building your front end applications running against a local copy of the API.

If you want to debug the Lambda Function you can restart the SAM CLI but pass the -d flag and a port. That will expose the Container instance over that port and allow a debugger to be attached to it. Using Visual Studio Code we can then create a launch configuration like:

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "attach",
            "name": "Debug API local",
            "port": 5858,
            "address": "localhost",
            "localRoot": "${workspaceFolder}",
            "remoteRoot": "/var/task/"
        }
    ]
}

Here I have configured the debug port to be the same as I exposed in the local API Gateway SAM command. The remote root is /var/task which is where your Lambda Function code is invoked from by default and we are telling the debugger that we're attaching to a Node process. Once we've started SAM start-api with the debugger port exposed, we can run this Launch Configuration in VS Code and we will hit our break point. This will allow us to debug against the transpiled javascript, so making sure esbuild doesn't minify or bundle the code can really help.

Whilst I would still advocate for a strong test harness to debug local code and test against real cloud infrastructure rather than emulating it locally, this approach can have some nice benefits. It allows you to have your Terraform'd API Gateway and Lambda function running locally, allowing you to quickly iterate your backend code and debug locally. Terraform doesn't support some of the other great features of the CDK or SAM like hot swapping, so the feedback loop against real infrastructure can often be tedious and slow. This approach should hopefully help speed that up.