Published ON
November 26, 2024
Updated on
November 22, 2024
CREATED ON
November 20, 2024

[101] Launch Your Own Secure Serverless API on AWS with Terraform

Introduction

We’ve created a hands-on tutorial to guide you through deploying a Node.js REST API with CRUD endpoints using Terraform. This setup incorporates AWS Cognito for authentication, DynamoDB for storage, and shared layers to optimize Lambda function builds and deployments.

In this tutorial, you’ll learn to deploy an API that manages “epic failures” — a whimsical term we’re using for noteworthy blunders, slip-ups, or moments of learning from failure. Think of it as a digital notebook for storing and retrieving your most memorable mistakes (or those of others). We are going to set up HTTP methods for sign-up, sign-in, creating, retrieving, and deleting these "epic failures."

This is the first part of a series of tutorials that will help you build a solid foundation in serverless development with Terraform and will provide you with a fully operational backend to play with.

Figure 1: High-level architecture of the serverless API we are going to build.

The Serverless Concept

Serverless is a development paradigm that eliminates the need for developers to manage underlying servers. Instead of provisioning and maintaining infrastructure, we focus solely on writing code, while cloud providers handle scaling, availability, and infrastructure management. AWS Lambda is one of the key services that implements this serverless concept. It allows us to run code in response to events without worrying about server maintenance. This approach is particularly advantageous for APIs, as it supports rapid scaling, reduces operational overhead, and follows a pay-as-you-go model, ensuring cost-efficiency for workloads of varying demand.

Terraform in a Nutshell

Terraform is an open-source tool that allows us to define and provision infrastructure using a high-level configuration language. Unlike manual setup, Terraform makes it easy to create reusable, maintainable configurations for your projects, which saves time and effort.

If you have previous experiences with Serverless Framework, Terraform can not only match in functionality but shines when it comes to flexibility and reusability across your infrastructure.

Set up your development environment

Before deploying this project, ensure that you have the following tools installed and configured on your machine:

Node.js and npm

Download and install Node.js (npm is included in the Node.js installation).
Verify the installation by running:

node -v
npm -v

yarn

Yarn is a popular alternative to npm for managing dependencies. Follow the installation guide for Yarn. Verify the installation with:

yarn -v

Terraform CLI

Terraform is required to deploy AWS resources. Download and install Terraform following the installation guide.

Check if Terraform is correctly installed by running:

terraform -v

AWS Account

You will need an AWS account with the AWS CLI properly configured on your machine to deploy resources. Create an AWS account if you don't have one. After creating your account, install the AWS CLI: AWS CLI installation guide. Then configure it by running:

aws configure

Once you have these prerequisites in place, you’ll be ready to proceed with the project setup and deployment.

Part 1: Deploy your first serverless method using Terraform

Let's start simple. No authentication, no database, just a simple Lambda function that returns a message. This will help you understand the basic structure of a serverless project and how to deploy it using Terraform.

Figure 2: High-level architecture of the serverless API we are going to build in the first part of the tutorial.

First, clone the project repository from GitHub. Open a terminal and run:

git clone https://github.com/move2edge/blog-serverless-api-using-terraform.git

Please navigate to the 101 directory:

cd blog-serverless-api-using-terraform/101

In the root directory you will find two main folders:

  • terraform: Contains the Terraform configuration files for deploying the API on AWS.
  • epicfailure-api: Contains the Node.js application code for the serverless API.

Understanding the Node.js application

The code is structured as follows:

epicfailure-api
├── src
   ├── handlers
      └── create-epic-failure-handler.ts
   └── models
       └── EpicFailure.ts
├── build-handlers.js
├── eslint.config.mjs
├── package.json
├── tsconfig.json
└── yarn.lock

Let's take a look at the create-epic-failure-handler.ts file:

// epicfailure-api/src/handlers/create-epic-failure-handler.ts

// This file defines the AWS Lambda handler for a dummy epic failure creation.
// It validates the request body against a schema and creates an epic failure object.

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import EpicFailure, { IEpicFailure } from '../models/EpicFailure';
import * as Joi from 'joi';

