Deploying a NextJS Static App to AWS S3 and CloudFront with Terraform
Tags: nextjs, aws, s3, cloudfront
Published: 2023-07-30 06:33:12

✨ [**Watch on YouTube**](https://youtu.be/ekvQBFsCOnU)  |  🐙 [GitHub](https://github.com/radzionc/reactkit/tree/main/app)

### 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:

```sh
#!/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](https://github.com/radzionc/reactkit/tree/main/app/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 `reactkit.radzion.com` as the domain and the hosted zone for the root `radzion.com` domain in the remaining variables.

```hcl
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.

```hcl
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" {}
```