Are you looking to deploy your frontend applications on the cloud? CloudFront and S3 are powerful services provided by Amazon Web Services (AWS) that can help you achieve this. In this blog post, we will walk you through the process of deploying a React App on CloudFront and S3 using Terraform. A popular infrastructure-as-code tool, though this tutorial can be used to deploy any project that contains HTML, CSS and JS.

Let's get started by understanding the role of each services used. We use S3 to store the build assets of react app, and CloudFront CDN for the benefits that a CDN provides namely caching, performance, security, DDOS protection and much more.

We use Terraform to automate the creation and configuration of the AWS resources required. Terraform allows us to create, manage, and version the infrastructure resources in a declarative way. Checkout more about Terraform here.

Overview of the architecture

Pre-requisites and Installations:

Steps:

  1. Create and prepare your React app for deploying.
  2. Creating AWS resources:
    - Creating an S3 bucket and uploading react app.
    - Creating CloudFront distribution.
    - Creating a Bucket Policy to allow CloudFront talk to S3.
  3. Cache Invalidation on new deployments.

Create and prepare your React app for deploying:

Run the following command in your terminal to create a new React app.

npx create-react-app react-app

Navigate into your project, and run the build command to generate build assets for deploying.

cd react-app 
npm run build

This will create a production ready build of your React app in the build directory. The build directory contains the standard HTML and CSS files. This can differ based on the environment you are using for example Gatsby creates public directory and Nextjs static export creates out directory.

Creating AWS Resources

Creating a S3 bucket and uploading our app:

Now that we have React app ready to deploy, we can proceed to creating a S3 bucket to store our app.

Create a terraform directory inside your react-app and create four new files inside the folder: variables.tf , main.tf , backends.tf and dev.tfvars

mkdir terraform
cd terraform
touch {variables,main,backends}.tf
touch dev.tfvars

The variables.tf file will hold our variables structure to reuse across the config, main.tf file will contain the main resource config of our AWS resources and the backends.tf file will hold our configuration for remote backend i.e storing the state of our AWS resources. Checkout more about backends here. The dev.tfvars will be used to assign the values to the variables.

Copy the following code into variables.tf

# Please change the default names as per your requirements.

variable "aws_profile" {
  description = "AWS profile name"
  type        = string
}

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

variable "bucket_name" {
  default = "my-bucket"
  type = string
}

variable "created_by" {
  default = "your name" 
  type = string
}

variable "object_ownership" {
  default = "BucketOwnerPreferred"
  type = string
}

NOTE - You can change the default values of bucket_name and created_by variables according to your requirements.

We are creating a bucket with ACL (Access Control List) as private - since we will be using CloudFront as our primary source for getting objects from S3.

PS: It is a best practise that we never make our buckets public for better security postures.

Now, copy the following code into main.tf

provider "aws" {
  region  = var.aws_region
  profile = var.aws_profile
}

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

  website {
    index_document = "index.html"
    error_document = "index.html"
  }

  tags = {
    Name       = var.bucket_name
    Created_By = var.created_by
  }

}

resource "aws_s3_bucket_ownership_controls" "ownership_controls" {
  bucket = aws_s3_bucket.deployment_bucket.id
  rule {
    object_ownership = var.object_ownership
  }
}


resource "aws_s3_bucket_acl" "s3_bucket_acl" {
  depends_on = [aws_s3_bucket_ownership_controls.ownership_controls]
  bucket     = aws_s3_bucket.deployment_bucket.id
  acl        = "private"
}

This Terraform code defines an S3 bucket with a website configuration, ownership controls, and ACL set to private. The index_document and error_document properties specify that index.html should be served as the default document for the bucket since we handle error pages and routing for react apps through a single entrypoint.

If you are using any other framework, you can specify the pages accordingly as per the build outputs by the framework.

Copy the following code into backends.tf

terraform {
  backend "s3" {
    bucket         = "your-terraform-state-bucket"
    key            = "example/terraform.tfstate"
    region         = "us-east-1"  # specify your AWS region
    encrypt        = true
  }
}

The backends.tf file in a Terraform project is typically used to define the configuration for remote backends i.e where the Terraform state files are stored. This state is basically holding your AWS resources version history and much more. This file allows users to abstract and centralize backend configuration details, keeping the main Terraform configuration files cleaner and focused on resource definitions.