const epicFailureSchema = Joi.object<IEpicFailure>({
  failureID: Joi.string().required(),
  taskAttempted: Joi.string().required(),
  whyItFailed: Joi.string().required(),
  lessonsLearned: Joi.array().items(Joi.string()).required(),
});

const createEpicFailureHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  console.log('Event: ', event);

  // Parse the request body
  const requestBody = JSON.parse(event.body || '{}');

  // Validate the request body against the schema
  const { error, value } = epicFailureSchema.validate(requestBody);
  if (error) {
    return {
      statusCode: 400,
      body: JSON.stringify({ message: 'Invalid request body', error: error.details[0].message }),
    };
  }

  const { failureID, taskAttempted, whyItFailed, lessonsLearned } = value as IEpicFailure;

  const epicFailure = new EpicFailure(failureID, taskAttempted, whyItFailed, lessonsLearned);

  console.log('Creating epic failure:', epicFailure);
  return {
    statusCode: 201,
    body: JSON.stringify({ message: 'Epic failure created successfully', epicFailure }),
  };
};

exports.handler = createEpicFailureHandler;

As you can see we are using Joi - external npm library to validate the request body. The EpicFailure class and its interface is defined in the models/EpicFailure.ts file. After validation of the request body, the handler creates an EpicFailure object, prints it and returns a message stating that the failure record is created (however not persisted anywhere yet).

Bundling the TypeScript code and building the project

To execute the Lambda, the handler needs access not only to its own code but also to all imports from other project files and external dependencies. In this first part of the tutorial, we’ll focus on building the project by bundling the TypeScript code into a single JavaScript file. Since we’re working with just one Lambda function for now, there’s no need to worry about shared layers or deployment optimizations.

build-handlers.js is a script that uses esbuild to compile the TypeScript code into JavaScript.

// epicfailure-api/sbuild-handlers.js
// This script uses esbuild to bundle each handler along with its dependencies into a single file.
// It reads the handlers directory, filters out unnecessary files, and bundles each handler into the dist directory.

const fs = require('fs');
const path = require('path');
const esbuild = require('esbuild');

const handlersDir = path.join(__dirname, 'src', 'handlers');
const outputDir = path.join(__dirname, 'dist', 'handlers');

if (!fs.existsSync(outputDir)) {
  fs.mkdirSync(outputDir, { recursive: true });
}

fs.readdir(handlersDir, (err, files) => {
  if (err) {
    console.error('Error reading handlers directory:', err);
    process.exit(1);
  }

  files = files.filter(file => file !== '.DS_Store'); // Ignore .DS_Store files (macOS)

  files.forEach(file => {
      const outfilename = file.replace('.ts', '.js');

      esbuild.build({
        entryPoints: [path.join(handlersDir, file)],
        bundle: true,
        outfile: path.join('dist', 'handlers', outfilename),
        external: ['aws-sdk'], // Exclude AWS SDK since it's available in the Lambda runtime
        platform: 'node',
        target: 'node18',
      }).catch(() => process.exit(1));
  });
});

Install the project dependencies by running:

yarn install

Let's build the project. Run the following command:

yarn build

This should create a dist directory with the compiled JavaScript handler file with all the dependencies bundled.

Figure 3: Handler bundle

Understanding the Terraform configuration

The terraform folder has all the config files for deploying this serverless API on AWS:

terraform
├── modules
   ├── apigateway
      ├── main.tf
      ├── outputs.tf
      └── variables.tf
   └── lambda-api
       ├── lambda-handler-create-epic-failure.tf
       ├── main.tf
       └── variables.tf
├── main.tf
├── outputs.tf
├── terraform.tf
└── variables.tf

The root main.tf sets up modules we'll need: API Gateway module and Lambda Api module. In the variables.tf file, we define the variables that will be used in the modules. The outputs.tf file defines the output variables that will be displayed after the terraform apply command.

Modules are reusable, which really comes in handy if you’re looking to set up different environments or customize the infrastructure for specific deployments.

Setting up the API Gateway

To expose our Lambda function to the internet, we need to create an API Gateway. The apigateway module is responsible for that.

# terraform/modules/apigateway/main.tf

