Skip to content
Walid Karray
Go back

Mastering Static Website Hosting on AWS with Terraform: A Step-by-Step Tutorial

The shift to cloud and serverless solutions has revolutionized modern infrastructure, empowering organizations with simplicity and efficiency. This article provides a comprehensive guide to deploying a static website on AWS using Terraform, covering essential services such as S3 for storage, CloudFront for content delivery, and Route 53 for domain management. Each step is explained in detail.

The article addresses common challenges and shares best practices, empowering readers to leverage AWS for their website deployment needs.

Your feedback and discussions are highly appreciated, as they enrich the experience for all readers.

Architecture Diagram :

Hosting Static Website on AWS — Architecture

Hosting Static Website on AWS — Architecture

This architecture illustrates the flow of hosting a static website on AWS using Route 53, CloudFront, S3, Certificate Manager, and a CloudFront Function. Here’s how it works:

Client: Represents the end user who wishes to access the website.
When the client attempts to access the website, the first step is a DNS Lookup.

Route53: Amazon Route 53 is AWS’s Domain Name System (DNS) cloud service.
When the client requests the website, the first thing that happens is a DNS query. This query is resolved by Route 53 to point towards the appropriate CloudFront distribution.

Certificate Manager (TLS Certificate): AWS Certificate Manager is responsible for provisioning, managing, and deploying public and private SSL/TLS certificates for use with AWS services.
The certificate ensures that the website connection remains encrypted and is indicated by “HTTPS” in the URL.
The CloudFront Distribution fetches the SSL certificate from the Certificate Manager to ensure an HTTPS-only communication with the client.

CloudFront Distribution: Amazon CloudFront is a content delivery network (CDN) service.
Upon DNS resolution by Route 53, the client’s request hits CloudFront, which may either serve cached content (for speed) or request the original content from the S3 bucket if it’s not already cached.
Uses the SSL certificate to ensure HTTPS communication.

CloudFront OAC + S3 Bucket Policy: Origin Access Control (OAC) is a security feature that restricts access to an S3 bucket to only authorized CloudFront distributions. It is a more flexible and secure way to secure S3 buckets with CloudFront than the legacy OAI method.

When using OAC, you can ensure that your S3 buckets are only accessible to authorized CloudFront distributions, which helps to keep your data safe and secure.

CloudFront Function: This function is specifically designed for HTTP redirects. Let’s suppose someone types https://www.yourdoamin.com into their browser. Instead of staying on the ‘www’ version, this function quickly redirected them to the root domain https://yourdoamin.com (HTTP status 301). This provides a consistent domain experience and recommended for SEO and user experience.

Private S3 Bucket: Amazon S3 (Simple Storage Service) is where the website’s static assets (HTML, CSS, JS, images) are stored.
It’s designated as “private”, meaning direct public access is restricted. Only CloudFront, with its OAC, can fetch content from this bucket.

Static Web Files: Represents the static content of the website, such as HTML, CSS, and JavaScript files.
These files can be pushed or uploaded to the private S3 bucket, from where they are served to the end-users through the CloudFront distribution.

In summary, this architecture provides a highly available and scalable way to serve a static website with low latency. Using Route 53, CloudFront, Certificate Manager, and S3 in tandem, the setup ensures high availability, reduced latency, and HTTPS security for a seamless user experience.

Bringing the Architecture to Life !

Prerequisites

Overview of the Directory structure

The project’s structure shown above, all Terraform files reside in the root level. The ./dist folder contains all static website assets, including HTML, CSS, JavaScript, and images, with ‘index.html’ serving as the main access point for users.

Declaring and Using Variables

Let’s start by declaring our required variables to keep our configurations dynamic. For clarity, each variable is paired with a comment outlining its use

variable "aws_region" {
  type        = string
  description = "AWS Region"
}

