Run Next.js 13 App on Lambda with AWS Lambda Web Adapter

June 29, 2023

In the ever-evolving world of web development, deploying applications has become increasingly complex. However, with the rise of serverless architectures and powerful cloud platforms like Amazon Web Services (AWS), the process of deploying and scaling applications has become more streamlined and efficient than ever before. In this article, we will explore how to leverage AWS Lambda Web Adapter, Terraform, and AWS Lambda in combination with CloudFront to deploy a simple Next.js application.

Setting up NextJS app for standalone deployment

To create a new Next.js project, run the following command in your terminal:

1
npx create-next-app@latest
2
3
yarn create next-app
4
5
pnpm create next-app

Once the project is set up, you'll need to make a couple of modifications in the next.config.js file to prepare the application for standalone deployment and utilize static files from a CDN. Open the next.config.js file and add the following code:

1
const nextConfig = {
2
output: 'standalone',
3
assetPrefix: 'https://<CDN_URL>',
4
// other code
5
}

By setting output to standalone, Next.js can automatically create a standalone folder that copies only the necessary files for a production deployment, including select files in node_modules.

The assetPrefix property should be set to the URL of your desired CDN. By doing so, your Next.js app will utilize the CDN to serve static files, resulting in improved performance and faster load times for your users.

Remember to replace <CDN_URL> with the actual URL of your CDN. Once you've made these changes, your Next.js application will be ready for deployment using AWS Lambda and a CDN.

Provisioning infrastructure with Terraform

To deploy the Next.js app, we need to provision specific infrastructure components like a Lambda function with an API Gateway for the server, as well as a CloudFront distribution to handle static files. In this guide, we will focus on packaging the code as a zip file and utilizing it in a Lambda function. If you prefer using a Docker image, please refer to the official documentation for detailed instructions.

Before we begin, make sure you have Terraform and the AWS CLI installed. If you haven't already, you can follow the links below to install them:

Now, let's set up the necessary Terraform files. Create a new folder named nextjs-lambda/ in the root directory of your application, and place the following files inside:

nextjs-lambda
├── api_gw.tf
├── cloudfront.tf
├── deploy.sh
├── domain.tf
├── lambda.tf
├── main.tf
├── output.tf
├── run.sh
├── s3_bucket.tf
├── terraform.tfvars
├── terraform.tfvars.example
└── variables.tf

These files contain the Terraform configurations needed to provision the required AWS resources. The main.tf file acts as the entry point for Terraform, and the other files define specific resources like API Gateway, CloudFront, Lambda function, and more.

Main

Sets up the providers and remote state management in main.tf. Here, the state is managed using S3 and Dynamodb. You can configure it to use other state management methods as well.

1
terraform {
2
backend "s3" {
3
bucket = "<tf-state-bucket-name>"
4
key = "<key-val>"
5
region = "<region>"
6
dynamodb_table = "tf-state-lock"
7
encrypt = true
8
9
}
10
required_providers {
11
aws = {
12
source = "hashicorp/aws"
13
version = "5.5.0"
14
}
15
archive = {
16
source = "hashicorp/archive"
17
version = "2.4.0"
18
}
19
}
20
21
required_version = "~> 1.5.1"
22
}
23
24
# Configure the AWS Provider
25
provider "aws" {
26
region = "us-east-1"
27
}

Lambda

The lambda.tf file is responsible for declaring the Lambda function, its policy, and CloudWatch logs. It utilizes the archive_file module to create a zip file of the Next.js package.