# This Terraform configuration defines the following AWS resources:
# 1. API Gateway (aws_apigatewayv2_api): Creates an HTTP API Gateway with the specified name.
# 2. API Gateway Stage (aws_apigatewayv2_stage): Creates a deployment stage for the API Gateway 
#    with auto-deploy enabled and access logging configured to CloudWatch.
# 3. CloudWatch Log Group (aws_cloudwatch_log_group): Creates a CloudWatch Log Group for storing 
#    API Gateway access logs with a retention period of 14 days.

resource "aws_apigatewayv2_api" "api_gateway" {
  name          = var.api_gateway_name
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_stage" "stage" {
  api_id = aws_apigatewayv2_api.api_gateway.id
  name        = var.api_gateway_stage_name
  auto_deploy = true

  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.cloudwatch.arn
    format = jsonencode({
      requestId      = "$context.requestId"
      sourceIp       = "$context.identity.sourceIp"
      requestTime    = "$context.requestTime"
      httpMethod     = "$context.httpMethod"
      resourcePath   = "$context.resourcePath"
      status         = "$context.status"
      responseLength = "$context.responseLength"
    })
  }
}

resource "aws_cloudwatch_log_group" "cloudwatch" {
  name = "/aws/api-gw/${aws_apigatewayv2_api.api_gateway.name}"
  retention_in_days = 14
}

Setting up the Lambda function

Having Api Gateway in place, we need to create a Lambda function that will handle the API requests. The lambda-api module is responsible for that.

Let's start with the main.tf file. We need a bucket where the code of the handler will be uploaded by the Terraform. We also need an IAM role for the Lambda function to execute.

# terraform/modules/lambda-api/main.tf

# This Terraform configuration defines the following AWS resources:

# 1. Data Source for AWS Account Information (data "aws_caller_identity"): Retrieves 
#    information about the current AWS account.
# 2. S3 Bucket (aws_s3_bucket): Creates an S3 bucket for storing Lambda functions.
# 3. S3 Bucket Public Access Block (aws_s3_bucket_public_access_block): This configuration 
#    ensures that the S3 bucket lambda_bucket is not publicly accessible by blocking various 
#    types of public access settings.
# 4. IAM Role for Lambda Execution (aws_iam_role): Creates an IAM role for Lambda execution 
#    with the necessary assume role policy.
# 5. IAM Role Policy Attachment (aws_iam_role_policy_attachment): Attaches the 
#    AWSLambdaBasicExecutionRole policy to the IAM role.

# Data source for AWS account information
data "aws_caller_identity" "current" {}


# S3 Bucket for Lambda functions
resource "aws_s3_bucket" "lambda_bucket" {
  bucket        = "${var.lambda_function_name_prefix}-lambda-bucket"
  force_destroy = true
}

