# S3rcel: Deploying PR Previews for React Using AWS S3 & CloudFront

# Introduction

If you've used [Vercel](https://vercel.com), you've likely seen how it provides **PR Preview Deployments** — a feature that generates a unique URL for each pull request, allowing developers to test changes before merging. This is incredibly useful for frontend applications, as it enables quick feedback cycles.

I wanted to replicate this system (as a developer, because why not?) using **AWS Infrastructure**, so I built my own, **S3rcel — A** **Pull Request Preview / Deployment System** for **React applications**, leveraging:

* **GitHub Actions** for automation
    
* **AWS S3** for static file storage
    
* **CloudFront** for fast global delivery
    

![Image](https://pbs.twimg.com/media/GmKa7sObAAAsvPr?format=jpg&name=large align="center")

In this guide, I'll walk you through how to set up AWS, configure GitHub, and explain the workflows powering this deployment system. I will mostly be focusing on setting up AWS, so I'll provide you with everything else you need to get started.

# **Prerequisites**

Before getting started, make sure you have:

1. **An AWS account** with root access or privileges to create an IAM user with the necessary permissions.
    

%[https://aws.amazon.com] 

2. **Fork the Repository:** To streamline the process and focus more on setting up AWS, you can start by forking the repository below. The following repository contains three workflows and a basic React application with Tailwind CSS styling. I will discuss the workflows once we are done with the AWS setup.
    

%[https://github.com/kartikmehta8/s3rcel-pr-preview] 

Once you have forked the repository, you need to set up the following **secrets** in your repository under **Settings → Secrets and Variables → Actions** as we continue progressing through the blog.

| Secret Name | Description |
| --- | --- |
| `AWS_ACCESS_KEY_ID` | Your AWS access key ID with permissions for S3 and CloudFront |
| `AWS_SECRET_ACCESS_KEY` | Your AWS secret access key |
| `AWS_REGION` | The AWS region where your S3 bucket is located (e.g., `ap-south-1`) |
| `AWS_S3_BUCKET_NAME` | The name of your S3 bucket for storing the previews |
| `CLOUDFRONT_DISTRIBUTION_ID` | The ID of your CloudFront distribution |
| `CLOUDFRONT_DISTRIBUTION_DOMAIN_NAME` | The domain name of your CloudFront distribution |

As we have completed the initial setup, let's log in to the [AWS Console](http://console.aws.com/console/home) and start setting up the environment.

# **Setup - S3 Bucket**

1. Go to the **AWS S3 Console**.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742301284342/c8c6eeda-8d54-46d3-b3c4-e8d5f8ee184b.png align="center")
    
2. Click **Create bucket**.
    
3. Set a **unique bucket name**.
    
4. **Uncheck** "Block all public access".
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742301759974/8e3812dc-d90d-4641-8500-535477d923d4.png align="center")
    
5. Leave the other settings as default.
    
6. Click **Create bucket**. You will see something like this: *(Your bucket will be empty initially.)*
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742301903302/17180918-8c32-4701-9919-bdac22d4456e.png align="center")
    
7. Enable **Static Website Hosting**: Go to the **Properties** tab, and scroll down to the bottom.
    
    1. Click on **Edit**.
        
        ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742302296534/9b04eb10-779c-48fc-b43f-f44d70a667f9.png align="center")
        
    2. Enable **Static website hosting**.
        
        ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742302414782/58b48348-c668-49e3-b3fe-84feb682d6de.png align="center")
        
8. Set up the **Bucket Policy**: Go to the **Permissions** tab, paste the following policy under **Bucket Policy**, and save it. (Make sure to replace `"your-bucket-name"` with the actual name of your bucket.)
    
    ```json
    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Principal": "*",
                "Action": "s3:GetObject",
                "Resource": "arn:aws:s3:::<your-bucket-name>/*"
            }
        ]
    }
    ```
    
    This S3 bucket policy allows public read access to all objects in the specified bucket name. The `"Principal": "*"` means anyone can access it, while `"Action": "s3:GetObject"` permits reading objects. The `"Resource"` specifies the bucket and its contents.
    
9. Check the **AWS Region**: You can find the region in the **AWS Management Console** at the top-right corner. Make sure you're using the correct region for your setup.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742303193992/949d70b5-fc81-4a11-8bb1-49b555c8b335.png align="center")
    

Alright, the **S3 setup** is complete! Now, you can add the following **two secrets** in your GitHub repository under:

| `AWS_REGION` | The AWS region where your S3 bucket is located. |
| --- | --- |
| `AWS_S3_BUCKET_NAME` | The name of your S3 bucket for storing the previews. |

Let's start setting up **AWS CloudFront**.

# **Setup - CloudFront**

1. Go to the **AWS CloudFront Console**.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742317438012/ece163be-f3b5-4c65-b86e-effb2bff25b5.png align="center")
    
