DEV Community

Cover image for Build an Express like app on AWS Lambda
Matt Jarrett
Matt Jarrett

Posted on • Edited on

Build an Express like app on AWS Lambda

A dead simple Express-like Lambda hello world example ⚡ ☁️

What

I recently had a need to build a backend REST app and wanted to use a few simple routes via Express to serve my needs. Express is a commonly used backend in JavaScript/ Node.js. I wanted to run this on AWS via 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.

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.

Prerequisites

You'll need Node.js installed and an AWS account.

lambda-api

lambda-api offers a simple and lightweight solution that will look familiar to anyone that has spent time with 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.

npm init

Install lambda-api

npm install lambda-api

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

index.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 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

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

variables.tf

Create a variables.tf file with this content.

variables.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

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 🎉!

iam

We will use AWS 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

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 of the role for use in another module a little later.

lambda

We will use AWS 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

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

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_permissionexpress-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 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

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

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.

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

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

celebrate

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

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.

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:

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.

"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.

npm run build

For example:

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.

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 🎉

curl https://REDACTED.execute-api.us-east-1.amazonaws.com/v1
hello world

More example use:

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. 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.

Top comments (0)