resource "aws_s3_bucket_public_access_block" "lambda_bucket" {
  bucket = aws_s3_bucket.lambda_bucket.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# IAM Role for Lambda execution
resource "aws_iam_role" "lambda_role_exec" {
  name = "exec-lambda"
   assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role       = aws_iam_role.lambda_role_exec.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

Now we can define the Lambda function itself. We need to upload the code of the handler to the S3 bucket and create the Lambda function. We define the handler, runtime, and the IAM role for the Lambda function. We are using archive_file data source to zip the handler code from the dist directory of the node.js application. The lambda is attached to the API Gateway using the aws_apigatewayv2_integration resource. We define the route for the Lambda function in the API Gateway using the aws_apigatewayv2_route resource.

# terraform/modules/lambda-api/lambda-handler-create-epic-failure.tf

# This Terraform configuration defines the following AWS resources:
# 1. Lambda Function for Creating an Epic Failure (aws_lambda_function): 
#    Creates a Lambda function for creating a failure.
# 2. Data Source for Create Epic Failure Lambda Code (data "archive_file"): 
#    Archives the create-epic-failure Lambda function code into a zip file.
# 3. API Gateway Integration for Create Epic Failure Lambda (aws_apigatewayv2_integration): 
#    Creates an integration between API Gateway and the create-epic-failure Lambda function.
# 4. API Gateway Route for Create Epic Failure (aws_apigatewayv2_route): Creates 
#    a route in API Gateway for the create-epic-failure endpoint.
# 5. Lambda Permission for API Gateway (aws_lambda_permission): Grants API Gateway 
#    permission to invoke the create-epic-failure Lambda function.

resource "aws_lambda_function" "lambda_create_epic_failure" {
  function_name    = "${var.lambda_function_name_prefix}-create-epic-failure"
  runtime          = "nodejs18.x"
  handler          = "create-epic-failure-handler.handler"
  role             = aws_iam_role.lambda_role_exec.arn
  filename         = data.archive_file.create_epic_failure_lambda_zip.output_path
  source_code_hash = data.archive_file.create_epic_failure_lambda_zip.output_base64sha256
}

data "archive_file" "create_epic_failure_lambda_zip" {
  type        = "zip"
  source_file = "${path.module}/../../../epicfailure-api/dist/handlers/create-epic-failure-handler.js"
  output_path = "${path.module}/../../../epicfailure-api/dist/createEpicFailure.zip"
}

resource "aws_apigatewayv2_integration" "lambda_create_epic_failure" {
  api_id             = var.api_gateway_id
  integration_uri    = aws_lambda_function.lambda_create_epic_failure.invoke_arn
  integration_type   = "AWS_PROXY"
  integration_method = "POST"
}

resource "aws_apigatewayv2_route" "post_create_epic_failure" {
  api_id    = var.api_gateway_id
  route_key = "POST /epic-failures"
  target    = "integrations/${aws_apigatewayv2_integration.lambda_create_epic_failure.id}"
}

resource "aws_lambda_permission" "api_gw_create_epic_failure" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.lambda_create_epic_failure.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${var.api_gateway_execution_arn}/*/*"
}

Deploying the project

In terraform/main.tf assign your profile name configured in the ~/.aws/credentials file.

First initialize Terrafom:

terraform init

Terraform has functionality to check the configuration before applying it. This is called a plan. Run the following command to see what Terraform will do:

terraform plan

Figure 4: Terraform plan output

If everything looks as expected, apply the configuration:

terraform apply

This should deploy the API Gateway and Lambda function on AWS along with the necessary resources. You can access the API Gateway URL from the output of the terraform apply command.

Figure 5: Terraform apply output

Test the deployed API

Create an epic failure record

In the terminal export the INVOKE_URL from the terraform apply output:

export INVOKE_URL="https://<api-id>.execute-api.<region>.amazonaws.com/<stage>"

Use the curl POST request to create epic failure record:

curl -X POST \
  ${INVOKE_URL}/epic-failures \
  -H "Content-Type: application/json" \
  -d '{
    "failureID": "001",
    "taskAttempted": "deploying a feature on Friday at 4:59 p.m.",
    "whyItFailed": "triggered a cascade of unexpected errors",
    "lessonsLearned": ["never deploy on a Friday afternoon"]
  }'

That should return a response with the epic failure record created.

Figure 6: Curl request to create an epic failure record

Browse logs in CloudWatch

You can view the logs generated by the Lambda function in CloudWatch. Simply navigate to the CloudWatch console and look for the log group under /aws/lambda/<function-name>.

Figure 7: CloudWatch logs

Wrapping up

Congratulations!

We have successfully deployed a simple serverless API using Terraform. In the next parts of this tutorial, we will:

  • Add DynamoDB table to store the epic failure records and create remaining endpoints.
  • Add AWS Cognito for authentication.
  • Optimize the deployment using shared layers.

At move2edge, we’ve found that Terraform’s flexibility helps us efficiently manage both small and large deployments, no matter how complex the infrastructure requirements are.

We hope this serverless API example shows how powerful Terraform can be for building and managing infrastructure. This is just the beginning — in the upcoming parts of this tutorial series, we will dive deeper into more advanced topics and configurations. Stay tuned, and get ready to explore the full potential of Terraform in your serverless projects!

Newsletter

Enjoying our content?
Sign up to our newsletter for more.
Insightful content on cloud, 5G, media streaming, and IoT delivered to you every someday.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.