2. Click **Create Distribution**.
    
3. Set **Origin Domain Name** to the **S3 bucket URL**.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742363707162/a470fc13-fee9-4d55-a6bf-b78bd01a3fa9.png align="center")
    
4. Set **Viewer Protocol Policy** to `Redirect HTTP to HTTPS`.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742363828776/ec44a839-8c9d-4eba-b694-4a0ff83895c1.png align="center")
    
5. Scroll down to the **Settings** section and set the Default Root Object to **index.html**.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742364235940/c29692d3-d5db-42df-93bc-d62096f0306d.png align="center")
    
6. Click **Create Distribution**.
    

We have completed the CloudFront setup. Now, go to the newly created distribution. You should be able to see the CloudFront Distribution ID and the Distribution Domain Name.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742364548152/9e0947e5-7bda-4887-b0de-c60eb43a4bd4.png align="center")

Now, you can add the following **two secrets** in your GitHub repository under:

| `CLOUDFRONT_DISTRIBUTION_ID` | The ID of your CloudFront distribution |
| --- | --- |
| `CLOUDFRONT_DISTRIBUTION_DOMAIN_NAME` | The domain name of your CloudFront distribution (without `https://`) |

Now, the only remaining step is to obtain the **Access Key ID** and **Secret Access Key**.

# **Setup - IAM**

1. Go to **AWS Console &gt; IAM**.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742364960291/eb9c174b-1ad9-4b1f-a0a1-19426ffc3bab.png align="center")
    
2. On the left-hand side, under **Access Management**, click on **Users**. This is where you can create users and generate their access keys and secrets. Click **Create user**.
    
3. Enter a **username** and click **Next** to proceed.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742365919232/a6ac11ed-13d0-4b75-bb26-50070c873014.png align="center")
    
4. In the **Permissions** section, select **Attach policies directly**.
    
    Scroll down to view the available policies. We only need to attach two policies:
    
    * **CloudFrontFullAccess**
        
    * **AmazonS3FullAccess**
        
    
    Select these policies and click **Next** to proceed.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742366455990/b24b012d-16f4-4660-9002-7b77fe627c67.png align="center")
    
5. Click **Create user**. You have successfully created the user.
    
6. Now, to generate the **Access Key ID** and **Secret Access Key** for this user, select the user you just created. Navigate to the **Security Credentials** tab.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742366753723/1c704660-16f9-47f0-987e-b1d1c74cd4e8.png align="center")
    
7. Scroll down to the **Access Keys** section and click **Create access key**.
    
8. Select **Command Line Interface (CLI)** as the use case, acknowledge the warning at the bottom, and click **Next** to proceed.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742367032639/8c8c9358-9d55-49ce-a423-5421c7e60aa1.png align="center")
    
9. Click **Create access key**. You can see your **Access Key ID** and **Secret Access Key**.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742367235473/e78fce88-01da-402b-9f74-5bb3dc4b5f25.png align="center")
    

Now, you can add the following **two secrets** in your GitHub repository under:

| `AWS_ACCESS_KEY_ID` | Your AWS access key ID with permissions for S3 and CloudFront |
| --- | --- |
| `AWS_SECRET_ACCESS_KEY` | Your AWS secret access key |

We have completed all the necessary configurations in the **AWS Console**.

Now, it's time to set up **GitHub** and go through the workflows that will handle **PR Previews** and **Deployments** automatically.

# **Setup - GitHub**

1. I assume you have already set up the required secrets under **Settings → Secrets and Variables → Actions**, so I'll skip that step and move forward with the next part of the setup.
    
    ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742367880731/6b042c2f-debc-4869-ad19-3ce3c5a471d0.png align="center")
    
2. Go to **Settings → Actions → General** in your GitHub repository and ensure your settings match the following configuration:
    

| **Workflow permissions** | Read and write permissions. Check - Allow GitHub Actions to create and approve pull requests. |
| --- | --- |
| **Approval for running fork pull request workflows from contributors** | Require approval for first-time contributors. |
| **Actions permissions** | Allow all actions and reusable workflows. |

Great! We’ve completed all the setup. Now, we’re ready to create a **Pull Request (PR)** with changes and watch the workflow in action.

# Understanding the Workflows

But wait! Before we dive in, let’s take a moment to **understand the workflow** that powers and supercharges this project.

### PR Preview Deployment

