Serve your React app with AWS Cloudfront using Gitlab and Terraform

Mickael Camus
Geek Culture
Published in
9 min readJul 4, 2021

In this article we will see how to deploy a React app behind Cloudfront using Terraform for the AWS configuration and Gitlab CI for the deployment.

Terraform initialisation

We will use terraform through this tutorial so let’s start by initializing our workspace.

$ mkdir terraform-react && cd terraform-react
$ terraform init

S3 Bucket creation

The first thing we will need is an empty S3 bucket to which we will upload our React compiled files.

Create a new main.tf file and add the configuration for our bucket. The bucket name used in this tutorial is “my-react-bucket”. Bucket names are globally unique on AWS so you will need to change the name for your own unique one.

Note: We want our files to only be accessed by Cloudfront so we add the aws_s3_bucket_public_access_block terraform block to forbid our files to become public.

$ terraform init
$ terraform apply
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:# aws_s3_bucket.static_react_bucket will be created
+ resource "aws_s3_bucket" "static_react_bucket" {
+ acceleration_status = (known after apply)
+ acl = "private"
+ arn = (known after apply)
+ bucket = "my-react-bucket"
+ bucket_domain_name = (known after apply)
+ bucket_regional_domain_name = (known after apply)
+ force_destroy = false
+ hosted_zone_id = (known after apply)
+ id = (known after apply)
+ region = (known after apply)
+ request_payer = (known after apply)
+ tags = {
+ "Name" = "my-react-bucket"
}
+ tags_all = {
+ "Name" = "my-react-bucket"
}
+ website_domain = (known after apply)
+ website_endpoint = (known after apply)
+ versioning {
+ enabled = true
+ mfa_delete = false
}
}
# aws_s3_bucket_public_access_block.block_public_access will be created
+ resource "aws_s3_bucket_public_access_block" "block_public_access" {
+ block_public_acls = true
+ block_public_policy = true
+ bucket = (known after apply)
+ id = (known after apply)
+ ignore_public_acls = true
+ restrict_public_buckets = true
}
Plan: 2 to add, 0 to change, 0 to destroy.Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yesaws_s3_bucket.static_react_bucket: Creating...
aws_s3_bucket.static_react_bucket: Creation complete after 3s [id=my-react-bucket]
aws_s3_bucket_public_access_block.block_public_access: Creating...
aws_s3_bucket_public_access_block.block_public_access: Creation complete after 1s [id=my-react-bucket]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

IAM access for gitlab

Gitlab CI will be configured to automatically upload our React code to our freshly created S3 bucket. In AWS permissions are managed by a service called IAM (Identity & Access Management). Let’s create a new policy for our Gitlab user to give it access to our bucket.

Go to https://console.aws.amazon.com/iam/home?region=us-east-1#/users and create a new user with only Programmatic Access named “gitlab-user”. Don’t give any permission for now, we will handle this with terraform. Download the access keys as we will need them to configure Gitlab CI.

Create a new iam.tf file in the terraform- react folder, with the following content:

$ terraform apply
...
Plan: 2 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yesaws_iam_policy.ci_policy: Creating...
aws_iam_policy.ci_policy: Creation complete after 2s [id=arn:aws:iam::XXXXXXXXXXXX:policy/gitlab-ci-policy]
aws_iam_policy_attachment.gitlab_ci_attachment: Creating...
aws_iam_policy_attachment.gitlab_ci_attachment: Creation complete after 1s [id=gitlab-ci-attachment]
Apply complete! Resources: 2added, 0 changed, 0 destroyed.

We now have a gitlab-user IAM user who is allowed to upload files on our S3 bucket. Next we will configure Gitlab CI to automate the deployment.

Configure Gitlab CI

First, we need to tell gitlab CI to use our user when running the pipeline. To do so, go to the CI settings (Settings -> CI/CD -> Variables).

Add the variable AWS_ACCESS_KEY_ID with the value you got earlier.
Add the variable AWS_SECRET_ACCESS_KEY with the value you got earlier.

Gitlab CI variables configuration

Second, create a new file at the root of your React project named .gitlab-ci.yml. This file will contain the configuration and stages to be run by the Gitlab CI runners. For this tutorial, we will create a basic file that will handle only two steps: building the app and uploading it to S3.

You can check the pipeline status on gitlab. Once pipeline has succeeded you can verify that the files are on the bucket using aws cli.

$ aws s3 ls s3://my-react-bucket
PRE static/
2021-07-04 17:34:05 1092 asset-manifest.json
2021-07-04 17:34:05 3870 favicon.ico
2021-07-04 17:34:05 3032 index.html
2021-07-04 17:34:05 5347 logo192.png
2021-07-04 17:34:05 9664 logo512.png
2021-07-04 17:34:05 492 manifest.json
2021-07-04 17:34:05 67 robots.txt

Let’s stop here and see where we are! We have created the CI configuration so that each time we push on master our code is being built and uploaded on S3. Our gitlab-user IAM user only has access to this bucket and can only upload objects, which is preferred as it does not need any other permission.

Our bucket is private and does not allow public files, so how do we access the files?

Cloudfront distribution