Copy the following code into dev.tfvars

aws_profile = "default"
aws_region  = "us-east-1"
bucket_name = "my-bucket-name"

Tfvars files enable dynamic inputs, facilitate consistent deployments, and promote collaboration in team settings. When applying Terraform configurations, the -var-file option allows users to specify a tfvars file, ensuring that the configuration is applied with the correct variable values.

Now that we have the terraform code ready, let’s deploy the resources. Make sure you're in the terraform directory.

To initialize Terraform and download the necessary provider plugins, run the following command:

terraform init

Next, we will confirm what resources terraform is going to create for us based on our config provided in the code.

terraform plan

After verifying the resources that terraform will be creating for us, let’s create them.

terraform apply -var-file="dev.tfvars"

Terraform will prompt you for your AWS profile and region, once you enter them it will show the changes that will be made and ask you to confirm. Type "yes" and press Enter to proceed with the deployment. Terraform will create the resources according to the specified configuration.

With our resources successfully created for us, we can now upload our React app's build to S3. You can use the AWS Command Line Interface (CLI) to sync the local build with the S3 bucket.

aws s3 sync <local-path-of-the-build> <s3-url-path>

# eg -> aws s3 sync ./build s3://<my-bucket>

Replace <local-path-of-the-build> with the local path of your React app's build directory.  In our example, we're syncing the contents of the build directory to the S3 bucket named my-bucket. Make sure the S3 bucket name matches the one you specified in the variables.tf file.

Creating CloudFront distribution for our app.

Now that the React app is synced in the S3 bucket, we can set up a CloudFront distribution to serve the app and take advantage of its content delivery network (CDN) capabilities.

