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