Cloudfront is one of the major CDN (Content Delivery Network). It allows for efficient caching and fast files delivery to users. You don’t pay for bandwidth between S3 and Cloudfront only what goes out of Cloudfront.

Note: You can check this blog post to better understand advantages of using Cloudfront with S3: https://aws.amazon.com/blogs/networking-and-content-delivery/amazon-s3-amazon-cloudfront-a-match-made-in-the-cloud/

We will use terraform to create our Cloudfront web distribution.

Let’s have a look at some specificity we have for React:

  • default_cache_behavior: this block will allow for efficient caching (basically we cache everything :)). Also we redirect all http calls to https. Finally we ask cloudfront to compress our static files to reduce bandwidth usage.
  • ordered_cache_behavior: when you release a new version of your app, your code will change. JS files will contain new hashes in their name allowing browsers to retrieved the new file. However your index.html file will still be called index.html and so will still be cached for at least 86400 seconds. To avoid this behavior we add a rule to tell Cloudfront to not cache index.html. This way each time your new code is being deployed your users will get the new index.html file.
  • custom_error_response: if you use a library like React Router you will use paths that don’t exist on S3 (by default if you want to have a /home route and try to navigate directly to this route, cloudfront will try to find it on S3. As it doesn’t exist you will get a 403 error). To deal with such cases we add 2 custom error responses to redirect 403 and 404 to the index.html file.

You can now run terraform with this new file. Be patient ☕ cloudfront distributions take a while to be created.

$ terraform apply
...
Plan: 2 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yesaws_cloudfront_origin_access_identity.oai: Creating...
aws_cloudfront_origin_access_identity.oai: Creation complete after 1s [id=E11IPI5S9FK6LQ]
aws_cloudfront_distribution.cf_distribution: Creating...
aws_cloudfront_distribution.cf_distribution: Still creating... [10s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [20s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [30s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [40s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [50s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [1m0s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [1m10s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [1m20s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [1m30s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [1m40s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [1m50s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [2m0s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [2m10s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [2m20s elapsed]
aws_cloudfront_distribution.cf_distribution: Still creating... [2m30s elapsed]
aws_cloudfront_distribution.cf_distribution: Creation complete after 2m31s [id=E1Q7A09488OCJI]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Note: Navigate to https://console.aws.amazon.com/cloudfront/home to retrieve the distribution URL

If you try to access the index.html file now, you will receive a 403 error

403 issue when requesting index.html

The issue is that for now your S3 bucket is still private and does not allow anyone to access the content of the bucket. We need to give our Cloudfront distribution access to the S3 bucket.

Add the following to your terraform main.tf file.

$ terraform apply
...
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yesaws_s3_bucket_policy.react_app_bucket_policy: Creating...
aws_s3_bucket_policy.react_app_bucket_policy: Creation complete after 1s [id=my-react-bucket-20210704]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Retry to call the index.html page on your distribution and you should now see your app!

Basic CRA index.html

Bonus: use your own domain

Until now we used Cloudfront distribution default domain (https://….cloudfront.net). In general you will want to use your own domain as an entrypoint. Cloudfront will also allow for that. Let’s see how we can achieve it.

First, create a new certificate in ACM (Amazon Certificate Manager) in us-east-1 region. You MUST use this region for your certificate to be eligible for use with Cloudfront. Go to https://console.aws.amazon.com/acm/home?region=us-east-1 and create your certificate for your domain (example.com in our example). Note the certificate ARN for further usage.

Note: I didn’t include the terraform code for the ACM request as you need to change the provider’s region for this. Refer to my other article if you want to have a working example of multi region providers.

Second, create the route53 record:

$ terraform apply
...
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yesaws_route53_record.cf_dns: Creating...
aws_route53_record.cf_dns: Still creating... [10s elapsed]
aws_route53_record.cf_dns: Still creating... [20s elapsed]
aws_route53_record.cf_dns: Still creating... [30s elapsed]
aws_route53_record.cf_dns: Creation complete after 33s [id=Z07080572RGHUGWY9O1KG_example.com_A]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Our domain is ready, we can now update our Cloudfront distribution to allow for this domain.

Don’t forget to remove the previous viewer_certificate block!

$ terraform apply
...
Plan: 0 to add, 1 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yesaws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 10s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 20s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 30s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 40s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 50s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 1m0s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 1m10s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 1m20s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 1m30s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 1m40s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 1m50s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 2m0s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 2m10s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 2m20s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 2m30s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 2m40s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 2m50s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 3m0s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 3m10s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 3m20s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 3m30s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 3m40s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 3m50s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 4m0s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 4m10s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 4m20s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 4m30s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 4m40s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 4m50s elapsed]
aws_cloudfront_distribution.cf_distribution: Still modifying... [id=E1Q7A09488OCJI, 5m0s elapsed]
aws_cloudfront_distribution.cf_distribution: Modifications complete after 5m7s [id=E1Q7A09488OCJI]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

🚀 Your application is now accessible from your own domain!

Conclusion

Hosting a React app on AWS using S3 and Cloudfront is easy and allow for almost unlimited scaling with very limited costs.

Now each time you push on master your app will be auto deployed thanks to Gitlab CI integration.

--

--

Mickael Camus
Geek Culture

Software developer, AWS Associate Solutions Architect, entrepreneur