Deploying a Static Website on AWS S3 with Terraform: A Beginner's Guide

Today, we will use AWS S3 to launch a basic static website as part of a friendly project for beginners. We will integrate AWS CloudFront to ensure rapid content delivery over HTTPS while maintaining security. We will use Terraform best practices, such as version control, remote state management, and modular architecture, throughout this project.

You will have a fully functional static website powered by Terraform that is hosted on AWS S3 and accessible through CloudFront by the end of this course!

This task assumes you already have a domain and a private S3 bucket for remote state management. However, the project is modularized in such a way that it may be deployed without a custom domain, relying solely on the CloudFront distribution domain for access.

The S3 bucket used for the website is private and will only be accessible through the CloudFront distribution, assuring security.

We’ll cover the following components:

  • S3 Bucket: For securely storing the website’s files, with public access enabled only for CloudFront.

  • CloudFront Distribution: To deliver the website content over HTTPS, with edge locations to speed up delivery.

  • Route53 (Optional): For associating your custom domain with the CloudFront distribution.

terraform-s3-static/
├── env/
│   └── dev/
│       ├── main.tf
│       ├── outputs.tf
│       ├── terraform.tfvars
│       └── vars.tf
├── modules/
│   ├── acm
│   ├── cloudfront
│   ├── route53
│   └── s3-static-website
└── static/
    ├── images/
    ├── index.html
    └── styles.css

Terraform modules are critical for reusability and maintainability. We designed a module for the S3 static website configuration that can be reused in other contexts. Let us look at the s3-static-website module.

# Generate a unique suffix for the S3 bucket name to ensure global uniqueness.
resource "random_string" "bucket_suffix_id" {
  length    = 8
  lower     = true
  special   = false
  min_lower = 8
}

# Create an S3 bucket for hosting the static website.
resource "aws_s3_bucket" "static_website" {
  bucket        = "kami-website-${random_string.bucket_suffix_id.result}"
  force_destroy = true # Ensures the bucket is deleted even if it contains objects

  tags = {
    Name        = "kami-website"
    Environment = var.environment
  }
}

# Configure the S3 bucket for static website hosting.
resource "aws_s3_bucket_website_configuration" "static_website_configurations" {
  bucket = aws_s3_bucket.static_website.id

  # Specify the index document for the website
  index_document {
    suffix = "index.html"
  }
}

# Define the S3 bucket policy to grant permissions to the CloudFront origin access control (OAC).
resource "aws_s3_bucket_policy" "bucket_policy" {
  bucket = aws_s3_bucket.static_website.id
  policy = var.cloudfront_oac_policy.json
}

# Define a local variable to manage the static files and their paths.
locals {
  files = {
    "index.html"             = "static/index.html"
    "styles.css"             = "static/styles.css"
    "images/dancing_cat.gif" = "static/images/dancing_cat.gif"
  }
}

# Upload static files to the S3 bucket.
resource "aws_s3_object" "static_files" {
  for_each = local.files

  bucket = aws_s3_bucket.static_website.id
  key    = each.key
  source = "${path.module}/../../${each.value}"
  content_type = lookup({
    "index.html"             = "text/html",
    "styles.css"             = "text/css",
    "images/dancing_cat.gif" = "image/gif"
  }, each.key, "application/octet-stream") # Default content type
}

This module defines an S3 bucket for hosting a static website and uploads the necessary files.

To ensure the bucket name is globally unique, we use a random string suffix.

We use the aws_s3_object resource to upload our static website files (HTML, CSS, images) to the S3 bucket. This is done using the for_each loop to iterate over each file in the local.files map

Outputs

To ensure the S3 bucket can be referenced by other modules, we define an output for the aws_s3_bucket resource.

hclCopy codeoutput "aws_s3_bucket" {
  description = "The S3 bucket details for the static website."
  value       = aws_s3_bucket.static_website
}

This output exposes the S3 bucket details so they can be easily passed into the CloudFront and Route53 modules for further configuration.

The ACM module generates an SSL/TLS certificate for use in securing the CloudFront delivery of the static website. This ensures that your website is safely served via HTTPS. This is how it is done:

Requesting an SSL Certificate: We utilize the aws_acm_certificate resource to get a certificate for our domain. This is usually done using DNS validation; however, the certificate will be issued after the validation is complete.

resource "aws_acm_certificate" "cert" {
  domain_name       = var.domain_name
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

Outputs

The output exposes the ARN of the SSL certificate, which will be used in the CloudFront distribution to associate the certificate for HTTPS support.

It also provides the domain validation options for the certificate, which are necessary for DNS validation.

output "acm_certificate_arn" {
  value = aws_acm_certificate.cert.arn
}

output "aws_acm_certificate_validation" {
  value = aws_acm_certificate.cert.domain_validation_options
}
  • The domain validation options will be used in the Route53 module to create the DNS validation record, proving ownership of the domain.

The Route 53 module is responsible for setting up DNS records to point a custom domain to the CloudFront distribution. It’s optional because you can still access your static website through the default CloudFront domain, but using Route 53 provides a more professional and user-friendly experience, especially if you're using your own domain name.

You begin by retrieving the hosted zone for your website from the aws_route53_zone data source. Next, generate the necessary DNS records for ACM certificate validation using the domain validation parameters. Finally, you will establish an alias record that directs your domain to the CloudFront distribution. This ensures that once your ACM certificate has been authenticated, your custom domain will redirect users to CloudFront for content delivery.

data "aws_route53_zone" "selected_zone" {
  name = var.domain_name
}

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in var.domain_validation_options :
    dvo.domain_name => {
      name  = dvo.resource_record_name
      type  = dvo.resource_record_type
      value = dvo.resource_record_value
    }
  }
  zone_id = data.aws_route53_zone.selected_zone.id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.value]
  ttl     = 60
}

