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 nameDISTRIBUTION_ID
- CloudFront distribution IDSince 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.
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" {}