variable "prefix" {
  type        = string
  description = "The prefix to be added to resource names"
}
variable "domain_name" {
  type        = string
  description = "The domain name for the website."
}
variable "bucket_name" {
  type        = string
  description = "The name of the bucket without the www. prefix. Normally domain_name."
}
variable "common_tags" {
  type        = map(string)
  description = "Common tags you want applied to all components."
}

Defining Local Variables

Local variables in Terraform allow for intermediate computations and transformations within a module, ensuring cleaner code without modifying the primary input variables.

locals.tf:

locals {
  dist_dir    = "${path.module}/dist"
  module_name = basename(abspath(path.module))
  prefix      = var.prefix

  content_types = {
    ".html" : "text/html",
    ".css" : "text/css",
    ".js" : "application/javascript",
    ".json" : "application/json",
    ".xml" : "application/xml",
    ".jpg" : "image/jpeg",
    ".jpeg" : "image/jpeg",
    ".png" : "image/png",
    ".gif" : "image/gif",
    ".svg" : "image/svg+xml",
    ".webp" : "image/webp",
    ".ico" : "image/x-icon",
    ".woff" : "font/woff",
    ".woff2" : "font/woff2",
    ".ttf" : "font/ttf",
    ".eot" : "application/vnd.ms-fontobject",
    ".otf" : "font/otf"
  }
}

In the code block above, the dist_dir directs to the static website files’ location, while **module_name** and **prefix** assist in resource organization. Additionally, the **content_types** map aligns file extensions with their appropriate MIME types, guaranteeing accurate content delivery for various assets.

Setting up the AWS Provider

Terraform providers are initial configurations that specify the cloud platform we’re using (e.g., AWS, Azure, GCP …). They enable Terraform to communicate with the cloud’s API. For our setup, we use two providers: one for general AWS services and a secondary one for ACM, which is required for CloudFront.

provider.tf:

provider "aws" {
  region                   = var.aws_region
}

# CloudFront requires SSL certificates to be provisioned in the North Virginia (us-east-1) region.
provider "aws" {
  alias  = "acm_provider"
  region = "us-east-1"
}

Create an S3 Bucket for Your Website

We will use an Amazon S3 bucket to host the static assets of our website

aws_s3_bucket.tf:

# S3 src website bucket
resource "aws_s3_bucket" "static_website" {
  bucket = var.bucket_name

  tags = var.common_tags
}

resource "aws_s3_bucket_website_configuration" "website_bucket" {
  bucket = aws_s3_bucket.static_website.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "index.html"
  }

}

# S3 bucket ACL access

resource "aws_s3_bucket_ownership_controls" "website_bucket" {
  bucket = aws_s3_bucket.static_website.id
  rule {
    object_ownership = "BucketOwnerPreferred"
  }
}

resource "aws_s3_bucket_public_access_block" "website_bucket" {
  bucket = aws_s3_bucket.static_website.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

}

resource "aws_s3_bucket_acl" "website_bucket" {
  depends_on = [
    aws_s3_bucket_ownership_controls.website_bucket,
    aws_s3_bucket_public_access_block.website_bucket,
  ]

  bucket = aws_s3_bucket.static_website.id
  acl    = "private"
}

Let’s break down the role of each Terraform resource.

S3 Bucket Creation (aws_s3_bucket): This resource defines the primary storage location, named after var.bucket_name, for our website’s static content like HTML, CSS, and JavaScript files.

Website Configuration (aws_s3_bucket_website_configuration): This configuration sets up the S3 bucket to behave like a web server. When someone accesses the root of our domain, they’re served the index.html file. If there’s an error, we redirect them back to the main page.

Bucket Ownership Controls (aws_s3_bucket_ownership_controls): This establishes that the default owner of objects uploaded to our bucket, unless specified otherwise, will be the bucket owner.

Public Access Restrictions (aws_s3_bucket_public_access_block): For security, this configuration ensures that our bucket is not publicly accessible. It blocks public ACLs, policies, and restricts public access to ensure our content remains private.

Bucket Access Control List (ACL) (aws_s3_bucket_acl): This resource, which depends on the aforementioned ownership and public access controls, confirms that the overall access level for the bucket is set to “private”, reinforcing our data security.

