CI/CD Using Terraform: Deploy a React-based single-page application to Amazon S3 and CloudFront with a Custom Domain name in Route53

Joel Wembo
Towards AWS
Published in
16 min readApr 5, 2024

--

Terraform is an infrastructure-as-code software tool created by HashiCorp. Users define and provide data center infrastructure using a declarative configuration language known as HashiCorp Configuration Language, or optionally JSON.

Terraform provision CloudFront and s3 static website with a custom domain name
Terraform provision CloudFront and s3 static website with a custom domain name

Table of Contents

· Table of Contents
·
Introduction
·
Prerequisites
·
Why S3 ?
·
Why CloudFront ?
·
Why Github Actions ?
·
1. Let’s Get It Started
·
2. Variables definitions:
·
3. provider.tf
·
4. Create an S3 Bucket for static content:
·
5. backend.tf
·
5.1 S3 bucket for terraform state management
·
5.2 Terraform Cloud Configuration
·
6. Provision AWS Certificate Manager and Validate:
·
7. Configure CloudFront Distribution:
·
8. Set Up Custom Domain:
·
9. AWS Route53 record resource:
·
10. Optional ( Terraform Cloud ) Terraform State management:
·
11. Setup Bucket Policy:
·
12. Validate and Test your solution
·
13. CI/CD Pipeline Configuration
·
14. Check result in your AWS Management Console
·
Summary
·
References

Introduction

CI/CD (Continuous Integration/Continuous Deployment or Continuous Delivery) is a set of practices in software development aimed at increasing the speed, reliability, and quality of delivering code changes. several reasons why CI/CD is beneficial such:

  1. Faster Time to Market: CI/CD automates the process of integrating code changes and deploying them to production, reducing the time it takes to get new features or fixes into the hands of users.
  2. Improved Quality: By automating testing and deployment processes, CI/CD ensures that every code change is thoroughly tested, reducing the likelihood of bugs and errors reaching production.
  3. Increased Collaboration: CI/CD encourages developers to commit code changes frequently, which fosters collaboration and enables faster feedback loops among team members.
  4. Reduced Risk: Automated testing and deployment pipelines help mitigate the risk of introducing bugs or errors into production environments, as issues are caught early in the development process.

A single-page application (SPA) is a website or web application that dynamically updates the contents of a displayed webpage by using JavaScript APIs. This approach enhances the user experience and performance of a website because it updates only new data instead of reloading the entire webpage from the server.

In this article we are going to guide you how to automate and implement CI/CD pipelines for the deployment of a react Single page application to S3 and CloudFront Using two devops tools : Terraform and Github Actions. We will use terraform, which is an infrastructure-as-code that will help provision Route53 Record Set for our custom domain, Certificate manager for SSL Certificate, S3 bucket for static web hosting in aws and finally CloudFront for traffic distribution. We will also conclude by automating the entire operations using GitHub actions.

Prerequisites

Before we get into the good stuffs, first we need to make sure we have the required services on our local machine or dev server, which are:

  1. Basic knowledge of React and Terraform.
  2. AWS Account
  3. Github Account
  4. AWS CLI installed and configured.
  5. Docker installed locally.
  6. Typescript installed
  7. NPM
  8. NodeJS
  9. Terraform
  10. Create React App
  11. A Domain name Hosted from any domain name provider ( Ex: AWS Route 53 )
  12. Any Browser for testing

Why S3 ?

Amazon S3 gives every user, its service access to the same highly scalable, reliable, fast, inexpensive data storage infrastructure that Amazon uses to run its own global network of websites. S3 Standard is designed for 99.99% availability and Standard — IA is designed for 99.9% availability.

Why CloudFront ?

With CloudFront Functions, you can write lightweight functions in JavaScript for high-scale, latency-sensitive CDN customizations. Your functions can manipulate the requests and responses that flow through CloudFront, perform basic authentication and authorization, generate HTTP responses at the edge, and more. The two main components of AWS CloudFront are content delivery and dynamic content caching.

Why Github Actions ?

GitHub Actions is a feature provided by GitHub that allows you to automate various tasks within your software development workflows directly from your GitHub repository. It enables you to build, test, and deploy your code directly within GitHub’s ecosystem. Some benefits of using GitHub Actions are native integration with GitHub, flexibility, large ecosystem of Actions, Custom Actions, Workflow Visualization and much more.

1. Let’s Get It Started

React is a free and open-source front-end JavaScript library for building user interfaces based on components. It is maintained by Meta and a community of individual developers and companies.

Step 1 : Create your React Application:

To start, Create your repository in github account as follow :

Make sure you upload the changes in your react web UI repository as we will host the infrastructure as code (terraform and cloudformation) in a different repository.

