One of the best things about static sites hosted on AWS is how easy it is to extend them. My main Jekyll blog (theclouddevopslearningblog.com) runs from an S3 bucket behind CloudFront, but I recently wanted to host a standalone JavaScript single-page application (SPA) on a subdomain:

https://breweriesnearme.theclouddevopslearningblog.com

Here’s exactly how I set it up.


1. Creating a New S3 Bucket for the Subdomain

The first step was to create a dedicated S3 bucket to hold the SPA build. Following AWS best practices:

I uploaded the build artifacts (index.html, main.js, assets/, etc.) directly into the bucket root.

💡 Tip: Make sure index.html is at the root of the bucket, not in a subfolder like dist/, unless you plan to set a CloudFront origin path.


2. Requesting an SSL/TLS Certificate in ACM

CloudFront requires certificates to be in the us-east-1 region, so I switched to N. Virginia and requested a new cert for:

breweriesnearme.theclouddevopslearningblog.com

I used DNS validation and, because the domain is managed in Route 53, ACM automatically created the necessary CNAME record. Once validation succeeded, the certificate was ready to attach.


3. Setting Up the CloudFront Distribution

Next, I created a new CloudFront distribution to serve the SPA.

Key settings:

🔁 SPA-Friendly Error Pages

Because SPAs handle routing client-side, I needed to configure CloudFront to serve index.html even when a 403 or 404 occurs:

This ensures deep links like /brewery/42 work correctly.


4. Bucket Policy for CloudFront Access

With OAC enabled, I updated the S3 bucket policy to allow CloudFront to read objects:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontOACRead",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::breweriesnearme.theclouddevopslearningblog.com/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::<ACCOUNT_ID>:distribution/<DISTRIBUTION_ID>"
        }
      }
    }
  ]
}

5. Adding the Subdomain in Route 53

Finally, I added a new DNS record in Route 53:

Once propagation completed, the subdomain pointed to CloudFront and the SPA became publicly accessible.


6. Common Gotchas (and How I Fixed Them)


🧰 CI/CD Deployment

I set up a GitHub Actions pipeline to automatically build and deploy the SPA to S3 and invalidate the CloudFront cache:

name: Deploy Breweries Near Me SPA

on:
  push:
    branches: [ master ]

permissions:
  contents: read

jobs:
  deploy:
    name: Build and Deploy SPA
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Build application
        run: npm run build

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: $
          aws-secret-access-key: $
          aws-region: ap-southeast-2

      # Upload static assets from dist/ (immutable cache)
      - name: Upload dist/ to S3
        run: |
          aws s3 sync dist/ s3://$/ \
            --delete \
            --cache-control "public,max-age=31536000,immutable"

      # Upload images from img/ (long cache but not immutable)
      - name: Upload img/ to S3
        run: |
          aws s3 sync src/img/ s3://$/img/ \
            --delete \
            --cache-control "public,max-age=31536000"

      # Upload index.html separately with no-cache
      - name: Upload index.html
        run: |
          aws s3 cp src/index.html s3://$/index.html \
            --cache-control "no-store" \
            --content-type "text/html"

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id $ \
            --paths "/*"


7. Application Changes: Updating the App and Migrating to OpenBreweryDB

BreweriesNearMe

I wrote BreweriesNearMe as a standalone app (was previously deployed and hosted on a raspberry pi at home), as part of some study years ago (I was doing a Functional Programming in JavaScript course using the Rambda library). The code for this app is pretty cool, as it used the hyperscript-helpers library, which allows you to assign the css class for each element where it is being coded via helper functions (making the styling much more readable, than normal CSS, and for my purposes, was definitely sufficient), e.g:

function fieldSet(labelText, inputValue, oninput) {
    return div({ className: 'w-80'},
    [
        label({ className: 'db mb1 mw-80' }, labelText),
        input({ 
            className: 'pa2 input-reset ba w-100 mb2 br3',
            id: 'addressSearch',
            type: 'text',
            value: inputValue,
            oninput
        }),
    ]);
}

Switching from the proprietary BreweryDB API to the open, community-driven OpenBreweryDB required several code and data handling changes. In my previous blog, I wrote how I scraped, enriched and then added data to this project, so I won’t go over it again today. However, while cutting the source over was straight-forward, there were a few things that I had to update to get everything working smoothly:

🔄 API Endpoint & Data Model Changes

🗺️ Address & Location Handling

📏 Distance Calculation

🧹 Deduplication

🖥️ UI & Table Rendering

🧪 Testing & Edge Cases


With these changes, the app now works seamlessly with OpenBreweryDB, is easier to maintain, and is free from API key or quota restrictions.


Final Thoughts

This project was a great reminder of how flexible AWS’s static hosting model is. With just a few services — S3, CloudFront, ACM, and Route 53 — I was able to stand up a completely separate React application under the same domain as my Jekyll blog, with full HTTPS support, CDN caching, and SPA-friendly routing.