Deploy Static AWS S3 Application with CloudFront as CDN using Terraform

Stefano Monti
AWS Infrastructure
Published in
9 min readApr 28, 2023

In this post, I will discuss how to deploy an S3 static website using Terraform.

Prerequisites

You need to have or register a personal domain, here’s the AWS official doc for registering a domain on Route53 Registrar.
In my case, I’ve created a domain called demo-apps.link, and the following is the relative hosted zone

Then, you should create a certificate for your domain. You can create it easily using AWS ACM. In the following step, you’ll need to retrieve the certificate arn.
As usual, you can find the complete repository here, in my Github profile.

Template

The stack is composed of different files organized by resources types:
providers.tf
variables.tf
s3.tf
cloudfront.tf
route53.tf

providers.tf

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.0"
}
}
}

provider "aws" {
region = "eu-west-1"
}

It specifies a configuration for controlling AWS resources in the eu-west-1 region using the aws provider.

The version is set to >4.0 in the required_providers block, which states that this Terraform configuration needs the HashiCorp AWS provider. This guarantees that the setup is compliant with the AWS provider’s version4.x.

The area where resources will be provisioned is specified in the provider block’s configuration for the aws provider.

These code chunks work together to create a Terraform configuration that can be used to control AWS resources in the eu-west-1 region using the provided AWS provider version.

variables.tf

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 "acm_certificate_arn" {
type = string
description = "ARN of the acm certificate."
}
variable "route53_zone_id" {
type = string
description = "ID of the route53 zone."
}

When installing a website on AWS infrastructure utilizing Amazon S3 and Amazon Route 53, these Terraform variables are probably utilized. A summary of each variable is provided below:

domain-name: A string variable that encapsulates the website’s domain name. This variable is probably used to set up the website’s DNS records.

bucket_name is a string variable that identifies the Amazon S3 bucket used to store website content.

ACM (AWS Certificate Manager) certificates have an ARN (Amazon Resource Name), which is represented by the string variable acm_certificate_arn. Most likely, this option is used to set up SSL/TLS for the website.

route53_zone_id: A string variable that holds the ID of the zone that is hosted by Amazon Route 53 and is charge of managing the website’s DNS records. The website’s DNS entries are probably created and configured using this variable.

You can use the file called terraform.tfvars to pass values to the variables listed above.

domain_name = "YOUR DOMAIN NAME"
bucket_name = "YOUR DOMAIN NAME"
acm_certificate_arn = "YOUR ACM CERTIFICATE ARN"
route53_zone_id = "YOUR ROUTE53 HOSTED ZONE ID"

You can find the domain name and route53_zone_id in the hosted zone inside the Route53 console…

…and the certificate arn in the ACM dashboard.

s3.tf

resource "aws_s3_bucket" "bucket" {
bucket = var.bucket_name
}

resource "aws_s3_bucket_acl" "bucket-acl" {
bucket = aws_s3_bucket.bucket.bucket
acl = "public-read"
}

resource "aws_s3_bucket_versioning" "versioning_example" {
bucket = aws_s3_bucket.bucket.bucket
versioning_configuration {
status = "Enabled"
}
}

resource "aws_s3_bucket_cors_configuration" "example" {
bucket = aws_s3_bucket.bucket.bucket
cors_rule {
allowed_headers = ["Authorization", "Content-Length"]
allowed_methods = ["GET", "POST"]
allowed_origins = ["https://${var.domain_name}"]
max_age_seconds = 3000
}
}

resource "aws_s3_bucket_policy" "bucket-policy" {
bucket = aws_s3_bucket.bucket.bucket
policy = data.aws_iam_policy_document.iam-policy-1.json
}

data "aws_iam_policy_document" "iam-policy-1" {
statement {
sid = "AllowPublicRead"
effect = "Allow"
resources = [
"arn:aws:s3:::${var.domain_name}",
"arn:aws:s3:::${var.domain_name}/*",
]
actions = ["S3:GetObject"]
principals {
type = "*"
identifiers = ["*"]
}
}
}

resource "aws_s3_bucket_website_configuration" "bucket" {
bucket = aws_s3_bucket.bucket.bucket

index_document {
suffix = "index.html"
}

error_document {
key = "404.html"
}
}

Using a variable called bucket_name to specify the bucket name, the first resource block constructs the S3 bucket itself.

The bucket’s access control list (ACL) is changed to public-read in the second resource block, allowing anyone to view its contents.

You can maintain different versions of files in the bucket thanks to the third resource block, which enables versioning for the bucket.

The bucket’s Cross-Origin Resource Sharing (CORS) configuration, which determines which domains are permitted access to the bucket’s contents, is done in the fourth resource block.

The bucket policy is established in the fifth resource block, granting public read access to the bucket and its contents. An IAM policy document that is defined in the data block below is used to define the policy.

An IAM policy document that grants public read access to the bucket and its contents is defined in the data block.

The S3 bucket is set up as a website in the last resource block by supplying the index document (in this case, index.html) and the error document (in this case, 404.htlm). By using its endpoint URL, the bucket can now be accessed like a website.