Define SSL Certificate

For a secure website, we need an SSL certificate. Let’s set one up using AWS Certificate Manager.

aws_acm_certificate.tf:

# SSL Certificate
resource "aws_acm_certificate" "ssl_certificate" {
  provider                  = aws.acm_provider
  domain_name               = var.domain_name
  subject_alternative_names = ["*.${var.domain_name}"]
  #validation_method         = "EMAIL"
  validation_method = "DNS"

  tags = var.common_tags

  lifecycle {
    create_before_destroy = true
  }
}

# Uncomment the validation_record_fqdns line if you do DNS validation instead of Email.
resource "aws_acm_certificate_validation" "cert_validation" {
  provider                = aws.acm_provider
  certificate_arn         = aws_acm_certificate.ssl_certificate.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.ssl_certificate.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }
  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = aws_route53_zone.main.zone_id
}

Here’s a breakdown of aws_acm_certificate.tf:

Note to Remember: DNS validation can take some time. It’s dependent on DNS propagation, so you might need to wait a while before the validation completes

Configure CloudFront Distribution

For any online business, delivering content swiftly and securely is key. AWS CloudFront addresses this by spreading content through a worldwide network of data centers, guaranteeing quicker load times and bolstered security.
Additionally, CloudFront is not just about speed and security. It offers a suite of advanced features, like edge computing capabilities, content optimization, and detailed analytics, making it a comprehensive solution for content delivery needs.

aws_cloudfront_distribution.tf:

resource "aws_cloudfront_origin_access_control" "current" {
  name                              = "OAC ${aws_s3_bucket.static_website.bucket}"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "s3_distribution" {
  depends_on = [aws_s3_bucket.static_website]
  origin {
    domain_name              = aws_s3_bucket.static_website.bucket_regional_domain_name
    origin_id                = "${var.bucket_name}-origin"
    origin_access_control_id = aws_cloudfront_origin_access_control.current.id
  }
  comment         = "${var.domain_name} distribution"
  enabled         = true
  is_ipv6_enabled = true
  http_version    = "http2and3"
  price_class     = "PriceClass_100" // Use only North America and Europe
  // wait_for_deployment = true
  aliases = [
    var.domain_name,
    "www.${var.domain_name}"
  ]
  default_root_object = "index.html"

  default_cache_behavior {
    cache_policy_id        = "658327ea-f89d-4fab-a63d-7e88639e58f6"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true
    target_origin_id       = "${var.bucket_name}-origin"

    //TODO : function_association

  }
  restrictions {
    geo_restriction {
      restriction_type = "none"
      locations        = []
    }
  }
  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate_validation.cert_validation.certificate_arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }
  tags = var.common_tags
}
//TODO : aws_cloudfront_function for www redirect

Diving into the Terraform code above:

The aws_cloudfront_origin_access_control Terraform resource creates an AWS CloudFront Origin Access Control (OAC), which is a secure way to grant CloudFront access to your S3 bucket.

OAC use AWS Identity and Access Management (IAM) role trusts to provide CloudFront with short-term credentials to access your S3 bucket. This is more secure than using a CloudFront Origin Access Identity (OAI), which is a deprecated resource that provided CloudFront with long-term credentials.

aws_cloudfront_distribution: The primary configuration for our content delivery. Let’s break this down:

Implementing CloudFront Function for URL Redirection