1
# tflint-ignore: terraform_unused_declarations
2
data "archive_file" "lambda" {
3
type = "zip"
4
# path to nextjs app root folder
5
source_dir = "${path.module}/../.next/standalone/"
6
output_path = "lambda_function_payload.zip"
7
}
8
9
resource "aws_lambda_function" "nextjs" {
10
filename = "lambda_function_payload.zip"
11
function_name = var.LAMBDA_FUNCTION_NAME
12
role = aws_iam_role.iam_for_lambda.arn
13
handler = "run.sh"
14
memory_size = var.memory_size
15
package_type = "Zip"
16
runtime = var.NodeRuntime
17
timeout = var.timeout
18
architectures = ["x86_64"]
19
layers = ["arn:aws:lambda:${var.REGION}:753240598075:layer:LambdaAdapterLayerX86:16"]
20
environment {
21
variables = {
22
AWS_LAMBDA_EXEC_WRAPPER = var.AWS_LAMBDA_EXEC_WRAPPER
23
AWS_LWA_ENABLE_COMPRESSION : true
24
RUST_LOG : "info"
25
PORT : var.PORT
26
}
27
}
28
29
}
30
31
data "aws_iam_policy_document" "assume_role" {
32
statement {
33
effect = "Allow"
34
35
principals {
36
type = "Service"
37
identifiers = ["lambda.amazonaws.com"]
38
}
39
40
actions = ["sts:AssumeRole"]
41
}
42
}
43
44
resource "aws_iam_role" "iam_for_lambda" {
45
name = "iam_for_lambda_nextjs"
46
assume_role_policy = data.aws_iam_policy_document.assume_role.json
47
}
48
49
resource "aws_cloudwatch_log_group" "nextjs_log" {
50
name = "/aws/lambda/${var.LAMBDA_FUNCTION_NAME}"
51
retention_in_days = 7
52
}
53
54
# See also the following AWS managed policy: AWSLambdaBasicExecutionRole
55
data "aws_iam_policy_document" "lambda_nextjs_logging" {
56
statement {
57
effect = "Allow"
58
59
actions = [
60
"logs:CreateLogGroup",
61
"logs:CreateLogStream",
62
"logs:PutLogEvents",
63
]
64
65
resources = ["arn:aws:logs:*:*:*"]
66
}
67
}
68
69
resource "aws_iam_policy" "lambda_logging" {
70
name = "lambda_nextjs_logging"
71
path = "/"
72
description = "IAM policy for logging from a lambda"
73
policy = data.aws_iam_policy_document.lambda_nextjs_logging.json
74
}
75
76
resource "aws_iam_role_policy_attachment" "lambda_logs" {
77
role = aws_iam_role.iam_for_lambda.name
78
policy_arn = aws_iam_policy.lambda_logging.arn
79
}
  • The Lambda handler in the code utilizes the run.sh bash script to initialize the server.
  • To make this entire setup work, we rely on the Lambda Web Adapter layer. The layer is available in different architectures, and further details can be found in the official documentation.
  • Additionally, you need to configure the Lambda environment variable AWS_LAMBDA_EXEC_WRAPPER to /opt/bootstrap. In the above example, this value is passed as a Terraform variable.

API Gateway

API Gateway enables us to invoke Lambda functions using a custom domain. In this example, Terraform is used to set up an HTTP API Gateway and grant it permission to invoke Lambda.

1
resource "aws_apigatewayv2_api" "httpAPI" {
2
name = var.DOMAIN_NAME
3
protocol_type = "HTTP"
4
target = aws_lambda_function.nextjs.arn
5
}
6
7
resource "aws_lambda_permission" "apigw" {
8
action = "lambda:InvokeFunction"
9
function_name = aws_lambda_function.nextjs.arn
10
principal = "apigateway.amazonaws.com"
11
12
# The /* part allows invocation from any stage, method and resource path
13
# within API Gateway.
14
15
source_arn = "${aws_apigatewayv2_api.httpAPI.execution_arn}/*/*"
16
}

S3 Bucket

This section sets up a private S3 bucket and grants permission to access its contents via CloudFront.