Update the main.tf file to include the CloudFront distribution config:

  .... previous existing code 

  resource "aws_cloudfront_origin_access_control" "cloudfront_oac" {
    name                              = "My_Cloudfront-OAC"
    description                       = "The origin access control configuration for the Cloudfront distribution"
    origin_access_control_origin_type = "s3"
    signing_behavior                  = "always"
    signing_protocol                  = "sigv4"
  }
  
  resource "aws_cloudfront_distribution" "website_cdn" {
    enabled = true
    
    origin {
      domain_name              = aws_s3_bucket.deployment_bucket.bucket_regional_domain_name
      origin_access_control_id = aws_cloudfront_origin_access_control.cloudfront_oac.id
      origin_id                = "origin-bucket-${aws_s3_bucket.deployment_bucket.id}"
    }

    default_root_object = "index.html"

    default_cache_behavior {
      allowed_methods        = ["GET", "HEAD", "DELETE", "OPTIONS", "PATCH", "POST", "PUT"]
      cached_methods         = ["GET", "HEAD"]
      min_ttl                = "0"
      default_ttl            = "300"
      max_ttl                = "1200"
      target_origin_id       = "origin-bucket-${aws_s3_bucket.deployment_bucket.id}"
      viewer_protocol_policy = "redirect-to-https"
      compress               = true

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

    restrictions {
      geo_restriction {
        restriction_type = "none"
      }
    }

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

    viewer_certificate {
      cloudfront_default_certificate = true
    }

    tags = {
      Created_By = var.created_by
    }
  }

In this configuration, we define a CloudFront distribution that uses the S3 bucket as its origin. The default_root_object property specifies that index.html should be the default document when accessing the CloudFront distribution. The viewer_certificate block enables the default SSL certificate provided by CloudFront for secure communication.

Here we are using the default domain provided by CloudFront for simplicity. We just added the prefix of bucket regional-name to have unique domain names. This domain name will be used by the users to access react app stored in S3 through CloudFront.

In order to configure the custom domains, check AWS docs here.

Creating a Bucket Policy to allow CloudFront talk to S3.

Now that we have the CloudFront config ready, in order for CloudFront to distribute and serve our app we need to allow it to fetch the build assets that we have stored in S3.

To allow CloudFront to get the files from S3, we need to add the S3 bucket policy in our main.tf file:

	.... previous existing code
    
    resource "aws_s3_bucket_policy" "bucket_policy" {
      bucket = aws_s3_bucket.deployment_bucket.id
	  policy = jsonencode({
	    "Version" : "2012-10-17",
	    "Statement" : [
	      {
	        "Sid" : "AllowCloudFrontServicePrincipalReadOnly",
	        "Effect" : "Allow",
	        "Principal" : {
	          "Service" : "cloudfront.amazonaws.com"
	        },
	        "Action" : "s3:GetObject",
	        "Resource" : "${aws_s3_bucket.deployment_bucket.arn}/*",
	        "Condition" : {
	          "StringEquals" : {
	            "AWS:SourceArn" : "${aws_cloudfront_distribution.website_cdn.arn}"
	          }
	        }
	      }
	    ]
	  })
	}

Since we only need to fetch files and serve it, we add the read only permission for CloudFront. Now that we have our config code ready, let’s deploy our new changes.

A good practise is to always run validate command to check if we have any issues with our code.

terraform validate

Next we check what resources will be created / updated / deleted.

terraform plan

After confirming our changes, we can finally deploy them:

terraform apply

After the deployment of the resources is finished, Terraform will provide information about the resources that were created. Look for the CloudFront distribution's domain name in the output. This domain name will serve as the URL for accessing your React app. Wait for a few minutes for the CloudFront distribution to deploy and propagate  changes across the network. After the deployment is complete, you can access your React app by visiting the CloudFront url.

Congratulations! You have successfully deployed your React app on CloudFront and S3 using Terraform. The combination of CloudFront's CDN and S3 allows for efficient content delivery with low latency and scales well.

To deploy a new version of the your app, make the changes to the React App, rebuild your assets and re-run the aws cli sync command:

aws s3 sync <local-path-of-the-build> <s3-url-path>

# eg -> aws s3 sync ./build s3://my-bucket

Cache Invalidation on new deployments.

By default CloudFront distribution caches the resources it fetches from S3. This becomes problematic when you have to deploy a new update to your app. When changes are made to the content stored in S3 bucket, such as updating files or adding new ones, CloudFront may continue to serve the old cached versions. To address this, an invalidation request needs to be initiated to CloudFront through the AWS Management Console, SDK, or API, targeting specific paths or the entire distribution. This triggers the removal of outdated content from CloudFront's edge locations, forcing the CDN to fetch the latest resources from the S3 origin upon subsequent user requests.

For the purpose of this blog we are going to run the file manually but you can run this within your CD pipelines. You can run this file after you make any changes to the content inside your buckets such as adding/removing/updating any files.

Create a javascript file:

mkdir scripts && cd scripts
touch deploy.js

Add shelljs package to run the aws commands through the script:

npm i shelljs

Add the following code to the file:

#!/usr/bin/env node

const shell = require('shelljs');

const config =  {
    deploy:{
        bucketUrl: 's3://<your-bucket-name>',
        cloudFrontDistributionId: '<your-cloudfront-distribution-id>',
        cacheControlMaxAge: 31536000
    }
}
console.log('config deploy:', config.deploy.bucketUrl);

const tryShell = (command) => {
  shell.exec(command);
  if (shell.error()) process.exit(1);
};

// upload
console.log('going for fix content-type:---------->');
tryShell(`aws s3 sync --delete ../build ${config.deploy.bucketUrl}`);

console.log('going for invalidate cloudfront cache:---------->');
// invalidate cloudfront cache
tryShell(
  `aws configure set preview.cloudfront true && aws cloudfront create-invalidation --distribution-id ${config.deploy.cloudFrontDistributionId} --paths "/*"`
);

To get the bucket url follow these steps:-

  1. Open the AWS S3 console and search for the bucket name
  2. Copy the bucket name

To get the cloudfront distribution ID follow these steps:-

  1. Open the AWS Cloudfront console
  2. In the CloudFront dashboard, you will see a list of your distributions. The "ID" column contains the unique identifier for each distribution.
  3. Look for the distribution that corresponds to your application. The distribution ID is a unique string that starts with "E" and is followed by a series of letters and numbers.

After you have successfully retrieved the bucket url and cloudfront distribution ID, run the script:

node deploy.js

And this will firstly delete the S3 contents to remove the files or objects from the target that are not present in the source. Next it will copy the build folder contents and set the max-age. Lastly it will invalidate your CloudFront distribution cache.

You can add this command to your scripts in package.json file:

{
  "scripts": {
    "deploy": "node scripts/deploy.js"
  },
  ... other dependencies
}

And that's it! You can find the complete source code here at: https://github.com/everestek/react-s3-terraform