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

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.
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.htmlwithin 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
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
Configure S3 Bucket
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 filess3: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
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
- Create a CloudFront distribution and connect it to the S3 bucket.
- Enable HTTPS using AWS Certificate Manager (ACM).
- Configure cache policies for performance optimization.
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.
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;
}
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
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 ]
}