1
resource "aws_s3_bucket" "cdn_bucket" {
2
bucket = var.CDN_URL
3
force_destroy = true
4
5
tags = {
6
Name = "Nextjs CDN Bucket"
7
}
8
}
9
10
resource "aws_s3_bucket_ownership_controls" "bucket_ownership" {
11
bucket = aws_s3_bucket.cdn_bucket.id
12
rule {
13
object_ownership = "BucketOwnerPreferred"
14
}
15
}
16
17
resource "aws_s3_bucket_acl" "cdn_bucket_acl" {
18
depends_on = [aws_s3_bucket_ownership_controls.bucket_ownership]
19
20
bucket = aws_s3_bucket.cdn_bucket.id
21
acl = "private"
22
}
23
24
resource "aws_s3_bucket_policy" "cloudfront_bucket_policy" {
25
bucket = aws_s3_bucket.cdn_bucket.id
26
policy = data.aws_iam_policy_document.s3_bucket_policy.json
27
28
}
29
30
data "aws_iam_policy_document" "s3_bucket_policy" {
31
statement {
32
33
effect = "Allow"
34
35
principals {
36
type = "Service"
37
identifiers = ["cloudfront.amazonaws.com"]
38
}
39
40
actions = [
41
"s3:GetObject",
42
]
43
44
resources = [
45
"${aws_s3_bucket.cdn_bucket.arn}/*",
46
]
47
48
condition {
49
test = "ForAnyValue:StringEquals"
50
variable = "AWS:SourceArn"
51
values = [aws_cloudfront_distribution.s3_distribution.arn]
52
}
53
}
54
}

CloudFront

CloudFront distribution is created with the S3 bucket as the source. Managed policies and other essential configurations are defined. Origin Access Control (OAC) is used to access the S3 bucket content.

1
resource "aws_cloudfront_origin_access_control" "s3_bucket_oac" {
2
name = "${var.CDN_URL}_oac"
3
description = "OAC policy for ${var.CDN_URL}"
4
origin_access_control_origin_type = "s3"
5
signing_behavior = "always"
6
signing_protocol = "sigv4"
7
}
8
9
data "aws_cloudfront_cache_policy" "CachingOptimized" {
10
name = "Managed-CachingOptimized"
11
}
12
13
data "aws_cloudfront_response_headers_policy" "CORS_With_Preflight" {
14
name = "Managed-CORS-With-Preflight"
15
}
16
17
resource "aws_cloudfront_distribution" "s3_distribution" {
18
origin {
19
domain_name = aws_s3_bucket.cdn_bucket.bucket_domain_name
20
origin_access_control_id = aws_cloudfront_origin_access_control.s3_bucket_oac.id
21
origin_id = var.CDN_URL
22
}
23
24
enabled = true
25
is_ipv6_enabled = true
26
comment = "nextjs-cdn"
27
price_class = "PriceClass_All"
28
wait_for_deployment = false
29
30
aliases = [var.CDN_URL]
31
32
viewer_certificate {
33
acm_certificate_arn = var.CERTIFICATE_ARN
34
minimum_protocol_version = "TLSv1.2_2021"
35
ssl_support_method = "sni-only"
36
}
37
38
default_cache_behavior {
39
allowed_methods = ["GET", "HEAD", "OPTIONS"]
40
cached_methods = ["GET", "HEAD"]
41
target_origin_id = var.CDN_URL
42
cache_policy_id = data.aws_cloudfront_cache_policy.CachingOptimized.id
43
44
viewer_protocol_policy = "redirect-to-https"
45
response_headers_policy_id = data.aws_cloudfront_response_headers_policy.CORS_With_Preflight.id
46
compress = true
47
}
48
49
restrictions {
50
geo_restriction {
51
restriction_type = "none"
52
locations = []
53
}
54
}
55
}

Domain

This section is responsible for creating and assigning custom domains for API Gateway and CloudFront distribution. It utilizes an existing Hosted Zone and SSL Certificate. For API Gateway, a custom domain is mapped to the HTTP API gateway with the default stage.

