Build an Express like app on AWS Lambda
Tags: javascript, aws, lambda, terraform
Published: 2020-10-06 13:52:52

A dead simple Express-like Lambda hello world example :zap: :cloud:

## What
I recently had a need to build a backend REST app and wanted to use a few simple routes via [Express](https://github.com/expressjs/express) to serve my needs. Express is a commonly used backend in JavaScript/ Node.js. I wanted to run this on AWS via [Lambda](https://aws.amazon.com/lambda/) for all of the benefits of having a serverless architecture. This article is the result of what I learned making it work. You should be able to follow this example and fall well within the [AWS free tier](https://aws.amazon.com/free/).

I had two goals in mind:

1. I want to create and manage my infrastructure with Terraform.
2. I want to use simple Express-like back end.

## How much work is it?

The JavaScript portion of this hello world example is simple with more of the lines of code being Terraform to create and wire up the AWS services. Both are outlined below. I also have all of the code for this example on [my GitHub](https://github.com/cujarrett/express-like-lambda-example).

## Prerequisites
You'll need [Node.js](https://nodejs.org/en/) installed and an [AWS account](https://aws.amazon.com/account/).

## lambda-api

[lambda-api](https://github.com/jeremydaly/lambda-api) offers a simple and lightweight solution that will look familiar to anyone that has spent time with [Express](https://github.com/expressjs/express). Building a solution with `lambda-api` provides a single dependency solution that is tiny at 28 kB.

Start a new Node project with [npm init](https://docs.npmjs.com/cli/init).

```sh
npm init
```

Install `lambda-api`

```sh
npm install lambda-api
```

Now create a index.js file in project with this content.

index.js
```js
// Require the framework and instantiate it
const api = require("lambda-api")()

// Define a route
api.get("/", async (req, res) => {
  console.log("hello world")
  return "hello world"
})

api.get("/foo", async (req, res) => {
  console.log("/foo hit")
  return "/foo hit"
})

api.get("/bar", async (req, res) => {
  console.log("/bar hit")
  return "/bar hit"
})

// Declare your Lambda handler
exports.handler = async (event, context) => {
  return await api.run(event, context)
}
```

`lambda-api` makes the routes simple via `get()`, `post()`, `put()` to name a few options.

## Terraform

In order to make this work with AWS Lambda, you need to expose the Lambda through an API Gateway. I wanted to use Terraform for building, deploying, and deleting my infrastructure. This is known as infrastructure as code (IaC). Using Terraform will provides us several benifits:

1. Orchestration, not merely configuration
2. Immutable infrastructure
3. Declarative, not procedural code
4. Speed to create, re-create, change, or delete infrastructure.

For simplicity in this hello world example, I will use my local machine to store the Terraform state but would recommend [Terraform Cloud](https://www.terraform.io/docs/cloud/overview.html) for actual app infrastructure state storage. Never upload your state to GitHub or your choice of repository.

Create a `terraform` folder in your project. In that folder, run `terraform init` to initialize a working directory containing Terraform configuration files. This is the first command that should be run after writing a new Terraform configuration or cloning an existing one from version control. It is safe to run this command multiple times.

#### provider.tf

Create a `provider.tf` file with this content.

provider.tf
```tf
provider "aws" {
  version = "~> 3.0"
  region = var.aws-region
}
```

#### variables.tf

Create a `variables.tf` file with this content.

variables.tf
```tf
variable "aws-region" {
  description = "AWS region for the infrastructure"
  type = string
  default = "us-east-1"
}
```

### Modules

We are going to use modules to organize the IaC. Inside the `terraform` folder, create a `modules` folder. We'll create a handful of modules within that.

#### archive_file

Inside of the terraform/modules folder, let's create a folder called `archive`.

In the terraform/modules/archive folder create a `main.tf` file with this content.

main.tf
```tf
data "archive_file" "placeholder" {
  type = "zip"
  output_path = "${path.module}/lambda-function-payload.zip"

  source {
    content  = "placeholder"
    filename = "placeholder.txt"
  }
}

output "data-archive-file-placeholder-output-path" {
  value = data.archive_file.placeholder.output_path
}
```

We use `archive_file` which generates an archive from content, a file, or directory of files. It will hold a placeholder text file used when creating the Lambda below. This is done to separate the creation, updating, and deletion of the infrastructure from the deployment of the code in the deployment stage of a CI/CD pipeline. Yay, clean seperation :tada:!

#### iam

We will use [AWS IAM](https://aws.amazon.com/iam/) to manage access to AWS services and resources securely. Using IAM, you can create and manage AWS users and groups, and use permissions to allow and deny their access to AWS resources.

Inside of the terraform/modules folder, let's create a folder called `iam`.

In the terraform/modules/iam folder create a `main.tf` file with this content.

main.tf
```tf
resource "aws_iam_role" "express-like-lambda-example" {
  name               = "express-like-lambda-example"
  assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": {
    "Action": "sts:AssumeRole",
    "Principal": {
      "Service": "lambda.amazonaws.com"
    },
    "Effect": "Allow"
  }
}
POLICY
}

resource "aws_iam_policy" "express-like-lambda-example-logs" {
  name        = "express-like-lambda-example-logs"
  description = "Adds logging access"

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "attach-logs" {
  role       = aws_iam_role.express-like-lambda-example.name
  policy_arn = aws_iam_policy.express-like-lambda-example-logs.arn
}

output "aws-iam-role-express-like-lambda-example-arn" {
  value = aws_iam_role.express-like-lambda-example.arn
}
```

The `aws_iam_role` `express-like-lambda-example` sets up the role for the Lambda we're going to use. After that we set a `aws_iam_policy` `express-like-lambda-example-logs` which adds logging access to the Lambda. We use a `aws_iam_role_policy_attachment` called `attach-logs` to attach the policy to the role. Lastly, we output the [arn](https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html) of the role for use in another module a little later.

#### lambda

We will use [AWS Lambda](https://aws.amazon.com/lambda/) to run our code without provisioning or managing servers. You pay only for the compute time you consume.

With Lambda, you can run code for virtually any type of application or backend service - all with zero administration. Lambda takes care of everything required to run and scale your code with high availability.

Inside of the terraform/modules folder, let's create a folder called `lambda`.

In the terraform/modules/lambda create a `variables.tf` file with this content.

variables.tf
```tf
variable "aws-iam-role-express-like-lambda-example-arn" {
  description = "IAM role ARN"
  type = string
}

variable "data-archive-file-placeholder-output-path" {
  description = "Placeholder content for Lambda"
  type = string
}
```

The first variable is the `arn` of the `iam` `role` from above. The second variable is the output path of the `archive` file from above. Both are needed in this example to create the Lambda. 

In the terraform/modules/lambda folder create a `main.tf` file with this content.

main.tf
```tf
resource "aws_lambda_function" "express-like-lambda-example" {
  filename = var.data-archive-file-placeholder-output-path
  function_name = "express-like-lambda-example"
  handler       = "index.handler"
  role          = var.aws-iam-role-express-like-lambda-example-arn
  runtime       = "nodejs12.x"
  memory_size   = 128
  timeout       = 1
}

resource "aws_lambda_function_event_invoke_config" "express-like-lambda-example-event-invoke-config" {
  function_name = aws_lambda_function.express-like-lambda-example.arn
  maximum_event_age_in_seconds = 60
  maximum_retry_attempts       = 0
}

resource "aws_lambda_permission" "express-like-lambda-example" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.express-like-lambda-example.arn
  principal     = "apigateway.amazonaws.com"
}

output "aws-lambda-function-express-like-lambda-example-arn" {
  value = aws_lambda_function.express-like-lambda-example.arn
}

output "aws-lambda-function-express-like-lambda-example-invoke-arn" {
  value = aws_lambda_function.express-like-lambda-example.invoke_arn
}
```

The `aws_lambda_function` `express-like-lambda-example` creates the Lambda function. The `filename` used is from the `archive` above by using the variable we defined in terraform/modules/lambda/variables.tf. The `aws_lambda_function_event_invoke_config` `express-like-lambda-example-event-invoke-config` allows us to define the maximim age in seconds to allow the function to run for and the maximum retry attempts. The `aws_lambda_permission``express-like-lambda-example` allows the Lambda to be to be executed via API Gateway. Finally, we output Lambda `arn` and `invoke_arn` to be used later when we set up the API Gateway.

#### api-gateway

Home stretch, hang in there...

We will use [AWS API Gateway](https://aws.amazon.com/api-gateway/) to create our RESTful API. API Gateway handles all the tasks involved in accepting and processing up to hundreds of thousands of concurrent API calls, including traffic management, CORS support, authorization and access control, throttling, monitoring, and API version management. API Gateway has no minimum fees or startup costs. You pay for the API calls you receive and the amount of data transferred out.

Inside of the terraform/modules folder, let's create a folder called `api-gateway`.

In the terraform/modules/api-gateway create a `variables.tf` file with this content.

variables.tf
```tf
variable "aws-lambda-function-express-like-lambda-example-arn" {
  description = "express-like-lambda-example Lambda ARN"
  type = string
}

variable "aws-lambda-function-express-like-lambda-example-invoke-arn" {
  description = "express-like-lambda-example Lambda invoke ARN"
  type = string
}
```

The first variable specifies the Lambda `arn` and the second specifies the Lambda `invoke_arn`.

In the terraform/modules/iam-gateway folder create a `main.tf` file with this content.

main.tf
```tf
resource "aws_api_gateway_rest_api" "express-like-lambda-example" {
  name = "express-like-lambda-example"
}

resource "aws_api_gateway_method" "proxy-root" {
  rest_api_id   = aws_api_gateway_rest_api.express-like-lambda-example.id
  resource_id   = aws_api_gateway_rest_api.express-like-lambda-example.root_resource_id
  http_method   = "ANY"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "express-like-lambda-example" {
  rest_api_id             = aws_api_gateway_rest_api.express-like-lambda-example.id
  resource_id             = aws_api_gateway_method.proxy-root.resource_id
  http_method             = aws_api_gateway_method.proxy-root.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = var.aws-lambda-function-express-like-lambda-example-invoke-arn
}

resource "aws_api_gateway_resource" "proxy" {
  rest_api_id = aws_api_gateway_rest_api.express-like-lambda-example.id
  parent_id   = aws_api_gateway_rest_api.express-like-lambda-example.root_resource_id
  path_part   = "{proxy+}"
}

resource "aws_api_gateway_method" "proxy" {
  rest_api_id   = aws_api_gateway_rest_api.express-like-lambda-example.id
  resource_id   = aws_api_gateway_resource.proxy.id
  http_method   = "ANY"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "lambda" {
  rest_api_id             = aws_api_gateway_rest_api.express-like-lambda-example.id
  resource_id             = aws_api_gateway_method.proxy.resource_id
  http_method             = aws_api_gateway_method.proxy.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = var.aws-lambda-function-express-like-lambda-example-invoke-arn
}

resource "aws_api_gateway_deployment" "express-like-lambda-example_v1" {
  depends_on = [
    aws_api_gateway_integration.express-like-lambda-example
  ]
  rest_api_id = aws_api_gateway_rest_api.express-like-lambda-example.id
  stage_name  = "v1"
}

output "endpoint" {
  value = aws_api_gateway_deployment.express-like-lambda-example_v1.invoke_url
}
```

Here we are setting up the Lambda Proxy Integration option in API Gateway that allows the details of an API request to be passed as the event parameter of a Lambda function.

`lambda-api` automatically parses this information to create a normalized REQUEST object. The request can then be routed using `lambda-api`'s methods.

`aws_api_gateway_rest_api` provides an API Gateway REST API. `aws_api_gateway_method` provides a HTTP Method for an API Gateway Resource. `aws_api_gateway_integration` provides an HTTP Method Integration for an API Gateway Integration. `aws_api_gateway_resource` provides an API Gateway Resource. `aws_api_gateway_deployment` provides an API Gateway REST Deployment. Lastly, we output the URL to invoke the API.

#### main.tf

We now need to tie all of the Terraform we've made together. In the terraform folder create a `main.tf` file with this content.

```tf
module "archive" {
  source = "./modules/archive"
}

module "iam" {
  source = "./modules/iam"
}

module "lambda" {
  source = "./modules/lambda"
  data-archive-file-placeholder-output-path = module.archive.data-archive-file-placeholder-output-path
  aws-iam-role-express-like-lambda-example-arn = module.iam.aws-iam-role-express-like-lambda-example-arn
}

module "api-gateway" {
  source = "./modules/api-gateway"
  aws-lambda-function-express-like-lambda-example-arn = module.lambda.aws-lambda-function-express-like-lambda-example-arn
  aws-lambda-function-express-like-lambda-example-invoke-arn = module.lambda.aws-lambda-function-express-like-lambda-example-invoke-arn
}

# Set the generated URL as an output. Run `terraform output url` to get this.
output "endpoint" {
  value = module.api-gateway.endpoint
}
```

This chains together all of the modules we've written and completes the declarative infrastructure with Terraform.

## Running the code

### Deploying the Infrastructure

:tada: You made it this far! Let's play with the code you've made! :tada:

![celebrate](https://media.giphy.com/media/l0MYt5jPR6QX5pnqM/giphy.gif)

We're going to run some Terraform commands to deploy the infrastructure.

```sh
terraform plan
```

The terraform plan command is used to create an execution plan. This command is a convenient way to check whether the execution plan for a set of changes matches your expectations without making any changes to real resources or to the state.

That should work without issue so you can move onto applying this Terraform plan.

```sh
terraform apply
```

The `terraform apply` command is used to apply the changes required to reach the desired state of the configuration, or the pre-determined set of actions generated by a `terraform plan` execution plan.

You will need to confirm this apply with a `yes` when prompted. Take the time to read what is about to be created before you enter `yes`. It will show you what is about to be created.

For example:

```txt
terraform apply

...

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

module.iam.aws_iam_policy.express-like-lambda-example-logs: Creating...
module.iam.aws_iam_role.express-like-lambda-example: Creating...
module.api-gateway.aws_api_gateway_rest_api.express-like-lambda-example: Creating...
module.iam.aws_iam_role.express-like-lambda-example: Creation complete after 0s [id=express-like-lambda-example]
module.lambda.aws_lambda_function.express-like-lambda-example: Creating...
module.iam.aws_iam_policy.express-like-lambda-example-logs: Creation complete after 1s [id=arn:aws:iam::REDACTED:policy/express-like-lambda-example-logs]
module.api-gateway.aws_api_gateway_rest_api.express-like-lambda-example: Creation complete after 1s [id=REDACTED]
module.iam.aws_iam_role_policy_attachment.attach-logs: Creating...
module.api-gateway.aws_api_gateway_resource.proxy: Creating...
module.api-gateway.aws_api_gateway_method.proxy-root: Creating...
module.api-gateway.aws_api_gateway_method.proxy-root: Creation complete after 0s [id=REDACTED-ANY]
module.iam.aws_iam_role_policy_attachment.attach-logs: Creation complete after 0s [id=express-like-lambda-example-REDACTED]
module.api-gateway.aws_api_gateway_resource.proxy: Creation complete after 1s [id=REDACTED]
module.api-gateway.aws_api_gateway_method.proxy: Creating...
module.api-gateway.aws_api_gateway_method.proxy: Creation complete after 0s [id=REDACTED-ANY]
module.lambda.aws_lambda_function.express-like-lambda-example: Still creating... [10s elapsed]
module.lambda.aws_lambda_function.express-like-lambda-example: Creation complete after 16s [id=express-like-lambda-example]
module.lambda.aws_lambda_permission.express-like-lambda-example: Creating...
module.lambda.aws_lambda_function_event_invoke_config.express-like-lambda-example-event-invoke-config: Creating...
module.api-gateway.aws_api_gateway_integration.lambda: Creating...
module.api-gateway.aws_api_gateway_integration.express-like-lambda-example: Creating...
module.lambda.aws_lambda_permission.express-like-lambda-example: Creation complete after 0s [id=AllowAPIGatewayInvoke]
module.api-gateway.aws_api_gateway_integration.express-like-lambda-example: Creation complete after 0s [id=REDACTED-ANY]
module.api-gateway.aws_api_gateway_deployment.express-like-lambda-example_v1: Creating...
module.api-gateway.aws_api_gateway_integration.lambda: Creation complete after 0s [id=REDACTED-ANY]
module.lambda.aws_lambda_function_event_invoke_config.express-like-lambda-example-event-invoke-config: Creation complete after 0s [id=arn:aws:lambda:us-east-1:REDACTED:function:express-like-lambda-example]
module.api-gateway.aws_api_gateway_deployment.express-like-lambda-example_v1: Creation complete after 1s [id=REDACTED]

Apply complete! Resources: 13 added, 0 changed, 0 destroyed.

Outputs:

endpoint = https://REDACTED.execute-api.us-east-1.amazonaws.com/v1
```

Copy or remember the endpoint from the output for use in a bit.

### Deploying the App

Open the `package.json` and create this [npm script](https://docs.npmjs.com/misc/scripts).

```js
"scripts": {
    "build": "npm install --production && rm -rf build && mkdir build && zip -r -q -x='*terraform*' -x='*.md' -x='LICENSE' -x='*build*' -x='*.DS_Store*' -x='*.git*' build/express-like-lambda-example.zip . && du -sh build"
  },
```

Now in root of the project you can run the build command to build the zip file in preparation to deploy it to the Lambda we created.

```sh
npm run build
```

For example:

```sh
npm run build

> express-like-lambda-example@1.0.0 build /Users/REDACTED/Development/express-like-lambda-example
> npm install --production && rm -rf build && mkdir build && zip -r -q -x='*media*' -x='*terraform*' -x=*coverage* -x='*.md' -x='LICENSE' -x='*build*' -x='*.DS_Store*' -x='*.git*' build/express-like-lambda-example.zip . && du -sh build

audited 1 package in 0.916s
found 0 vulnerabilities

 28K    build
```

Now we can deploy our zipped app to Lambda. For that I use this command.

```sh
aws lambda update-function-code --function-name=express-like-lambda-example --zip-file=fileb://build/express-like-lambda-example.zip --region=us-east-1 1> /dev/null
```

## Calling the API

Now we can hit our API :tada:

```sh
curl https://REDACTED.execute-api.us-east-1.amazonaws.com/v1
hello world
```
More example use:
```sh
curl https://REDACTED.execute-api.us-east-1.amazonaws.com/v1/foo
/foo hit

curl https://REDACTED.execute-api.us-east-1.amazonaws.com/v1/bar
/bar hit

curl https://REDACTED.execute-api.us-east-1.amazonaws.com/v1/baz
{"error":"Route not found"}
```

Note, your URL will differ from the one above. It's unique each deployment. Your URL will come from the output of the `terraform apply`.

## Conclusion

I had fun. I learned a little more Terraform and about a rad Node.js package [lambda-api](https://github.com/jeremydaly/lambda-api). If I made any mistakes I can learn from I'm happy to learn of those in the comments. If you have any questions please feel free to ask.