let sets up your development environment so that you can use the latest JavaScript features, provides a nice developer experience, and optimizes your app for production. You’ll need to have Node >= 14.0.0 and npm >= 5.6 on your machine. To create a project, run:

npx create-react-app prodx-reactwebui-react-demo-1
cd prodx-reactwebui-react-demo-1
npm start

To get ahead, You can download the source code here:

Step 2: Create a Production Build of your application

Let build static contents to serve in AWS S3 Static Web Hosting

npm run build

The above command creates a build directory with a production build of your app. Set up your favorite HTTP server so that a visitor to your site is served index.html, and requests to static paths like /static/js/main.<hash>.js are served with the contents of the /static/js/main.<hash>.js file.

React project Structure

Step 3: Test the application locally

The generated build folder contains the index.html to serve in aws s3 static web hosting, run the command server to test the build contents for production release.

serve -s build

we are going to need the build files for our next part , where terraform will upload these contents to s3 using infrastructure as code.

React and build folder project structure
Serving react production files in windows computer

Infrastructure as code solutions

Infrastructure As Code

Setting up a CI/CD pipeline using Terraform to deploy a React-based single-page application to Amazon S3 and CloudFront with a custom domain name involves several steps. Here’s a high-level overview of the process:

  1. Create an S3 Bucket: Set up an S3 bucket to host your static website assets.
  2. Configure CloudFront Distribution: Create a CloudFront distribution to serve your content globally with low latency.
  3. Set Up Custom Domain: Configure a custom domain name for your application.
  4. CI/CD Pipeline Configuration: Use a CI/CD tool like Jenkins, GitLab CI/CD, or GitHub Actions to automate the deployment process using Terraform.
  5. Terraform Configuration: Write Terraform scripts to define and provision the infrastructure resources required for deployment.

Here’s a detailed guide on how to accomplish each step:

2. Variables definitions:

In Terraform, variables allow you to parameterize your configurations, making them more flexible and reusable. Instead of hardcoding values directly into your Terraform configuration files, you can define variables and then reference them throughout your code.

variable "bucket-name" {
default = "socialcloudsync.com"
}

# Domain name that you have registered
variable "domain-name" {
default = "socialcloudsync.com" // Modify as per your domain name
}

3. provider.tf

A Terraform provider is a plugin responsible for understanding API interactions with a particular infrastructure service. Providers can manage resources, execute operations, and handle authentication and communication with the underlying infrastructure.

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

4. Create an S3 Bucket for static content:

Creating S3 bucket and apply force destroy So, when going to destroy it won’t throw error ‘Bucket is not empty’

# Creating S3 bucket and apply force destroy So, when going to destroy it won't throw error 'Bucket is not empty'
resource "aws_s3_bucket" "s3-bucket" {
bucket = var.bucket-name
force_destroy = true
lifecycle {
prevent_destroy = false
}
}

# Using null resource to push all the files in one time instead of sending one by one
resource "null_resource" "upload-to-S3" {
provisioner "local-exec" {
command = "aws s3 sync ${path.module}/react_app s3://${aws_s3_bucket.s3-bucket.id}"
}
}

resource "null_resource" "upload-to-S3-2" {
provisioner "local-exec" {
command = "aws s3 sync ${path.module}/react_app s3://${aws_s3_bucket.s3-bucket.id}"
}
}


# Keeping S3 bucket private
resource "aws_s3_bucket_public_access_block" "webiste_bucket_access" {
bucket = aws_s3_bucket.s3-bucket.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}

# This Terraform code defines an IAM policy document that allows CloudFront to access objects in the S3 bucket
data "aws_iam_policy_document" "website_bucket" {
statement {
actions = ["s3:GetObject"]
effect = "Allow"
resources = ["${aws_s3_bucket.s3-bucket.arn}/*"]

principals {
type = "*"
identifiers = ["*"]
}
# condition {
# test = "StringEquals"
# variable = "aws:SourceArn"
# values = [aws_cloudfront_distribution.cdn_static_website.arn]
# }
}
}

# Creating the S3 policy for the S3 bucket
resource "aws_s3_bucket_policy" "website_bucket_policy" {
bucket = aws_s3_bucket.s3-bucket.id
policy = data.aws_iam_policy_document.website_bucket.json
}
AWS S3 Web Hosting

Our react application in react_app folder will be uploaded using aws cli s3 command , the folder contains index.html and other react build output files and folders.

5. backend.tf

In Terraform, the backend is the component responsible for storing and retrieving Terraform state files, which contain information about your infrastructure and the resources managed by Terraform. The state file maintains a mapping between the resources in your configuration and the real-world resources they represent, enabling Terraform to manage and update your infrastructure accurately.

5.1 S3 bucket for terraform state management

Just verify first that the bucket where you are going to save the terraform state was already created.

terraform {
backend "s3" {
bucket = "website-app-route53"
region = "us-east-1"
key = "state/terraform.tfstate"
encrypt = true
}
}