1
resource "aws_apigatewayv2_domain_name" "api_gw_domain" {
2
domain_name = var.DOMAIN_NAME
3
4
domain_name_configuration {
5
certificate_arn = var.CERTIFICATE_ARN
6
endpoint_type = "REGIONAL"
7
security_policy = "TLS_1_2"
8
}
9
}
10
11
data "aws_route53_zone" "HostedZoneID" {
12
name = var.HOSTED_ZONE_DOMAIN
13
private_zone = false
14
}
15
16
resource "aws_route53_record" "application_domain" {
17
name = aws_apigatewayv2_domain_name.api_gw_domain.domain_name
18
type = "A"
19
zone_id = data.aws_route53_zone.HostedZoneID.zone_id
20
21
alias {
22
name = aws_apigatewayv2_domain_name.api_gw_domain.domain_name_configuration[0].target_domain_name
23
zone_id = aws_apigatewayv2_domain_name.api_gw_domain.domain_name_configuration[0].hosted_zone_id
24
evaluate_target_health = false
25
}
26
}
27
resource "aws_route53_record" "cloudfront_domain" {
28
name = var.CDN_URL
29
type = "A"
30
zone_id = data.aws_route53_zone.HostedZoneID.zone_id
31
32
alias {
33
name = aws_cloudfront_distribution.s3_distribution.domain_name
34
zone_id = aws_cloudfront_distribution.s3_distribution.hosted_zone_id
35
evaluate_target_health = false
36
}
37
}
38
39
resource "aws_apigatewayv2_api_mapping" "api_gw_domain_mapping" {
40
api_id = aws_apigatewayv2_api.httpAPI.id
41
domain_name = aws_apigatewayv2_domain_name.api_gw_domain.id
42
stage = "$default"
43
}

Variables

All the variables used in this Terraform module:

1
variable "memory_size" {
2
type = number
3
default = 512
4
5
}
6
7
variable "NodeRuntime" {
8
type = string
9
default = "nodejs18.x"
10
11
}
12
13
variable "timeout" {
14
type = number
15
default = 10
16
17
}
18
19
variable "LAMBDA_FUNCTION_NAME" {
20
21
type = string
22
default = "Nextjs-app"
23
24
}
25
26
variable "AWS_LAMBDA_EXEC_WRAPPER" {
27
type = string
28
default = "/opt/bootstrap"
29
30
}
31
32
variable "PORT" {
33
type = number
34
default = 8000
35
36
}
37
38
variable "REGION" {
39
type = string
40
sensitive = true
41
42
}
43
44
variable "DOMAIN_NAME" {
45
type = string
46
47
}
48
49
variable "CERTIFICATE_ARN" {
50
type = string
51
52
}
53
54
variable "HOSTED_ZONE_DOMAIN" {
55
type = string
56
57
}
58
59
variable "CDN_URL" {
60
type = string
61
62
}

terraform.tfvars

The terraform.tfvars file is used to store variable values for the Terraform configuration. You can create it based on the provided terraform.tfvars.example file and fill in the necessary values.

1
REGION = ""
2
DOMAIN_NAME = ""
3
CERTIFICATE_ARN = ""
4
HOSTED_ZONE_DOMAIN = ""
5
CDN_URL = ""
6
PORT = ""
7
LAMBDA_FUNCTION_NAME = ""

Output (optional)

Returns the CDN domain and API Gateway domain

1
output "cloudfront_domain_name" {
2
value = aws_route53_record.cloudfront_domain.name
3
}
4
5
output "application_domain" {
6
value = aws_route53_record.application_domain.name
7
}

With the infrastructure provisioning files in place, we are ready to move on to the next steps of deploying the Next.js app using AWS Lambda and CloudFront.

Deploy Script

Add a run.sh bash script to the nextjs-lambda/ folder, which will serve as the entry point to run the app after deployment.

1
#!/bin/bash
2
3
[ ! -d '/tmp/cache' ] && mkdir -p /tmp/cache
4
5
exec node server.js

In addition, include another bash script called deploy.sh that packages files, provisions the infrastructure, and uploads static files to S3. Remember to update the CDN_NAME variable (which must match the one used for infrastructure). Ensure that these bash scripts have execution permissions.