When users visit our site with ‘www’ (like https://www.yourdoamin.com), we want them to be sent straight to the root domain https://yourdoamin.com. This gives our website address a uniform look and can help with our search ranking (SEO). That’s where CloudFront functions come in.

Why Choose CloudFront Functions Over Lambda@Edge?

  1. Simplicity: CloudFront Functions are specialized for lighter, high-frequency tasks like URL redirections, making them straightforward to use.
  2. Low Latency: Designed for near-instantaneous execution, they ensure rapid, barely noticeable redirections for users.
  3. Cost-Effective: Typically more economical than Lambda@Edge, especially for high-request use cases like URL redirects.
  4. Ease of Deployment: Simpler and more direct management and deployment compared to Lambda@Edge.

cloudfront-function.js:

function handler(event) {
    var request = event.request;
    var hostHeader = request.headers.host.value;

    // Regular expression to extract the top-level domain and root domain
    var domainRegex = /(?:.*\.)?([a-z0-9\-]+\.[a-z]+)$/i;
    var match = hostHeader.match(domainRegex);

    // If the regex does not match, or the host does not start with 'www.', return the original request
    if (!match || !hostHeader.startsWith('www.')) {
        return request;
    }

    // Extract the root domain
    var rootDomain = match[1];

    // Construct and return the redirect response
    return {
        statusCode: 301,
        statusDescription: 'Moved Permanently',
        headers: {
            "location": { "value": "https://" + rootDomain + request.uri },
            "cache-control": { "value": "max-age=3600" }
        }
    };
}

To integrate this function code to our CloudFront distribution, we’ll make two updates in the aws_cloudfront_distribution.tf file:

//  *** Other configurations and code are hidden for clarity. ***

resource "aws_cloudfront_distribution" "s3_distribution" {

    //  *** Other configurations and code are hidden for clarity. ***

    default_cache_behavior {
        cache_policy_id        = "658327ea-f89d-4fab-a63d-7e88639e58f6"
        viewer_protocol_policy = "redirect-to-https"
        allowed_methods        = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
        cached_methods         = ["GET", "HEAD"]
        compress               = true
        target_origin_id       = "${var.bucket_name}-origin"
    
        function_association {
          event_type   = "viewer-request"
          function_arn = aws_cloudfront_function.www_redirect.arn
        }
      }

   //  *** Other configurations and code are hidden for clarity. ***

}

/*
  A CloudFront function to redirect www-prefixed URLs to the apex domain,
  enhancing user experience and consolidating domain authority.
*/
resource "aws_cloudfront_function" "www_redirect" {
  name    = "${local.prefix}-www-redirect"
  runtime = "cloudfront-js-1.0"
  code    = file("./cloudfront-function.js")
  publish = true
}

1) aws_cloudfront_function: This is the core terraform definition of our redirection function. It specifies:

2) function_association within default_cache_behavior: This piece of configuration associates our function with the CloudFront distribution. Whenever a viewer request event occurs, the function specified by its ARN is triggered.

Configure Domain with Route53

Amazon Route53’s Hosted Zone is a container that holds information about how you want to route traffic for a specific domain, like example.com. It encompasses domain-specific record sets, guiding traffic to the intended destinations. When you register or manage a domain in Route53, you’re essentially creating or editing a hosted zone for it.

In this section we will setup out website’s domain using Route53 zone.

aws_route53_record.tf:

resource "aws_route53_zone" "main" {
  name = var.domain_name
  tags = var.common_tags
}

resource "aws_route53_record" "root_a" {
  zone_id = aws_route53_zone.main.zone_id
  name    = var.domain_name
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.s3_distribution.domain_name
    zone_id                = aws_cloudfront_distribution.s3_distribution.hosted_zone_id
    evaluate_target_health = false
  }
}

resource "aws_route53_record" "www_a" {
  zone_id = aws_route53_zone.main.zone_id
  name    = "www.${var.domain_name}"
  type    = "A"

  alias {
    name                   = aws_cloudfront_distribution.s3_distribution.domain_name
    zone_id                = aws_cloudfront_distribution.s3_distribution.hosted_zone_id
    evaluate_target_health = false
  }
}

Examining each Terraform resource :

Secure Your S3 Bucket

When hosting content on an S3 bucket, ensure proper access control. The aws_s3_bucket_policy “allow_cloudfront” grants CloudFront read-only access via Origin Access Control (OAC). This approach ensures exclusive content access through CloudFront.

aws_s3_bucket_policy.tf:

