Radzion
3 min readJul 30, 2023

Watch on YouTube | 🐙 GitHub

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 reactkit.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" {}