Or, you have another option to keep your state and runs at the same place using terraform cloud.

5.2 Terraform Cloud Configuration

HashiCorp provides GitHub Actions that integrate with the Terraform Cloud API. These actions let you create your own custom CI/CD workflows to meet the needs of your organization.

Step 1: Create your project and workplace in terraform cloud

Create project in terraform Cloud

Step 2: Define Variables set to allow terraform cloud for state management

Step 3 : Change the default execution Mode to remote

Step 4 : Create API tokens for Github actions to interact with Terraform Cloud

Terraform cloud API tokens
API Token for your project
Generated Token
Organization token ( Optional )

6. Provision AWS Certificate Manager and Validate:

The Domain Name System (DNS) is a directory service for resources that are connected to a network. Your DNS provider maintains a database containing records that define your domain. When you choose DNS validation instead of email validation, ACM provides you with one or more CNAME records that must be added to this database. These records contain a unique key-value pair that serves as proof that you control the domain.

# ACM certificate resource with the domain name and DNS validation method, supporting subject alternative names

resource "aws_acm_certificate" "cert" {
provider = aws.use_default_region
domain_name = var.domain-name
validation_method = "DNS" # EMAIL is our preference
subject_alternative_names = [var.domain-name]
# wait_for_validation = false

lifecycle {
create_before_destroy = true
}
}

#ACM certificate validation resource using the certificate ARN and a list of validation record FQDNs.
resource "aws_acm_certificate_validation" "cert" {
provider = aws.use_default_region
certificate_arn = aws_acm_certificate.cert.arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}

7. Configure CloudFront Distribution:

CF distributions provide an efficient way of delivering key content to end users all over the world by using a global network of edge locations . An edge location is a geographical site where CloudFront caches copies of commonly downloaded objects such as web pages, images, media files, etc.

# CloudFront distribution with S3 origin, HTTPS redirect, IPv6 enabled, no cache, and ACM SSL certificate.

resource "aws_cloudfront_distribution" "cdn_static_website" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"

origin {
domain_name = aws_s3_bucket.s3-bucket.bucket_regional_domain_name
origin_id = "my-s3-origin"
# origin_access_control_id = aws_cloudfront_origin_access_control.default.id
}

default_cache_behavior {
min_ttl = 0
default_ttl = 0
max_ttl = 0
viewer_protocol_policy = "redirect-to-https"

allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "my-s3-origin"

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

restrictions {
geo_restriction {
locations = []
restriction_type = "none"
}
}

viewer_certificate {
# acm_certificate_arn = ""
acm_certificate_arn = aws_acm_certificate.cert.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}


}

# CloudFront origin access control for S3 origin type with always signing using sigv4 protocol
resource "aws_cloudfront_origin_access_control" "default" {
name = "cloudfront OAC"
description = "description OAC"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}

# Output the CloudFront distribution URL using the domain name of the cdn_static_website resource.
output "cloudfront_url" {
value = aws_cloudfront_distribution.cdn_static_website.domain_name
}

8. Set Up Custom Domain:

To configure a custom domain with CloudFront, you need to create a CloudFront SSL certificate in AWS Certificate Manager (ACM) and associate it with your CloudFront distribution. Then, you can configure Route 53 or your DNS provider to point your custom domain to the CloudFront distribution.

To set up a custom domain using Route 53 with your CloudFront distribution, you’ll need to follow these steps:

Register a Domain: If you haven’t already, register a domain name through Route 53 or another registrar.

Create a Hosted Zone in Route 53: This is where you’ll manage DNS records for your domain.

Create an Alias Record: Alias records are used to map your domain to your CloudFront distribution.

How to register a domain name in Route 53
Route 53 Domain pricing and validation

9. AWS Route53 record resource:

Amazon Route 53 currently supports the following DNS record types:

A (address record)

AAAA (IPv6 address record)

CNAME (canonical name record)

CAA (certification authority authorization)

MX (mail exchange record)

NAPTR (name authority pointer record)

NS (name server record)

PTR (pointer record)

# AWS Route53 zone data source with the domain name and private zone set to false
data "aws_route53_zone" "zone" {
provider = aws.use_default_region
name = var.domain-name
private_zone = false
}

# AWS Route53 record resource for certificate validation with dynamic for_each loop and properties for name, records, type, zone_id, and ttl.
resource "aws_route53_record" "cert_validation" {
provider = aws.use_default_region
for_each = {
for dvo in aws_acm_certificate.cert.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]
type = each.value.type
zone_id = data.aws_route53_zone.zone.zone_id
ttl = 60
}

