How to implement Lambda@Edge functions in Terraform

AWS CloudFront is a global Content Delivery Network (CDN) that gives developers extensive controls over their frontends. CloudFront comes with many configuration options for controlling caching, dynamic origins, geographic restrictions, and much more. But the greatest control that CloudFront offers comes from a service called Lambda@Edge.

With Lambda@Edge, you can run serverless functions through AWS Lambda on any of four event hooks that happen during a request for our origin content. These functions run at the edge locations of the CDN, meaning that Lambda@Edge promises a way to have a multi-region active-active backend where you only pay for the compute time that you use.

Lambda@Edge hooks (Image via Amazon AWS)

That sounds wonderful, but implementing these functions in practice can be challenging and error-prone. Terraform makes provisioning most cloud infrastructure a breeze, but Lambda functions have a number of challenges with them.

In a typical modern codebase, a tool like Terraform will manage infrastructure only, while your CI/CD pipeline deploys the application code. But Lambda functions blur those lines, being a serverless service that integrates with other pieces of infrastructure, while itself containing application code.

This blog post aims to show that not only are making Lambda@Edge functions in Terraform possible, but that it can actually be a pleasant experience.

At Transcend, these functions have enabled us to create consistently secure frontends without needing to update our frontend application code at all, which has been a wonderful experience.

Pro tip: If you want to host a frontend in AWS, CloudFront with an S3 origin is the recommended method. And if you want to get started with setting up a frontend with Terraform, check out our recent blog post on how to do so.

Getting started

For the purposes of this tutorial, let’s assume we have a very basic frontend that we’d like to deploy. And as a security-conscious organization, we’d like to add a Lambda@Edge function to ensure we are properly setting security headers (such as a Content Security Policy) in our responses.

To begin with, let’s start with a Terraform file that declares a frontend on AWS using S3 and Cloudfront. For an overview of how this works, check out our blog post here

provider "aws" {
  version = "~> 2.0"
  region  = "us-east-1"
}


module "cloudfront-s3-cdn" {
  source  = "cloudposse/cloudfront-s3-cdn/aws"
  version = "0.34.1"


  name               = "edge-acme-example"
  encryption_enabled = true


  # Caching Settings
  default_ttl = 300
  compress    = true


  # Website settings
  website_enabled = true
  index_document  = "index.html"
  error_document  = "index.html"
}


output s3_bucket {
  description = "Name of the S3 origin bucket"
  value       = module.cloudfront-s3-cdn.s3_bucket
}


/** Use remote state through Terraform cloud */
terraform {
  backend "remote" {
    hostname     = "app.terraform.io"
    organization = "transcend-io"


    workspaces {
      name = "edge-blog-frontend"
    }
  }
}

This sets up a complete frontend, with an S3 bucket origin, and a CloudFront distribution on top of it like we have available here. To create a Lambda function, we just need to point a module towards some application code we’ve written.

In this example, we’ve written a simple function that updates a response to include the security headers we want:

exports.handler = (event, context, callback) => {
  // Get contents of response
  const response = event.Records[0].cf.response;
  const headers = response.headers;


  // Set new headers
  headers['strict-transport-security'] = [{key: 'Strict-Transport-Security', value: 'max-age= 63072000; includeSubdomains; preload'}];
  headers['content-security-policy'] = [{key: 'Content-Security-Policy', value: "default-src 'self' cdnjs.cloudflare.com style-src 'self' 'unsafe-inline';"}];
  headers['x-frame-options'] = [{key: 'X-Frame-Options', value: 'DENY'}];
  headers['x-xss-protection'] = [{key: 'X-XSS-Protection', value: '1; mode=block'}];
  headers['referrer-policy'] = [{key: 'Referrer-Policy', value: 'same-origin'}];


  // Return modified response
  callback(null, response);
};

And then to create the Lambda function, we add the Terraform module:

module "security_header_lambda" {
  source                 = "transcend-io/lambda-at-edge/aws"
  version                = "0.0.2"
  name                   = "security_headers"
  description            = "Adds security headers to the response"
  runtime                = "nodejs12.x"
  lambda_code_source_dir = "${path.module}/../../src/security_headers"
}

The last step is to connect that Lambda function to one of the four CloudFront events. To do so, we update the CloudFront module to add the field:

lambda_function_association = [{
  event_type   = "origin-response"   
  include_body = false
  lambda_arn   = module.security_header_lambda.arn
}]

You can use up to four functions per CloudFront cache behavior, one for each of the hooks.