```yaml
name: Deploy PR Preview

on:
  pull_request:
    branches:
      - main
    types: [opened, synchronize, reopened]

permissions:
  pull-requests: write

jobs:
  deploy-preview:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v3

      - name: Install Dependencies
        run: npm install

      - name: Fix React Build Paths
        run: echo "REACT_APP_PUBLIC_URL=/" > .env

      - name: Build React App
        run: npm run build

      - name: Configure AWS Credentials
        run: |
          aws configure set aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws configure set aws_secret_access_key ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws configure set region ${{ secrets.AWS_REGION }}

      - name: Set PR Deployment Path
        run: echo "DEPLOY_PATH=pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV

      - name: Deploy to S3 (Preview)
        run: aws s3 sync ./build s3://${{ secrets.AWS_S3_BUCKET_NAME }}/${{ env.DEPLOY_PATH }} --delete

      - name: Set Correct Content-Type for JS Files
        run: |
          aws s3 cp s3://${{ secrets.AWS_S3_BUCKET_NAME }}/${{ env.DEPLOY_PATH }} s3://${{ secrets.AWS_S3_BUCKET_NAME }}/${{ env.DEPLOY_PATH }} --recursive --exclude "*" --include "*.js" --metadata-directive REPLACE --content-type "application/javascript"

      - name: Invalidate CloudFront Cache
        run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"

      - name: Comment on PR with Preview URL
        uses: thollander/actions-comment-pull-request@v2
        with:
          message: "🚀 Preview deployed: [View Preview](https://${{ secrets.CLOUDFRONT_DISTRIBUTION_DOMAIN_NAME }}/${{ env.DEPLOY_PATH }}/index.html)"
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

This workflow triggers **whenever a PR is created or updated** against the `main` branch. It installs dependencies, builds the React project, and uploads the build files to an **S3 bucket under a PR-specific path** (`pr-<PR_NUMBER>`). Once uploaded, CloudFront ensures that the preview is accessible globally, and a **comment is posted on the PR with the preview link**. This allows reviewers to test the changes before merging. Each time the PR is updated, the deployment is refreshed with the latest changes.

### Main Deployment

```yaml
name: Deploy to Production

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v3

      - name: Install Dependencies
        run: npm install

      - name: Fix React Build Paths
        run: echo "REACT_APP_PUBLIC_URL=/" > .env

      - name: Build React App
        run: npm run build   

      - name: Configure AWS Credentials
        run: |
          aws configure set aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws configure set aws_secret_access_key ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws configure set region ${{ secrets.AWS_REGION }}

      - name: Deploy to S3
        run: aws s3 sync ./build s3://${{ secrets.AWS_S3_BUCKET_NAME }}/main --delete

      - name: Set Correct Content-Type for JS Files
        run: |
          aws s3 cp s3://${{ secrets.AWS_S3_BUCKET_NAME }}/main s3://${{ secrets.AWS_S3_BUCKET_NAME }}/main --recursive --exclude "*" --include "*.js" --metadata-directive REPLACE --content-type "application/javascript"

      - name: Invalidate CloudFront Cache
        run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"
```

Every time code is pushed to `main`, this workflow kicks in to **deploy the latest production build**. It follows the same steps as the PR preview workflow but uploads the build to the `main` **directory** in S3. Additionally, it **invalidates the CloudFront cache** to ensure that users receive the latest version of the deployed app without waiting for cache expiry.

### Cleanup Workflow

```yaml
name: Cleanup PR Preview

on:
  pull_request:
    types: [closed]

jobs:
  cleanup-preview:
    runs-on: ubuntu-latest

    steps:

      - name: Remove PR Preview from S3
        run: aws s3 rm s3://${{ secrets.AWS_S3_BUCKET_NAME }}/pr-${{ github.event.pull_request.number }} --recursive
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: ${{ secrets.AWS_REGION }}
```

Once a PR is **closed or merged**, there is no need to retain the preview deployment. This workflow automatically deletes the PR’s specific deployment folder from **S3**, keeping the storage clean and avoiding unnecessary clutter. This ensures that old, unused previews do not persist indefinitely.

# Give It a Try!

Want to see it in action? Just create a **Pull Request (PR) to the** `main` **branch** in the forked repository, and GitHub Actions will automatically deploy a preview of your changes. A comment will be posted on the PR with the preview link, allowing you to test the deployment before merging. Check it out and experience the seamless PR preview workflow! 💪

Here's an example Pull Request:

%[https://github.com/kartikmehta8/s3rcel-pr-preview/pull/20] 

# **Conclusion**

This project demonstrates how you can **self-host a Vercel-like PR preview system** using AWS infrastructure. Instead of relying on third-party services, we leveraged S3 for storage, CloudFront for efficient global distribution, and GitHub Actions for automation. As a developer, building your own tools not only gives you greater control but also deepens your understanding of cloud infrastructure and CI/CD workflows. If you're using AWS in your stack, taking the time to **set up custom dev tools** like this can save costs and provide a tailored experience. Give it a try, experiment with AWS, and start deploying your own frontend projects seamlessly!

If you ever need help or just want to chat, **DM me on Twitter / X or LinkedIn.**

[**Kartik Mehta**](https://www.mrmehta.in/)

[***X***](https://x.com/kartik_mehta8) */* [***LinkedIn***](https://www.linkedin.com/in/kartikmehta17)