CloudFront.tf

resource "aws_cloudfront_distribution" "s3_distribution" {
origin {
domain_name = aws_s3_bucket_website_configuration.bucket.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_name}"]

custom_error_response {
error_caching_min_ttl = 0
error_code = 404
response_code = 200
response_page_path = "/404.html"
}

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"
}
}

viewer_protocol_policy = "redirect-to-https"
min_ttl = 31536000
default_ttl = 31536000
max_ttl = 31536000
compress = true
}

restrictions {
geo_restriction {
restriction_type = "none"
}
}

viewer_certificate {
acm_certificate_arn = var.acm_certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.1_2016"
}
}

The goal of this distribution is to use a globally dispersed network of edge points to provide material from the S3 bucket across the internet.

The following configuration options are defined in the code for the CloudFront distribution:

origin: This block defines the S3 bucket origin for the distribution, along with the origin ID, domain name, and any special configuration options for the origin, such as the HTTP and HTTPS ports to use, the origin protocol policy, and the supported SSL protocols.

enabled: The CloudFront distribution is made possible by this parameter.

is_ipv6_enabled: This configuration option makes IPv6 available for the CloudFront distribution.

default_root_object: When the root URL is accessed, the default root object for the CloudFront distribution will be provided.

aliases: In addition to the AWS-provided default domain name, this parameter defines any extra domain names that the CloudFront distribution should reply to.

custom_error_response: This block defines a custom error response that will be sent in the event of a 404 error, along with the HTTP response code to use, the response page path, and the caching period.

default_cache_behavior: This block defines the default cache behavior for the CloudFront distribution, including the HTTP methods that are permitted and cached, the target origin ID, the query string and cookies that are transmitted, the viewer protocol policy, and the duration of the content caching.

restrictions: This block details any limitations to be applied to the CloudFront distribution, such as IP address-based boundaries.

The viewer SSL certificate to use for the CloudFront distribution is specified in the block viewer_certificate, along with the ARN of the ACM certificate, the SSL support method, and the required minimum SSL protocol version.

route53.tf

resource "aws_route53_record" "root-a" {
zone_id = var.route53_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
}
}

For adding a A record to a zone hosted by Amazon Web Services (AWS) Route 53, use this Terraform resource block. To construct a record set in a Route 53 hosted zone, utilize the aws_route53_record resource. AnArecord, which links a domain name to an IP address, is the specific record set being created here.

The following characteristics apply to the resource block:

Zone ID is used to identify the hosted zone by Route 53 where the record set will be created. The var.route53_zone_id field provides the value.

name: The domain name for which the record set is being made. Thevar.domain_name variable provides the value.

type: The kind of record set that is being made. This particular record is aA record.

An alias block that identifies the target of the record set is known as an alias. The target for the record set is an AWS CloudFront distribution, and the name attribute is set to the distribution’s domain name. The hosted zone ID of the CloudFront distribution is the value for the zone_id parameter. Since Route 53 won’t run a target’s health checks, the evaluate_target_health attribute is set to false.

Time to launch terraform commands!

Let’s begin with:

$ terraform init

Terraform sets up a backend to store the state of your infrastructure when you run terraform init. It also downloads the necessary provider plugins and initializes a number of other parameters.

When you run terraform init, the following processes take place:

  • Installs the required provider plugins that have been downloaded.
  • the backend is set up to store the infrastructure’s current state.
  • the backend is set up to use the chosen backend provider (if applicable).
  • creates the necessary directories and files to configure Terraform’s working directory.
  • determines whether the necessary provider versions are set up and accessible locally.
  • enables any necessary parameters or variables in the Terraform environment.

One of the first tasks you execute when working with a fresh Terraform project or when setting up an old project on a new machine is normally terraform init.

$ terraform plan
$ terraform apply

The terraform apply command is used to actually apply the changes that Terraform will make to your infrastructure, as opposed to using it to preview them.

The terraform plan command can be used to generate a report on the changes that will be made to your infrastructure depending on the current state and the configuration after you have written your Terraform configuration files. This enables you to examine and confirm the alterations that will be made before putting them into effect.

Use the terraform apply command to implement the changes to your infrastructure after you are happy with them. To achieve the intended state, Terraform will add, alter, or destroy resources as necessary.

Test the application

Ok, now you should find the bucket that will host your website in the S3 dashboard:

In my case, the name of the bucket is demo-apps.linkthe same name as the website (this is not mandatory).

Let’s test the website!
If you try to access your website using your browser you will receive the following error:

This is because the S3 bucket doesn’t have any objects.
In the repo on GitHub is present a simple html file that contains a hello world code. You can upload the file into the bucket in order to test the website:

After having uploaded the index.html file retry to access your website via browser:

Now you should see the content of the index.html.

If you launch terraform destroy command, it will fail if you have objects inside the bucket. Make sure to delete all the objects inside the bucket in order to perform successfully the destroy command.

Bye 😘

--

--