To validate that response headers are updated, we can run:

curl -D - https://d3h5cy7by9ugu9.cloudfront.net/ -o /dev/null

And we will see that the headers include our new security settings:

strict-transport-security: max-age= 63072000; includeSubdomains; preload
content-security-policy: default-src 'self' cdnjs.cloudflare.com style-src 'self' 'unsafe-inline';
x-frame-options: DENY
x-xss-protection: 1; mode=block
referrer-policy: same-origin

Beyond the basics

Building with the right building blocks blocks blockers in the future.(Susan Holt Smith via Unsplash)

In the remaining sections, we will expand our Terraform code to handle some common real-world use cases when dealing with Lambda@Edge functions.

Setting up a CI/CD pipeline

As mentioned before, Lambda@Edge blurs some lines between application code and infrastructure. Because of this, your CI pipelines will need to do application code tasks, like building your code from source, while your pipeline will also need to deploy your infrastructure with Terraform commands.

As an example, here is an example of a Github Action workflow that can build and deploy a Lambda@Edge function from scratch using our module we made above:

name: Deploy a Lambda@Edge function and website


on:
  push:
    branches:
    - master


jobs:
  deploy:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra/lambda_at_edge_example
    steps:
    # Checkout this repo
    - uses: actions/checkout@master

    # Download a specific terraform version you'd like to use
    - uses: hashicorp/setup-terraform@v1
      with:
        terraform_version: 0.13.3
        cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
        terraform_wrapper: false


    # Ensure you have AWS credentials set up. Your config will likely differ here
    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
        role-skip-session-tagging: true
        role-duration-seconds: 1200
        aws-region: us-east-1


    # Build the lambda@edge function
    - uses: actions/setup-node@v2-beta
      with:
        node-version: '12'
    - run: yarn
      working-directory: src/security_headers


    # Apply the terraform code
    - run: terraform init
    - run: terraform validate
    - run: terraform plan -out planfile
    - run: terraform apply planfile


    # Upload our website code to our origin S3 bucket
    - name: Deploy static site to S3 bucket
      run: aws s3 sync ../../public s3://$(terraform output s3_bucket) --delete

There’s nothing terribly fancy in this action. It checks out the repository, downloads dependencies, sets up credentials, builds the code, deploys the terraform to create the Lambda function and CloudFront distribution, then deploys the static site to the CloudFront origin.

Even if you don’t use Github actions, these steps should be straightforward enough to port to your CI/CD platform of choice. For a complete example of a working repo with this pipeline set up, check out https://github.com/transcend-io/blog-frontend.

Using Typescript

Fancy drinks for a fancy language. (Anthony Delanoix via Unsplash)

When you upload a Lambda function artifact, the artifact should contain runnable Javascript. That means that the Lambda runtime environment will not compile down your code, nor will it install node_modules. It’s up to you to package your code so it is ready to run.

First, let’s add a tsconfig.json to our package. For this example, I’ll keep things with most of the defaults from running tsc --init:

{
  "compilerOptions": {
    "target": "ES2017",                      
    "module": "commonjs",                  
    "strict": true,                 
    "noImplicitAny": true,
    "esModuleInterop": true,                 
    "forceConsistentCasingInFileNames": true ,
    "outDir": "dist",
  }
}

Next, we will update our package.json to install a specific typescript version and to know how to build our code with tsc:

{
  "author": "Transcend Inc.",
  "name": "@main/security_headers",
  "license": "UNLICENSED",
  "main": "index.js",
  "dependencies": {},
  "scripts": {
    "build": "tsc --build"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.62",
    "typescript": "^4.0.3"
  }
}

Notice that we included a dev dependency on some AWS Lambda types so that we can strongly type our code as we write it. Outside of adding types, our JavaScript code stays mostly the same. Here’s the new index.ts:

import {
  CloudFrontResponseEvent,
  CloudFrontResponseHandler,
  Context,
  CloudFrontResponseCallback,
} from 'aws-lambda';


/**
 * Adds security headers to a response.
 */