# AWS Route53 record resource for the "www" subdomain. The record uses an "A" type record and an alias to the AWS CloudFront distribution with the specified domain name and hosted zone ID. The target health evaluation is set to false.
resource "aws_route53_record" "www" {
zone_id = data.aws_route53_zone.zone.id
name = "${var.domain-name}"
type = "A"

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

10. Optional ( Terraform Cloud ) Terraform State management:

For this Guide we have preferred to use S3 bucket instead


terraform {
backend "remote" {
hostname = "app.terraform.io"
organization = "prodxcloud"
workspaces {
name = "prodxcloud"
}
}
}
S3 Bucket for Statement Management
S3 Terraform State Addressing

11. Setup Bucket Policy:

A bucket policy is a resource-based policy option. It allows you to grant more granular access to Object Storage resources.

CloudFront distribution from an S3 bucket

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::socialcloudsync.com/*",
"Condition": {
"StringEquals": {
"aws:SourceArn": "arn:aws:cloudfront::xxx:distribution/E2SZ49FXEKL75W"
}
}
}
]
}

12. Validate and Test your solution

Run Terraform

To init Terraform:

terraform init
terraform fmt
terraform plan

Create the Resources:

terraform apply -auto-approve
Terraform Plan
Terraform Plan

13. CI/CD Pipeline Configuration

A pipeline is a process that drives software development through a path of building, testing, and deploying code, also known as CI/CD. By automating the process, the objective is to minimize human error and maintain a consistent process for how software is released.

To Setup a CI/CD workflows Using Github Actions:

  1. On GitHub.com, navigate to the main page of the repository.
  2. Under your repository name, click Settings. …
  3. In the “Security” section of the sidebar, select Secrets and variables, then click Actions.
  4. Click the Secrets tab.
  5. Click New repository secret.
How to setup Repository secrets in Github for Github Actions

6. Next, define your workflows file using the following script in .github/workflows folder:

name: "Terraform Pipeline Static App Using S3 Cloudfront & Route53"

on:
push:
branches: ['master', 'main']
pull_request:
branches: ['master', 'main']
permissions:
contents: write
env:
TF_LOG: INFO
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
CONFIG_DIRECTORY: "./terraform/aws/terraform-aws-deploy-website-aws-cloudfront-route53-s3/"

jobs:
terraform:
name: "React App Using S3 Cloudfront & Route53"
runs-on: ubuntu-latest
environment: production
defaults:
run:
shell: bash
# We keep Terraform files in the terraform directory.
working-directory: terraform/aws/terraform-aws-deploy-website-aws-cloudfront-route53-s3

steps:
- name: Checkout the repository to the runner
uses: actions/checkout@v2

- name: Setup Terraform with specified version on the runner
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.3.0

- name: Terraform init
id: init
run: terraform init -lock=false

- name: Terraform format
id: fmt
run: terraform fmt

- name: Terraform validate
id: validate
run: terraform validate

- name: Terraform Apply
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
run: terraform apply -auto-approve -input=false -lock=false

7. Next, you have to push your changes to your github repository.

Github Actions Pipeline in progress
Github Actions Pipeline Completed

This setup will provision the S3 bucket and CloudFront distribution. You can further modularize your configuration for better management.

Ensure to replace placeholders like your-bucket-name, your-region, etc., with your actual values.

Remember to run terraform init, terraform plan, and terraform apply commands in your CI/CD pipeline to apply changes.

14. Check result in your AWS Management Console

Terraform bucked the react application build output to s3 bucket
CloudFront Distribution successfully created using terraform
ACM Certificate Manager
Hosted Zone details created by terraform
Attached Route53 Record Set Domain name to CloudFront Address
React App Hosted in AWS CloudFront Using Terraform

Summary

This is a basic outline of how to setup a CI/CD Using Terraform and Github Actions to deploy a React-based single-page application to Amazon S3 and CloudFront with a Custom Domain name in Route53, and you may need to customize it based on your specific requirements and infrastructure setup. Additionally, ensure that you’re following security best practices and optimizing configurations for performance and cost efficiency.

“Simply put, things always had to be in a production-ready state: if you wrote it, you darn well had to be there to get it running!” — Mike Miller

You can also find the codes on Github here.

Thank you for Reading !! 🙌🏻, don’t forget to subscribe and give it a CLAP 👏see you in the next article.🤘

About me

I am Joel O’Wembo, AWS certified cloud architect, Back-end developer, and AWS Community Builder, I‘m based in the Philippines 🇵🇭 I bring a powerful combination of expertise in cloud architecture, DevOps practices, and a deep understanding of high availability (HA) principles. I leverage my knowledge to create robust, scalable cloud applications using open-source tools for efficient enterprise deployments.”

For more information about the author ( Joel O. Wembo ) visit:

Links:

References

--

--

I am a Cloud Solutions Architect, I provide IT solutions using AWS, AWS CDK, Kubernetes, Serverless and Terraform. https://www.linkedin.com/in/joelotepawembo