1
#! /usr/bin/bash
2
3
set -e # stop execution if anything fails
4
5
# Add cdn domain address
6
CDN_DOMAIN="<CDN_DOMAIN>"
7
LAMBDA_FUNCTION_NAME="<LAMBDA_FUNCTION_NAME>"
8
9
10
if [ "$1" = 0 ]; then
11
echo "No arguments provided. Please provide 'deploy' or 'sync' as an argument."
12
exit 1
13
fi
14
15
if [ "$1" != "deploy" ] && [ "$1" != "sync" ]; then
16
echo "Invalid argument. Please provide 'deploy' or 'sync' as an argument."
17
exit 1
18
fi
19
20
21
# get the absolute file path
22
SOURCE=${BASH_SOURCE[0]}
23
while [ -L "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink
24
DIR=$( cd -P "$( dirname "$SOURCE" )" >/dev/null 2>&1 && pwd )
25
SOURCE=$(readlink "$SOURCE")
26
[[ $SOURCE != /* ]] && SOURCE=$DIR/$SOURCE # if $SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located
27
done
28
29
DIR=$( cd -P "$( dirname "$SOURCE" )" >/dev/null 2>&1 && pwd )
30
31
file_dir_name=$(basename "$DIR")
32
33
cd $(dirname "$DIR")
34
35
echo "Installing node modules"
36
npm i
37
38
echo "Building nextjs application"
39
npm run build
40
41
# Package files for deployment
42
echo "packaging files"
43
44
cp -r public/. .next/standalone/public
45
cp $file_dir_name/run.sh .next/standalone/
46
47
48
if [ "$1" = "deploy" ]; then
49
echo "Running deploy command..."
50
51
echo "provisioning infrastructure"
52
53
cd $file_dir_name/
54
55
if [ ! -d ".terraform" ]
56
then
57
echo "Terraform has not been initialized. Initializing now..."
58
terraform init
59
else
60
echo "Terraform is already initialized"
61
fi
62
63
terraform validate
64
terraform apply -auto-approve
65
66
67
elif [ "$1" = "sync" ]; then
68
echo "Running sync command..."
69
70
cd .next/standalone/
71
72
zip -r -q lambda_function_payload.zip .
73
74
echo "This might take a while..."
75
76
aws lambda update-function-code --function-name $LAMBDA_FUNCTION_NAME --zip-file fileb://lambda_function_payload.zip 1> /dev/null
77
78
if [ $? -eq 0 ]
79
then
80
echo "Command executed successfully, continuing to next section."
81
else
82
echo "There was an error in executing the command."
83
fi
84
85
rm -rf lambda_function_payload.zip
86
87
cd $DIR
88
fi
89
90
91
# move to application folder and sync static files to CDN
92
echo "syncing static files to S3"
93
94
cd ../
95
96
# sync static files to S3 + Cloudfront
97
aws s3 cp .next/static/ s3://$CDN_DOMAIN/_next/static/ --recursive

To deploy the app, execute the deploy.sh bash script with argument deploy.

1
./deploy.sh deploy

Note that even after the deployment is complete, it may take some time for the CloudFront deployment to finish.

To just sync files to Lambda and S3, execute the deploy.sh bash script with argument sync.

1
./deploy.sh sync

To destroy the deployment, navigate to the nextjs-lambda/ folder and run:

1
terraform destroy

Remember to add this to the .gitignore file before committing any changes to Git.

1
### Terraform ###
2
# Local .terraform directories
3
**/.terraform/*
4
5
# .tfstate files
6
*.tfstate
7
*.tfstate.*
8
9
# Crash log files
10
crash.log
11
crash.*.log
12
13
# Exclude all .tfvars files, which are likely to contain sensitive data, such as
14
# password, private keys, and other secrets. These should not be part of version
15
# control as they are data points which are potentially sensitive and subject
16
# to change depending on the environment.
17
*.tfvars
18
*.tfvars.json
19
20
# Ignore override files as they are usually used to override resources locally and so
21
# are not checked in
22
override.tf
23
override.tf.json
24
*_override.tf
25
*_override.tf.json
26
27
# Include override files you do wish to add to version control using negated pattern
28
# !example_override.tf
29
30
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
31
# example: *tfplan*
32
33
# Ignore CLI configuration files
34
.terraformrc
35
terraform.rc
36
lambda_function_payload.zip

You can find the code and an example in this GitHub repository

Reference: