Building my Website with AWS S3, CloudFront, Terraform, and Next.js

AWS Diagram

I'll walk you through how I built and deployed my website using AWS S3, CloudFront, Terraform, and Next.js.

Setting Up the Next.js App

npx create-next-app@latest

After creating the Next.js app. The app must be configured to build a as a static site.

next.config.js
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
    output: 'export', // Enable static export
    pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
    transpilePackages: ['next-mdx-remote'],
    images: {
        unoptimized: true // Required for static export
    },
    trailingSlash: true,
};

export default nextConfig;

output: 'export'
This configuration option tells Next.js to export the application as a static website. When you run next build, it will:

  • Generate static HTML files for all your pages
  • Generate one HTML file per route in your application

trailingSlash: true This configuration option adds a trailing slash to all URLs in the Next.js application. When enabled:

  • URLs will end with a forward slash (e.g., /about/ instead of /about)
  • Generated HTML files will be named index.html within directories (e.g., /about/index.html)
  • Internal links will automatically include the trailing slash

images: { unoptimized }: true This configuration option disables Next.js's built-in Image Optimization features. When set to true: This setting is required when using next export for static site generation (as the Image Optimization API requires a Node.js server)

This generates a out/ folder, which contains the static files for deployment.

Terraform Infrastructure Setup

Create Terraform Configuration Files Structure

terraform/
├── main.tf
├── variables.tf
├── s3.tf
├── cloudfront.tf
└── route53.tf

Provider and Region

I am using aws version ~>5.0 in the us-east-1 region

main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
    region = "us-east-1"
}

Configure S3 Bucket

s3.tf
resource "aws_s3_bucket" "this" {
  bucket = "www.${var.domain}"
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket = aws_s3_bucket.this.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_policy" "this" {
  bucket = aws_s3_bucket.this.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "AllowCloudFrontServicePrincipal"
        Effect    = "Allow"
        Principal = {
          Service = "cloudfront.amazonaws.com"
        }
        Action    = "s3:GetObject"
        Resource  = "${aws_s3_bucket.this.arn}/*"
        Condition = {
          StringEquals = {
            "AWS:SourceArn" = aws_cloudfront_distribution.this.arn
          }
        }
      }
    ]
  })
}

This configuration:

  • Implements strict security controls through IAM policies
  • Uses principle of least privilege with specific action permissions:
    • s3:GetObject: Allows retrieval of website files
    • s3:ListBucket: Enables directory listing for proper path resolution
  • Restricts access to CloudFront using Origin Access Identity
  • Prevents direct public access to maintain security

Configuring AWS S3 for Static Website Hosting

s3.tf
resource "aws_s3_bucket_website_configuration" "this" {
    bucket = aws_s3_bucket.this.id

    index_document {
        suffix = "index.html"
    }

    error_document {
        key = "index.html"
    }
}

Setting Up CloudFront for CDN & HTTPS

  1. Create a CloudFront distribution and connect it to the S3 bucket.
  2. Enable HTTPS using AWS Certificate Manager (ACM).
  3. Configure cache policies for performance optimization.
cloudfront.tf
resource "aws_cloudfront_origin_access_identity" "this" {
    comment = "access-identity-${var.domain}.s3.amazonaws.com"
}

data "aws_cloudfront_cache_policy" "managed-cachined-optimized" {
  name = "Managed-CachingOptimized"
}

resource "aws_cloudfront_distribution" "this" {
    origin {
      domain_name = aws_s3_bucket.this.bucket_regional_domain_name
      origin_id = aws_s3_bucket.this.bucket

      s3_origin_config {
        origin_access_identity = aws_cloudfront_origin_access_identity.this.cloudfront_access_identity_path
      }
    }

    price_class = "PriceClass_100"

    enabled = true
    is_ipv6_enabled = true
    aliases = [ aws_s3_bucket.this.bucket ]

    default_root_object = "index.html"

    default_cache_behavior {
        allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
        cached_methods = [ "GET", "HEAD" ]
        target_origin_id = aws_s3_bucket.this.bucket
        viewer_protocol_policy = "redirect-to-https"
        compress = true

        cache_policy_id = data.aws_cloudfront_cache_policy.managed-cachined-optimized.id

        function_association {
            event_type = "viewer-request"
            function_arn = aws_cloudfront_function.redirect-to-index.arn
        }
    }
    
    restrictions {
      geo_restriction {
        restriction_type = "none"
      }
    }

    viewer_certificate {
      acm_certificate_arn = aws_acm_certificate.this.arn
      ssl_support_method = "sni-only"
      minimum_protocol_version = "TLSv1.2_2018"
    }
}

CloudFront Function for Routing

A cloudFront function was needed to redirect incoming request to the respective index.html file. This is because I am using the trailingSlash: true Next.js configuration.

RedirectToIndex.js

async function handler(event) {
    var request = event.request;
    var uri = request.uri;
    
    // Check whether the URI is missing a file name.
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    } 
    // Check whether the URI is missing a file extension.
    else if (!uri.includes('.')) {
        request.uri += '/index.html';
    }

    return request;
}
cloudfront.tf
resource "aws_cloudfront_function" "redirect-to-index" {
    name    = "RedirectToIndex"
    runtime = "cloudfront-js-2.0"
    code    = file("${path.module}/RedirectToIndex.js")
    publish = true
}

Setting up Route53 and SSL certificate

route53.tf
resource "aws_route53_zone" "this" {
    name = var.domain
    comment = "HostedZone created by Route53 Registrar"

    lifecycle {
        prevent_destroy = true
    }
}

resource "aws_route53_record" "www" {
  zone_id = aws_route53_zone.this.zone_id
  name    = "www"
  type    = "CNAME"
  ttl     = 300
  records = [aws_cloudfront_distribution.this.domain_name]
}

resource "aws_acm_certificate" "this" {
    domain_name = "www.${var.domain}"
    validation_method = "DNS"
    lifecycle {
      create_before_destroy = true
    }
}

resource "aws_route53_record" "cert_validation" {
    allow_overwrite = true
    name = tolist(aws_acm_certificate.this.domain_validation_options)[0].resource_record_name
    records = [ tolist(aws_acm_certificate.this.domain_validation_options)[0].resource_record_value ]
    type = tolist(aws_acm_certificate.this.domain_validation_options)[0].resource_record_type
    zone_id = aws_route53_zone.this.id
    ttl = 60
}

resource "aws_acm_certificate_validation" "this" {
    certificate_arn = aws_acm_certificate.this.arn
    validation_record_fqdns = [ aws_route53_record.cert_validation.fqdn ]
}