How to host a Next js static website on AWS Cloudfront and S3
While there are many ways to use Next.js to run a website I am most interested in it’s static site generation capability. I appreciate it’s ability to provide a modern website experience to users while also removing the need for server side code execution.
This guide will show you how to host a statically generated Next.js site on AWS Cloudfront and S3. Github actions and Terraform will be used to create a CI/CD pipeline to build and deploy changes to the site.
The versions used for Terraform and the AWS providers are all the latest as of June 16, 2023.
Prerequisites
Before we get started you are going to do some initial setup:
- Terraform 1.5 installed link
- AWS CLI installed link
- AWS Credentials correctly configured on your machine link
- A purchased domain, I got mine from AWS. link
- Get a free SSL certificate from ACM(in us-east-1) link
End goal for this guide
- A static website hosted at https://www.yourdomain.com/ with a valid SSL certificate
- Requests to the root Domain are redirected to the www subdomain
- A CI/CD pipeline to allow for simple and reliable deployment of updates
Next.js SSG
First to use Next.js SSG(static site generation) the nextConfig.output needs to be set to export and nextConfig.images.unoptimized need to be true. Having to turn off image optimization is a limitation of how Next.js Image component works, in the coming week I will write a guide on how to restore image optimization functionality.
const nextConfig = {
output: 'export',
images: { unoptimized: true },
...
}
Terraform
These are all the terra
S3 bucket for Terraform state files
You will need a S3 bucket to hold the Terraform state files. This is the one
piece of infrastructure that is best to setup manually since it bootstraps
Terraform. Chose a name for your bucket I have have gone with
yourdomain-terraform
for these examples.
Once created give it the following policy, replace the iam arn and s3 arn to use your account ID and bucket name. You can also change the Principal to restrict access to a specific user or group.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::1234567890:root"
},
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::yourdomain-terraform"
},
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::1234567890:root"
},
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::yourdomain-terraform/*"
}
]
}
Variables
variable "domain_name" {
type = string
description = "The domain name for the website."
}
variable "common_tags" {
description = "Common tags you want applied to all components."
}
You will need a terraform.tfvars
file to set the Terraform variables.
domain_name = "yourdomain.com"
common_tags = {
Project = "yourdomain"
}
Providers
The region can be changed from us-east-2 to one that is closer to you.
terraform {
required_version = "~> 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.2"
}
}
backend "s3" {
bucket = "yourdomain-terraform"
key = "prod/terraform.tfstate"
region = "us-east-2"
}
}
provider "aws" {
region = "us-east-2"
}
S3 redirect bucket
This buckets job is to redirect all requests from the root domain to the www subdomain.
# S3 bucket for redirecting root domain to www subdomain
resource "aws_s3_bucket" "root_bucket" {
bucket = var.domain_name
tags = var.common_tags
}
resource "aws_s3_bucket_website_configuration" "root_bucket" {
bucket = aws_s3_bucket.root_bucket.id
redirect_all_requests_to {
host_name = "www.${var.domain_name}"
protocol = "https"
}
}
S3 website content buckets
There need to be two buckets because we want to be able to serve pages at both https://www.yourdomain.com/articles and https://www.yourdomain.com/articles/article_1.
The bucket for all the content not is a subdirectory.
# Policy allowing Cloudfront to get objects from the bucket
data "aws_iam_policy_document" "www_bucket" {
statement {
actions = [
"s3:GetObject",
]
resources = [
"arn:aws:s3:::www.${var.domain_name}/*",
]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
values = [ aws_cloudfront_distribution.www_distribution.arn ]
variable = "aws:SourceArn"
}
}
}
# S3 bucket for www subdomain
resource "aws_s3_bucket" "www_bucket" {
bucket = "www.${var.domain_name}"
tags = var.common_tags
}
resource "aws_s3_bucket_policy" "www_bucket" {
bucket = aws_s3_bucket.www_bucket.id
policy = data.aws_iam_policy_document.www_bucket.json
}
resource "aws_s3_bucket_ownership_controls" "www_bucket" {
bucket = aws_s3_bucket.www_bucket.id
rule {
object_ownership = "BucketOwnerPreferred"
}
}
resource "aws_s3_bucket_acl" "www_bucket" {
depends_on = [aws_s3_bucket_ownership_controls.www_bucket]
bucket = aws_s3_bucket.www_bucket.id
acl = "private"
}
The bucket for all the content in a subdirectory.
# Policy allowing Cloudfront to get objects from the bucket
data "aws_iam_policy_document" "subdirectories_bucket" {
statement {
actions = [
"s3:GetObject",
]
resources = [
"arn:aws:s3:::www.${var.domain_name}-subdirectories/*",
]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
values = [ aws_cloudfront_distribution.www_distribution.arn ]
variable = "aws:SourceArn"
}
}
}
# S3 bucket for content subdirectories
resource "aws_s3_bucket" "subdirectories_bucket" {
bucket = "www.${var.domain_name}-subdirectories"
tags = var.common_tags
}
resource "aws_s3_bucket_policy" "subdirectories_bucket" {
bucket = aws_s3_bucket.subdirectories_bucket.id
policy = data.aws_iam_policy_document.subdirectories_bucket.json
}
resource "aws_s3_bucket_ownership_controls" "subdirectories_bucket" {
bucket = aws_s3_bucket.subdirectories_bucket.id
rule {
object_ownership = "BucketOwnerPreferred"
}
}
resource "aws_s3_bucket_acl" "subdirectories_bucket" {
depends_on = [aws_s3_bucket_ownership_controls.subdirectories_bucket]
bucket = aws_s3_bucket.subdirectories_bucket.id
acl = "private"
}
Cloudfront redirecting distribution
The arn of you ACM SSL certificate needs to be added manually
# Cloudfront distribution for root domain (redirected to www)
resource "aws_cloudfront_distribution" "root_distribution" {
depends_on = [
aws_s3_bucket.root_bucket
]
origin {
domain_name = aws_s3_bucket_website_configuration.root_bucket.website_endpoint
origin_id = "s3-cloudfront"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "http-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
enabled = true
is_ipv6_enabled = true
aliases = [var.domain_name]
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-cloudfront"
# The AWS managed policy CachingOptimizedForUncompressedObjects is used
cache_policy_id = "b2884449-e4de-46a7-ac36-70bc7f1ddd6d"
viewer_protocol_policy = "allow-all"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = "{arn of acm certificate}"
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
tags = var.common_tags
}
Cloudfront content distribution
The arn of you ACM SSL certificate needs to be added manually
resource "aws_cloudfront_origin_access_control" "s3_access" {
name = "s3_access_control"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# Cloudfront distribution for www subdomain (main website)
resource "aws_cloudfront_distribution" "www_distribution" {
depends_on = [
aws_s3_bucket.www_bucket,
aws_s3_bucket.subdirectories_bucket
]
origin {
domain_name = aws_s3_bucket.www_bucket.bucket_regional_domain_name
origin_id = "s3-cloudfront"
origin_access_control_id = aws_cloudfront_origin_access_control.s3_access.id
}
origin {
domain_name = aws_s3_bucket.subdirectories_bucket.bucket_regional_domain_name
origin_id = "s3-subdirectories"
origin_access_control_id = aws_cloudfront_origin_access_control.s3_access.id
}
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
aliases = ["www.${var.domain_name}"]
custom_error_response {
error_caching_min_ttl = 0
error_code = 404
response_code = 200
response_page_path = "/404"
}
custom_error_response {
error_code = 403
response_code = 200
error_caching_min_ttl = 0
response_page_path = "/404"
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-cloudfront"
# The AWS managed policy CachingOptimized is used
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
viewer_protocol_policy = "redirect-to-https"
compress = true
# The AWS managed policy SecurityHeadersPolicy is used
response_headers_policy_id = "67f7725c-6f97-4210-82d7-5512b31e9d03"
}
ordered_cache_behavior {
path_pattern = "*/*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-subdirectories"
# The AWS managed policy CachingOptimized is used
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
viewer_protocol_policy = "redirect-to-https"
compress = true
# The AWS managed policy SecurityHeadersPolicy is used
response_headers_policy_id = "67f7725c-6f97-4210-82d7-5512b31e9d03"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = "{arn of acm certificate}"
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
tags = var.common_tags
}
Route53 DNS routing
Routes to for both IPv4 and IPv6 for both the redirecting distribution and the content distribution.
data "aws_route53_zone" "domain_name" {
name = var.hosted_zone
private_zone = false
}
resource "aws_route53_record" "root_a" {
depends_on = [
aws_cloudfront_distribution.root_distribution
]
zone_id = data.aws_route53_zone.domain_name.zone_id
name = var.domain_name
type = "A"
alias {
name = aws_cloudfront_distribution.root_distribution.domain_name
zone_id = aws_cloudfront_distribution.root_distribution.hosted_zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "www_a" {
depends_on = [
aws_cloudfront_distribution.www_distribution
]
zone_id = data.aws_route53_zone.domain_name.zone_id
name = "www.${var.domain_name}"
type = "A"
alias {
name = aws_cloudfront_distribution.www_distribution.domain_name
zone_id = aws_cloudfront_distribution.www_distribution.hosted_zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "root_aaaa" {
depends_on = [
aws_cloudfront_distribution.root_distribution
]
zone_id = data.aws_route53_zone.domain_name.zone_id
name = var.domain_name
type = "AAAA"
alias {
name = aws_cloudfront_distribution.root_distribution.domain_name
zone_id = aws_cloudfront_distribution.root_distribution.hosted_zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "www_aaaa" {
depends_on = [
aws_cloudfront_distribution.www_distribution
]
zone_id = data.aws_route53_zone.domain_name.zone_id
name = "www.${var.domain_name}"
type = "AAAA"
alias {
name = aws_cloudfront_distribution.www_distribution.domain_name
zone_id = aws_cloudfront_distribution.www_distribution.hosted_zone_id
evaluate_target_health = false
}
}