resource "aws_route53_record" "cloudfront_record" {
  name    = var.domain_name
  type    = "A"
  zone_id = data.aws_route53_zone.selected_zone.zone_id

  alias {
    evaluate_target_health = false
    name                   = var.cloudfront.domain_name
    zone_id                = var.cloudfront.hosted_zone_id
  }
}

This configuration ensures that CloudFront securely connects to the S3 bucket using an Origin Access Control (OAC). It configures a CloudFront distribution with custom domain options and secure HTTPS access using an ACM certificate when a custom domain is specified. If no custom domain is specified, CloudFront uses its own certificate by default.

# Define an Origin Access Control (OAC) to securely connect CloudFront to the S3 bucket
resource "aws_cloudfront_origin_access_control" "oac" {
  name                              = "s3-cloudfront-oac-${var.environment}"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

# CloudFront distribution configuration for serving content from the S3 bucket
resource "aws_cloudfront_distribution" "s3_distribution" {
  enabled             = true
  aliases             = var.use_custom_domain ? [var.domain_name] : [] # Custom domain name for the distribution
  default_root_object = "index.html"
  is_ipv6_enabled     = true
  wait_for_deployment = true

  # Default caching behavior for the distribution
  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = var.aws_s3_bucket.id
    viewer_protocol_policy = "redirect-to-https" # Enforce HTTPS

    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }
  }

  # Origin configuration linking the S3 bucket with CloudFront
  origin {
    domain_name              = var.aws_s3_bucket.bucket_domain_name
    origin_id                = var.aws_s3_bucket.id
    origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
  }

  # Restrict access based on geographic region (currently open to all)
  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  # Viewer certificate for HTTPS using an ACM certificate
  viewer_certificate {
    acm_certificate_arn      = var.use_custom_domain ? var.acm_certificate : null
    cloudfront_default_certificate = var.use_custom_domain ? false : true
    minimum_protocol_version = "TLSv1.2_2021" # Ensure a secure TLS version
    ssl_support_method       = "sni-only"
  }
}

# IAM policy document allowing CloudFront to access S3 objects
data "aws_iam_policy_document" "cloudfront_oac" {
  statement {
    principals {
      identifiers = ["cloudfront.amazonaws.com"]
      type        = "Service"
    }

    actions   = ["s3:GetObject"] # Grant access to retrieve objects from S3
    resources = ["${var.aws_s3_bucket.arn}/*"]

    condition {
      test     = "StringEquals"
      values   = [aws_cloudfront_distribution.s3_distribution.arn]
      variable = "AWS:SourceArn" # Restrict access based on the CloudFront ARN
    }
  }
}

Variable

The critical variable in use is use_custom_domain, which determines whether a custom domain is used with CloudFront. If set to true, CloudFront will use the provided ACM certificate for HTTPS, and the custom domain will be applied. If set to false, CloudFront defaults to using its own certificate, and no custom domain is configured.

variable "use_custom_domain" {
  type        = bool
  description = "Indicates whether a custom domain is being used."
  default     = false
}

Outputs

This provides the CloudFront distribution's domain name, which can be used in Route 53 for DNS configuration. If a custom domain is used, this value will be crucial for setting up the DNS record to point to the CloudFront distribution

output "cloudfront_domain" {
  value = module.cloudfront.cloudfront_distribution.domain_name
}

Main TF file

In the dev/main.tf file, the modules are organized based on whether a custom domain is used.

ACM Module: The acm_certificate module is conditionally created based on the create_custom_domain variable. If the variable is true, the module is instantiated to handle the ACM certificate creation. If false, the module is skipped.

module "acm_certificate" {
  count        = var.create_custom_domain ? 1 : 0
  source      = "../../modules/acm"
  domain_name = var.domain_name
}

S3 Module: The s3 module is always created to set up the S3 static website, and it passes the cloudfront_oac_policy from the CloudFront module.

module "s3" {
  source                = "../../modules/s3-static-website"
  cloudfront_oac_policy = module.cloudfront.cloudfront_oac_policy
}

CloudFront Module: The cloudfront module configures the distribution, with the ACM certificate and domain name conditionally applied based on the create_custom_domain variable. If create_custom_domain is true, it uses the ACM certificate; otherwise, it defaults to CloudFront’s default certificate.

module "cloudfront" {
  source          = "../../modules/cloudfront"
  domain_name     = var.domain_name
  aws_s3_bucket   = module.s3.aws_s3_bucket
  environment     = var.environment
  acm_certificate = var.create_custom_domain ? module.acm_certificate.acm_certificate_arn : null
  use_custom_domain   = var.create_custom_domain
}

Route 53 Module: The route53 module is conditionally created based on the create_custom_domain variable. It sets up DNS records for the custom domain only if the domain is specified.

module "route53" {
  count = var.create_custom_domain ? 1 : 0
  source                    = "../../modules/route53"
  cloudfront                = module.cloudfront.cloudfront_distribution
  domain_validation_options = var.create_custom_domain ? module.acm_certificate.aws_acm_certificate_validation : null
  domain_name               = var.domain_name
}

This conditional logic ensures that resources such as the ACM certificate and Route 53 records are only created if a custom domain is specified, maintaining efficiency and reducing unnecessary resources when not needed

In this article, we have looked at how to use Terraform to automate the deployment of a static website, from setting up S3 hosting to managing certificates with ACM and deploying via CloudFront. We have also observed Route 53's integration with CloudFront for a custom domain setup. By modularizing the process, we ensure scalability and flexibility across multiple deployment situations. Using Git for version control, paired with correct tagging, makes it easier to keep track of changes and do any necessary rollbacks.

You can get the whole code and implementation in the repository here.