Post

Building a Modern Serverless Workflow: Node.js, TypeScript, Serverless Framework, and AWS Lambda with Terraform

Hey folks, it's time to dive into something practical and super useful! 🚀

Today I’ll walk you through how to deploy a Node.js Lambda function to AWS using the Serverless Framework and Terraform. This setup is perfect if you want a scalable and modern serverless application that plays nicely with infrastructure-as-code.

Building a Modern Serverless Workflow: Node.js, TypeScript, Serverless Framework, and AWS Lambda with Terraform

Project Overview

This project is a template for developing AWS Lambda functions using modern JavaScript tooling. It leverages:

  • Node.js for the runtime environment.
  • TypeScript for type safety and maintainability.
  • Serverless Framework to define and organize Lambda functions and their event sources.
  • Terraform for infrastructure-as-code, handling the provisioning of AWS resources.
  • Docker for local development and environment parity.
  • Jest for unit testing.

The codebase is organized to separate application logic (src/), tests (tests/), and infrastructure (terraform/). Supporting files like serverless.yml, tsconfig.json, and Docker-related configs enable a smooth developer experience.


How It Works

1. Application Layer

All business logic and Lambda handlers are written in TypeScript under the src/ directory. TypeScript is compiled to JavaScript before deployment, ensuring compatibility with AWS Lambda’s Node.js runtime.

Here’s the actual Lambda handler from this project:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { APIGatewayProxyEvent, Context } from "aws-lambda";
import ExampleController from "./example-controller";

export const handler = async (event: APIGatewayProxyEvent | any, context: Context | any): Promise<any> => {
    try {
        const { httpMethod } = event;
        const action = event.pathParameters.proxy.includes('/') ? event.pathParameters.proxy.split('/')[0] : event.pathParameters.proxy;
        if (!methods[httpMethod.toLowerCase()] || !methods[httpMethod.toLowerCase()][action]) {
            return {
                statusCode: 404,
                body: JSON.stringify({ message: 'Not found' }),
            };
        }
        const response = await methods[httpMethod.toLowerCase()][action](event, context);
        return response;
    } catch (error) {
        return {
            statusCode: 500,
            body: JSON.stringify({ message: 'An error occurred' }),
        };
    }
};

const example_controller = new ExampleController();

const methods: any = {
    post: {
        hello: async (event: APIGatewayProxyEvent, context: Context) => await example_controller.helloWorld(event, context),
    },
    get: {
        hello: async (event: APIGatewayProxyEvent, context: Context) => await example_controller.helloWorld(event, context),
    },
    put: {
        hello: async (event: APIGatewayProxyEvent, context: Context) => await example_controller.helloWorld(event, context),
    },
    delete: {
        hello: async (event: APIGatewayProxyEvent, context: Context) => await example_controller.helloWorld(event, context),
    }
}

The serverless.yml file describes the Lambda functions, their handlers, and event triggers. Here’s the actual configuration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
service: lambda-example

provider:
  name: aws
  runtime: nodejs18.x
  stage: dev
  region: us-east-1

plugins:
  - serverless-plugin-typescript
  - serverless-offline

package:
  individually: false

functions:
  hello:
    handler: src/index.handler
    events:
      - http:
          path: /
          method: get
      - http:
          path: /
          method: post
      - http:
          path: /
          method: put
      - http:
          path: /
          method: delete
      - http:
          path: /{proxy+}
          method: any

custom:
  serverless-offline:
    httpPort: 3000
    host: 0.0.0.0
    reloadHandler: true

This abstraction allows developers to focus on code, while the framework handles packaging and wiring up AWS resources.

2. Local Development

Local development is streamlined with several tools:

  • Serverless Offline: Emulates AWS Lambda and API Gateway locally, allowing you to invoke functions and test endpoints without deploying to AWS.
  • Docker: The included Dockerfile.dev and docker-compose.yml files let you spin up a development container that mirrors the Lambda environment.

Here’s the actual Dockerfile used for development:

1
2
3
4
FROM node:22-alpine
COPY . /app
WORKDIR /app
RUN npm install
  • Jest: Unit tests live in the tests/ directory and can be run with npm test, ensuring code quality before deployment.