resource "aws_s3_bucket_policy" "allow_cloudfront" {
  bucket = aws_s3_bucket.static_website.id
  policy = data.aws_iam_policy_document.cloudfront.json
}

data "aws_iam_policy_document" "cloudfront" {
  statement {
    sid     = "AllowCloudFrontServicePrincipalReadOnly"
    effect  = "Allow"
    actions = ["s3:GetObject"]
    resources = [
      aws_s3_bucket.static_website.arn,
      "${aws_s3_bucket.static_website.arn}/*"
    ]

    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values = [
        aws_cloudfront_distribution.s3_distribution.arn
      ]
    }
  }
}

Automatically upload your website files to S3 when you deploy

When deploying your static website, it’s essential to ensure that your site’s files are correctly and efficiently transferred to the S3 bucket. The terraform file below provides an automated approach to achieve this.

aws_s3_object.tf:

# Uploads all files from the local "src/dist" directory to a specified AWS S3 bucket.
resource "aws_s3_object" "static_file" {
  for_each     = fileset(local.dist_dir, "**")
  bucket       = aws_s3_bucket.static_website.id
  key          = each.key
  source       = "${local.dist_dir}/${each.value}"
  content_type = lookup(local.content_types, regex("\\.[^.]+$", each.value), null)
  etag         = filemd5("${local.dist_dir}/${each.value}")
}

Within the aws_s3_object resource, each file from the local ”./dist” directory is iterated over and uploaded to the specified AWS S3 bucket. The for_each directive here is used to go through each file in the directory. This means, when you run the deployment process, Terraform will automatically handle the upload of every file, ensuring the structure of your website remains consistent in the cloud. Furthermore, by determining the content_type dynamically, the configuration ensures proper handling and delivery of different file types, enhancing the performance and user experience of your site.

Outputs

Terraform outputs are essential for getting important information about generated resources, such as S3 bucket names and CloudFront URLs, for validation and post-deployment access.

outputs.tf:

output "dist_files" {
  value = fileset(local.dist_dir, "**/*")
}

output "s3_website_endpoint" {
  description = "S3 hosting URL (HTTP)"
  value       = "http://${aws_s3_bucket_website_configuration.website_bucket.website_endpoint}"
}

output "cloudfront_id" {
  description = "Cloudfront ID"
  value       = aws_cloudfront_distribution.s3_distribution.id
}

output "cloudfront_url" {
  description = "Cloudfront distribution URL (HTTPS)"
  value       = "https://${aws_cloudfront_distribution.s3_distribution.domain_name}"
}

output "website_url" {
  description = "Website URL (HTTPS)"
  value       = "https://${var.domain_name}"
}

Terraform Remote State (S3 & Dynamodb Backend)

This section is optional, but recommended for maintaining best practice standards.

Managing Terraform remote state is essential for teamwork and secure, scalable infrastructure. Using a remote state backend ensures that everyone on your team is working with the same infrastructure, and it helps you make changes safely.

To do so, we need to create a Terraform configuration file called

backend.tf :

terraform {
  backend "s3" {
    encrypt        = true
    bucket         = "my-org-tf-state"
    dynamodb_table = "my-org-tf-state-lock"
    key            = "my-static-website.tfstate"
    region         = "us-east-1"
  }
}

The provided Terraform code defines how to handle the state of your infrastructure using AWS S3 as a backend. In the context of Terraform, a backend determines how the state is loaded and how an operation such as apply is executed. It abstracts state management and locks the state to prevent conflicts in a team environment.

In the given code:

Important Notes for Adaptation:

Bucket Name: Ensure bucket is unique across all AWS since S3 bucket names are globally unique. Replace “my-org” with your organization or project’s name.

Region: Change the region to where you prefer to store your state file, considering latency and any data residency requirements.

Discover further details on provisioning the necessary resources for S3 remote state management by visiting the following the HashiCorp Developer Guide.

Assigning Variable Values