export const handler: CloudFrontResponseHandler = (
  event: CloudFrontResponseEvent,
  _: Context,
  callback: CloudFrontResponseCallback,
): void => {
  // Get contents of response
  const response = event.Records[0].cf.response;
  const headers = response.headers;


  // Set new headers
  headers['strict-transport-security'] = [{key: 'Strict-Transport-Security', value: 'max-age= 63072000; includeSubdomains; preload'}];
  headers['content-security-policy'] = [{key: 'Content-Security-Policy', value: "default-src 'self' cdnjs.cloudflare.com style-src 'self' 'unsafe-inline';"}];
  headers['x-frame-options'] = [{key: 'X-Frame-Options', value: 'DENY'}];
  headers['x-xss-protection'] = [{key: 'X-XSS-Protection', value: '1; mode=block'}];
  headers['referrer-policy'] = [{key: 'Referrer-Policy', value: 'same-origin'}];


  // Return modified response
  callback(null, response);
};

Lastly, we need to update our Terraform code to pull from the dist directory that our compiler outputs code to:

lambda_code_source_dir = "${path.module}/../../src/security_headers/dist"

The final step is to update our CI/CD script to compile our typescript down, right after the yarn step that installs node dependencies:

- run: yarn build
  working-directory: src/security_header

And with those small changes, we are done!

A workaround for not having environment variable support

While Lambda@Edge runs on AWS Lambda, there are quite a few restrictions placed on edge functions that normal functions aren’t subject to.

The one that seems to get in the way most often is that environment variables are not supported. This can be problematic, as sometimes you want to be able to write a single Lambda typescript file that works across multiple different frontends.

A common workaround is to upload a file with configuration values to your Lambda function that can be read in by the application code.

Uploading a static file is easy enough, but in the world of Infrastructure as Code, you will likely want this file to be dynamic. Say you are building a Lambda@Edge function to handle user authentication. If you are authenticating with Amazon Cognito, you will likely want to pass in some information about who should be allowed to login to your site. And as this information likely comes from another Terraform resource or variable, you will want to dynamically create the file based on the terraform outputs.

With our Lambda@Edge module, this is as easy as specifying a plaintext_params field:

plaintext_params = {
  userpool_id              = var.userpool_id
  client_id                = var.client_id
  userpool_region          = var.userpool_region
  ui_subdomain             = var.ui_subdomain
  scopes                   = join(" ", var.scopes)
  client_secret_param_name = var.ssm_client_secret_param_name
}

Then in your code, you can access these values with:

import { readFileSync } from 'fs';

const config = JSON.parse(readFileSync('./config.json'));

And just like that, config.userpool_id will have the value you set in Terraform. And because the config variable is set outside of your lambda handler, it’s value will be cached across Lambda runs, meaning there should be no performance hit for executions on an already warm Lambda server.

Securely reference secret values

The above section shows us how to add configuration values to our functions, but what if these config values are sensitive? API keys or other secret information should not be set in plaintext where a developer or attacker may be able to find them. Instead, your secrets should be protected in a secret store like the AWS Systems Manager (SSM) Parameter Store or Hashicorp Vault where you can finely control and audit access.

The Lambda@Edge module we are using will allow you to create SSM parameters easily, and will also automatically update your function’s IAM Role so that it has permissions to access those secret values.

To add a secret in Terraform, add a ssm_params field like:

ssm_params = {
  edge_client_secret = var.client_secret
}

Then in your code, you can enable accessing secrets with the function:

// All Lambda@Edge functions must reside in us-east-1.
const ssmClient = new SSM({ region: 'us-east-1' });


/**
 * Fetches a decrypted parameter from AWS SSM Parameter Store
 *
 * @param name - The name of the parameter to fetch
 * @returns the decrypted value of the parameter
 */
async function fetchSsmParam(name) {
  const { Parameter } = await ssmClient
    .getParameter({
      Name: name,
      WithDecryption: true,
    })
    .promise();
  return Parameter.Value;
}

Later, you could access the secret with:

await fetchSsmParam("edge_client_secret")

And just like that, you have a secure way of adding dynamic secrets to your Lambda@Edge functions. You can follow a similar process for Hashicorp Vault, but will need to first authenticate with the AWS IAM authentication method.

Summing up

Lambda@Edge adds nearly unlimited power to controlling your frontend behaviors. It can be used for authentication purposes, A/B testing, DDoS protection, creating dynamic websites, and much more.

It took a fair amount of research and troubleshooting to make this process easy, but hopefully our module makes it easier for you to get started.


If you want to get started with setting up a frontend with Terraform, check out our recent blog post on how to do so.

Share this article

Discover more articles

Snippets

Sign up for Transcend's weekly privacy newsletter.

    By clicking "Sign Up" you agree to the processing of your personal data by Transcend as described in our Data Practices and Privacy Policy. You can unsubscribe at any time.

    Discover more articles