Deploying a NextJS Static App to AWS S3 and CloudFront with Terraform

July 29, 2023

4 min read

Deploying a NextJS Static App to AWS S3 and CloudFront with Terraform
Watch on YouTube

Deploying a NextJS App to AWS S3 and CloudFront

Let's deploy a NextJS static app to AWS S3 and CloudFront.

Here's the deploy.sh script:

#!/bin/zsh -e

# Required environment variables:
# - BUCKET: S3 bucket name
# - DISTRIBUTION_ID: CloudFront distribution ID

yarn build

OUT_DIR=out

aws s3 sync $OUT_DIR s3://$BUCKET/ \
  --delete \
  --exclude $OUT_DIR/sw.js \
  --exclude "*.html" \
  --metadata-directive REPLACE \
  --cache-control max-age=31536000,public \
  --acl public-read

aws s3 cp $OUT_DIR s3://$BUCKET/ \
  --exclude "*" \
  --include "*.html" \
  --include "$OUT_DIR/sw.js" \
  --metadata-directive REPLACE \
  --cache-control max-age=0,no-cache,no-store,must-revalidate \
  --acl public-read \
  --recursive

process_html_file() {
  file_path="$1"
  relative_path="${file_path#$OUT_DIR/}"
  file_name="${relative_path%.html}"

  aws s3 cp s3://$BUCKET/$file_name.html s3://$BUCKET/$file_name
}

find $OUT_DIR -type f -name "*.html" | while read -r html_file; do
  process_html_file "$html_file"
done

aws configure set preview.cloudfront true
aws cloudfront create-invalidation --distribution-id $DISTRIBUTION_ID --paths "/*"

It requires two variables:

  • BUCKET - S3 bucket name
  • DISTRIBUTION_ID - CloudFront distribution ID

Since it's a static app, NextJS will generate everything into the out folder.

We want to cache all the files in the out directory except for HTML files and the service worker. To achieve that, we first perform a sync with the --delete flag and exclude sw.js and *.html files. Then we do a copy with the --exclude "*" flag and include *.html and sw.js files.

Afterwards, we need to create a copy of every HTML file without the .html extension. This is necessary because if a user visits a page like /about, S3 will not return the about.html file. Instead, it will return either the about file without an extension or the index.html file inside the about folder.

Finally, we create an invalidation to propagate the changes to CloudFront.

Setting up AWS S3 and CloudFront with Terraform

To create an S3 bucket and set up CloudFront, we can use Terraform. You can view the entire setup in the repository under the infra folder.

I already have a hosted zone and a certificate for HTTPS, so I provide them through variables instead of creating new resources. I pass kit.radzion.com as the domain and the hosted zone for the root radzion.com domain in the remaining variables.

variable "domain" {}

variable "bucket_name" {}

variable "certificate_arn" {}

variable "hosted_zone_id" {}

Next, we proceed to the main.tf file and create the S3 bucket, CloudFront distribution, and Route53 record.

provider "aws" {
}

terraform {
  backend "s3" {
  }
}

resource "aws_s3_bucket" "frontend" {
  bucket = var.bucket_name
}

resource "aws_s3_bucket_policy" "frontend" {
  bucket = aws_s3_bucket.frontend.id
  policy = data.aws_iam_policy_document.frontend.json
}

data "aws_iam_policy_document" "frontend" {
  version = "2012-10-17"
  statement {
    sid    = "bucket_policy_site_main"
    effect = "Allow"

    principals {
      type        = "AWS"
      identifiers = ["*"]
    }

    actions = [
      "s3:GetObject",
    ]

    resources = [
      "${aws_s3_bucket.frontend.arn}/*",
    ]
  }
}

resource "aws_s3_bucket_ownership_controls" "frontend" {
  bucket = aws_s3_bucket.frontend.id
  rule {
    object_ownership = "BucketOwnerPreferred"
  }
}

resource "aws_s3_bucket_public_access_block" "frontend" {
  bucket = aws_s3_bucket.frontend.id

  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_acl" "frontend" {
  depends_on = [
    aws_s3_bucket_ownership_controls.frontend,
    aws_s3_bucket_public_access_block.frontend,
  ]

  bucket = aws_s3_bucket.frontend.id
  acl    = "public-read"
}

resource "aws_s3_bucket_website_configuration" "frontend" {
  bucket = aws_s3_bucket.frontend.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "index.html"
  }
}

resource "aws_cloudfront_distribution" "frontend" {
  origin {
    domain_name = aws_s3_bucket_website_configuration.frontend.website_endpoint
    origin_id   = var.bucket_name

    custom_origin_config {
      http_port              = "80"
      https_port             = "443"
      origin_protocol_policy = "http-only"
      origin_ssl_protocols   = ["TLSv1", "TLSv1.1", "TLSv1.2"]
    }
  }
  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"

  aliases = [var.domain]

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = var.bucket_name

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }
    compress               = true
    viewer_protocol_policy = "redirect-to-https"
  }

  viewer_certificate {
    acm_certificate_arn = var.certificate_arn
    ssl_support_method  = "sni-only"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  custom_error_response {
    error_caching_min_ttl = "0"
    error_code            = "403"
    response_code         = "200"
    response_page_path    = "/"
  }
  custom_error_response {
    error_caching_min_ttl = "0"
    error_code            = "404"
    response_code         = "200"
    response_page_path    = "/"
  }
  custom_error_response {
    error_caching_min_ttl = "0"
    error_code            = "400"
    response_code         = "200"
    response_page_path    = "/"
  }
}

resource "aws_route53_record" "frontend_record" {
  zone_id = var.hosted_zone_id
  name    = var.domain
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.frontend.domain_name
    zone_id                = aws_cloudfront_distribution.frontend.hosted_zone_id
    evaluate_target_health = false
  }
}

data "aws_caller_identity" "current" {}