Early in this article, we defined various variables in our variables.tf file to create a flexible and reusable Terraform configuration. Now, it’s time to assign specific values to these predefined variables using the terraform.tfvars file. This practice aids in keeping our configurations modular and secure, preventing sensitive data from being inadvertently exposed in our main configuration files.

Here’s a quick snapshot of what your terraform.tfvars might look like:

aws_region  = "eu-west-1"
prefix      = "static-website"
domain_name = "example.com"
bucket_name = "my-static-site-frontend"

common_tags = {
  ManagedBy = "Terraform"
  Project   = "Static Website"
}

You will need to change the following values:

To the Cloud ! 🚀 Deploy Your Website to AWS

Execute the following commands in a terminal from your project directory

[terraform init]Prepares your Terraform project for use by installing necessary plugins and setting up the backend for state management. It’s the first command to run in a new Terraform project to get it ready for further actions.

Console output:

2. Apply Configuration

[terraform apply] command configures your AWS resources to match your code specifications. It provides a preview of changes, seeks your approval, and subsequently adjusts AWS resources, handling creation, modification, or deletion as needed.

Console output:

You should type “yes” in order to apply changes

Please note that deploying might take a while, potentially up to 30 minutes, due to CloudFront’s propagation across its extensive global server network and the necessary time for SSL certificate validation.

Once Terraform has finished deploying your infrastructure, you will see a success message with the output variables printed, as shown below.

Confirming successful website deployment

Now, let’s see our website in action!

Launch your browser and navigate to your domain using HTTPS, like so: https://www.yourdoamin.com .

Your page should now load as shown in the screenshot below. If it doesn’t, or if you encounter any issues, please refer to the Troubleshooting section that follows.

Next, let’s verify that the redirection from “www” to the root domain is functioning. Type https://www.yourdoamin.com into your browser - it should seamlessly redirect you to https://yourdomain.com. This redirection is made possible thanks to the configured CloudFront functions, ensuring both better SEO and consistent user experience.

Troubleshooting

If you’re experiencing issues with DNS propagation, it could stem from a misconfiguration of your domain name’s name servers, especially if your domain is registered with a registrar different from Route53. In such cases, ensure to replace the name servers of your domain name with the NS records found in your Route53 Hosted Zone. This aligns your domain with AWS and assists in resolving any propagation issues that may arise.

After adjusting your name servers, it can be handy to verify that the changes are propagating across the internet. A quick way to do this is by using an online DNS checker tool. You can visit DNS Checker to view the status of your domain’s DNS propagation worldwide

Estimating Your AWS Bill !

Let’s take a look at the possible expenses for hosting a static website on AWS, considering our current structure. Assuming each page load retrieves 1MB of data, and our website takes up 8MB of space on S3, and expecting about 1000 daily visitors, we’ll approximate the costs, keeping in mind the AWS pricing as of October 2023.

1. S3 Bucket

2. Amazon CloudFront

3. AWS Certificate Manager

4. Route 53

5. CloudFront Functions

Total Monthly Cost Estimate: Around** $3.57**

These numbers provide a rough idea and the actual cost may vary a bit based on exact usage and any price changes from AWS. For a more detailed estimate tailored to your unique usage, try using the AWS Pricing Calculator. And remember, it’s smart to keep an eye on your AWS costs and usage with tools like AWS Budgets and Cost Explorer.

Summary

In this article, we have shown you how to create a secure and scalable static website infrastructure on AWS using Terraform.

We covered the following steps:

As your team grows and your projects expand, you can use Terraform workspaces to manage multiple instances of infrastructure in an isolated and organized manner. This is perfect for development, staging, and production environments.

Thank you for reading this article. For a deeper dive and a more hands-on experience, check out the full project on GitHub repository link: https://github.com/tux86/static-website-hosting-amazon-s3


Share this post:

Previous Post
Quick Guide: Automating Tailscale Certificate Configuration on Synology DSM
Next Post
Building a Todo App with TypeScript Using Clean Architecture: A Detailed Look at the Directory Structure