The TypeScript configuration is managed in tsconfig.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
    "compilerOptions": {
        "target": "es2016",
        "lib": [
            "es2016"
        ],
        "module": "commonjs",
        "rootDir": "src",
        "allowJs": true,
        "outDir": ".build/src",
        "esModuleInterop": true,
        "forceConsistentCasingInFileNames": true,
        "strict": true,
        "noImplicitAny": true,
        "skipLibCheck": true,
        "resolveJsonModule": true,
        "moduleResolution": "node",
        "types": [
            "node",
            "jest"
        ]
    },
    "ts-node": {
        "compilerOptions": {
            "module": "ESNext",
            "resolveJsonModule": true
        }
    },
    "ts-jest": {
        "diagnostics": {
            "warnOnly": true
        }
    },
    "exclude": [
        "tests",
        "node_modules",
        "build",
        "**/*.js"
    ]
}

3. Infrastructure as Code

While the Serverless Framework can deploy resources directly, this project uses Terraform for infrastructure management. All AWS resources (including the Lambda function, IAM roles, and API Gateway) are defined in HCL under terraform/.

Here’s the actual Lambda resource definition:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = [
      "sts:AssumeRole"
    ]
    effect = "Allow"
    principals {
        type        = "Service"
        identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "lambda_role" {
  name               = "lambda_role"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/../dist/"
  output_path = "${path.module}/../lambda.zip"
}

resource "aws_lambda_function" "example_lambda" {
  function_name = "example_lambda"
  role          = aws_iam_role.lambda_role.arn
  handler       = "src/index.handler"
  runtime       = "nodejs18.x"
  filename      = data.archive_file.lambda_zip.output_path

  environment {
    variables = {
      EXAMPLE_VAR = "example_value"
    }
  }

  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
}

This approach provides greater control and visibility over infrastructure, and makes it easy to manage resources outside the scope of Serverless.

4. Deployment Workflow

A sample build-lambda.sh script is included to automate the build and packaging process. It compiles TypeScript, bundles the code, and prepares a ZIP file for deployment.

Here’s the actual script:

1
2
3
4
5
6
7
8
9
#!/bin/bash

# This script builds the AWS Lambda function for the Node.js example project.
npm run build
mkdir -p ./dist
cp package.json ./dist/package.json
cd ./dist
npm install --omit=dev
cp -r ../.build/* ./

Important: This script is for demonstration only. In production, you should use a CI/CD pipeline (such as GitHub Actions, GitLab CI, or AWS CodePipeline) to automate builds, tests, and deployments securely and reliably.

Deployment is handled by Terraform:

1
2
3
cd terraform
terraform init
terraform apply

Terraform provisions the Lambda function and any associated AWS resources.


Local Configuration

To get started locally:

  • Install dependencies with npm install.
  • Use npx serverless offline to emulate AWS Lambda and test endpoints.
  • Optionally, use Docker Compose to run the development environment in a container.
  • Run tests with npm test.

The project is designed to be developer-friendly, with clear separation of concerns and support for modern tooling.


Why This Stack?

  • TypeScript improves code quality and maintainability.
  • Serverless Framework abstracts away much of the AWS boilerplate, letting you focus on business logic.
  • Terraform provides a single source of truth for infrastructure, making it easy to manage, version, and review changes.
  • Docker ensures consistency between local and production environments.

Lessons Learned

  • Combining Serverless Framework and Terraform gives you the best of both worlds: rapid development and robust infrastructure management.
  • Investing in local emulation (via Serverless Offline and Docker) pays off in faster feedback loops and fewer surprises in production.
  • Automating deployment with CI/CD is essential for real-world projects; scripts are useful for illustration, but pipelines are the standard.

Conclusion

This project serves as a blueprint for building, testing, and deploying serverless applications with modern tools. By combining Node.js, TypeScript, Serverless Framework, and Terraform, you can create scalable, maintainable, and production-ready AWS Lambda functions with confidence.

Feel free to explore the codebase, adapt it for your own needs, and integrate these patterns into your workflow!

Check the Repository!

This post is licensed under CC BY 4.0 by the author.