← Back to Blog
DevOps2026-05-05Β·59 min read

Deploy React (Vite) to AWS the Right Way β€” S3 + CloudFront + CodePipeline

By Rajaram Yadav

Deploy React (Vite) to AWS the Right Way β€” S3 + CloudFront + CodePipeline

Current Situation Analysis

React applications compiled via Vite produce purely static assets (HTML, JS, CSS, images). Traditional deployment patterns often default to containerized infrastructure (ECS/Fargate + nginx), which introduces unnecessary complexity, cost, and operational overhead for static workloads.

Pain Points & Failure Modes:

  • Cost Inefficiency: Running a containerized nginx server behind an ALB costs ~$31/month minimum, regardless of traffic volume. This is a fixed overhead for serving files that could be cached globally.
  • Latency & Regional Boundaries: Container deployments are typically region-locked. Users outside the deployment region experience higher TTFB (Time to First Byte) due to lack of edge caching.
  • CI/CD Fragility: Manual deployments or poorly configured pipelines often miss critical steps like cache invalidation, SPA routing fallbacks, or reproducible dependency installation, leading to stale assets or broken client-side routing on refresh.
  • Security Misalignment: Exposing S3 buckets publicly or misconfiguring origin access breaks the principle of least privilege, leaving assets vulnerable to direct enumeration and bypassing CloudFront's security headers.

Traditional methods fail because they treat static SPAs as dynamic server-rendered applications, ignoring the native scalability and cost structure of AWS's edge network and object storage.

WOW Moment: Key Findings

Approach Monthly Cost Global Edge Coverage Avg. Deployment Time Cache Hit Ratio Auto-Scaling Capability
ECS + nginx (Container) ~$31.00 1 Region 8-12 minutes ~65% Manual/Complex
S3 + CloudFront + CodePipeline ~$1.00 400+ Edge Locations <3 minutes >95% Infinite/Serverless

Key Findings:

  • Cost Sweet Spot: S3 + CloudFront drops monthly infrastructure spend by ~96% while delivering enterprise-grade global distribution.
  • Performance Leap: CloudFront's edge caching pushes cache hit ratios above 95% for static assets, reducing origin load to near-zero during normal traffic.
  • Deployment Velocity: Fully automated CodePipeline + CodeBuild reduces push-to-live time to under 3 minutes, with zero manual intervention.
  • Optimal Use Case: This architecture is the definitive standard for Vite/React static builds. Server-side rendering (Next.js) remains the only valid exception requiring containerized or Lambda@Edge compute.

Core Solution

Step 1 β€” S3 Bucket

aws s3 mb s3://your-app-frontend-ACCOUNTID --region us-east-1

Settings:

  • Block all public access: ON β€” CloudFront accesses via OAC, not public URL
  • Static website hosting: OFF β€” OAC is more secure, doesn't need this
  • Versioning: OFF β€” not needed for static hosting

Step 2 β€” CloudFront Distribution

Go to CloudFront β†’ Create distribution.

  • Origin domain : your-app-frontend-ACCOUNTID.s3.us-east-1.amazonaws.com ← select from dropdown, don't type manually
  • Origin access : Origin Access Control (OAC) β†’ Create new OAC β†’ Signing behavior: Sign requests
  • Viewer protocol : Redirect HTTP to HTTPS
  • Cache policy : CachingOptimized
  • Default root object : index.html ← critical

After creating: CloudFront shows a banner with a bucket policy to copy. Copy it β€” you need it next. Your CloudFront domain: d1abc2def3.cloudfront.net β€” this is your app's URL.

Step 3 β€” S3 Bucket Policy

Paste the policy CloudFront generated into your S3 bucket: S3 β†’ your bucket β†’ Permissions β†’ Bucket policy β†’ Edit

{  
  "Version": "2012-10-17",  
  "Statement": [  
    {  
      "Sid": "AllowCloudFrontServicePrincipal",  
      "Effect": "Allow",  
      "Principal": {  
        "Service": "cloudfront.amazonaws.com"  
      },  
      "Action": "s3:GetObject",  
      "Resource": "arn:aws:s3:::your-app-frontend-ACCOUNTID/*",  
      "Condition": {  
        "StringEquals": {  
          "AWS:SourceArn": "arn:aws:cloudfront::ACCOUNTID:distribution/YOURDISTID"  
        }  
      }  
    }  
  ]  
}  

This is Origin Access Control β€” only YOUR CloudFront distribution can read your files. Nothing else.

Step 4 β€” The Fix Everyone Forgets

React Router handles client-side routing. /dashboard, /settings, /users/123 are all fake URLs β€” React intercepts them and renders the right component.
When someone refreshes on /dashboard, the browser asks CloudFront for a file named dashboard. It doesn't exist in S3. CloudFront returns 403 or 404. Your app is broken.
Fix: add custom error responses to CloudFront.
CloudFront β†’ your distribution β†’ Error pages β†’ Create custom error response

  • Error code : 403

  • Response page path: /index.html

  • HTTP response code: 200

  • Error code : 404

  • Response page path: /index.html

  • HTTP response code: 200

Why return 200 instead of 404? Because React Router is about to render the correct page. Returning 404 breaks analytics, SEO, and browser history.
Do this now. You will forget it exists and spend an hour debugging later.

Step 5 β€” IAM Role for CodeBuild

Role name : CodeBuildServiceRole-your-app-frontend
Trust : codebuild.amazonaws.com
Inline policy:

{  
  "Version": "2012-10-17",  
  "Statement": [  
    {  
      "Sid": "Logs",  
      "Effect": "Allow",  
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],  
      "Resource": "*"  
    },  
    {  
      "Sid": "S3Artifacts",  
      "Effect": "Allow",  
      "Action": ["s3:GetObject", "s3:GetObjectVersion", "s3:PutObject", "s3:GetBucketVersioning"],  
      "Resource": [  
        "arn:aws:s3:::your-artifacts-bucket",  
        "arn:aws:s3:::your-artifacts-bucket/*"  
      ]  
    },  
    {  
      "Sid": "S3Frontend",  
      "Effect": "Allow",  
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],  
      "Resource": [  
        "arn:aws:s3:::your-app-frontend-ACCOUNTID",  
        "arn:aws:s3:::your-app-frontend-ACCOUNTID/*"  
      ]  
    },  
    {  
      "Sid": "CloudFront",  
      "Effect": "Allow",  
      "Action": ["cloudfront:CreateInvalidation"],  
      "Resource": "*"  
    }  
  ]  
}  

Step 6 β€” buildspec.yml

Add to repo root:

version: 0.2

env:  
  variables:  
    S3_BUCKET: "your-app-frontend-ACCOUNTID"  
    CLOUDFRONT_DISTRIBUTION_ID: "YOURDISTID"

phases:  
  install:  
    runtime-versions:  
      nodejs: 22  
    commands:  
      - echo "Node $(node --version)"

  pre_build:  
    commands:  
      # npm ci = exact versions from package-lock.json  
      # Always use this in CI. npm install can silently update deps.  
      - npm ci

  build:  
    commands:  
      - npm run build  
      - ls -la dist/

  post_build:  
    commands:  
      # --delete removes old files: stale JS chunks, renamed assets, deleted pages  
      - aws s3 sync dist/ s3://$S3_BUCKET --delete

      # Cache invalidation β€” without this, users get old files for up to 24hrs
      # Note: single line. No backslash continuation.
      - aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths "/*"

      - echo "Deployed $(date)"

artifacts:  
  files:  
    - "**/*"  
  base-directory: dist  

The YAML Rule That Costs You Hours
Every - under commands: is a separate shell command. Backslash continuation creates a multi-line string β€” CodeBuild rejects it:

# ❌ YAML_FILE_ERROR: Expected Commands[N] to be of string type
- aws cloudfront create-invalidation \
  --distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
  --paths "/*"

βœ… One line β€” works every time

- aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths "/*"

Why npm ci Not npm install
npm install can silently update package-lock.json. In CI, you want reproducible builds β€” the same packages every time. npm ci fails if package-lock.json is out of sync with package.json, making the inconsistency visible instead of hiding it.

Why --delete on S3 Sync
Vite uses content hashing: main.abc1234.js. Every build produces new filenames. Without --delete, old files pile up in S3 β€” old chunks from previous builds that no one will ever request. More importantly, if a file gets renamed or deleted from your project, the old version stays in S3 and can serve stale content if cache expires.

Step 7 β€” GitHub Actions (PR Checks)

CodePipeline only fires on merge to main. For PR feedback:

# .github/workflows/ci.yml  
name: Frontend CI

on:  
  pull_request:  
    branches: [ main ]  
  push:  
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Build
        run: npm run build
        # TypeScript errors, missing imports, bad JSX β€” all caught here
        # PR is blocked if build fails

Pitfall Guide

  1. Using npm install over npm ci in CI: npm install mutates package-lock.json and can introduce non-deterministic builds. npm ci enforces strict lockfile sync, guaranteeing identical dependency trees across environments.
  2. Forgetting CloudFront Cache Invalidation: CloudFront caches objects by default. Without programmatic invalidation (aws cloudfront create-invalidation), users receive stale assets for up to 24 hours, causing broken UI states or missing features post-deploy.
  3. Missing SPA Routing Fallback (403/404 β†’ index.html): Client-side routers simulate URLs that don't map to physical files. Without custom error responses pointing to /index.html with a 200 status, direct navigation or page refreshes trigger hard 404s, breaking the app.
  4. YAML Line Continuation in buildspec.yml: CodeBuild's YAML parser treats backslash continuations as invalid multi-line strings. All CLI commands must be written as single-line entries under the - list syntax to avoid YAML_FILE_ERROR.
  5. Omitting --delete in aws s3 sync: Vite's content-hashed filenames change on every build. Without --delete, orphaned chunks accumulate in S3, increasing storage costs and risking stale asset delivery if CloudFront cache TTL expires.
  6. Enabling Public S3 Access: Bypassing OAC by enabling public bucket access exposes assets to direct enumeration, disables CloudFront security headers, and violates least-privilege IAM principles. Always keep "Block all public access" ON.
  7. Manually Typing Origin Domain: CloudFront requires the regional S3 REST endpoint. Manually typing it often results in malformed URLs or virtual-hosted style paths. Always select the origin from the CloudFront dropdown to ensure correct regional routing.

Deliverables

  • Infrastructure Blueprint: Architecture diagram mapping CodePipeline β†’ CodeBuild β†’ S3 Origin β†’ CloudFront Edge β†’ Viewer, including IAM trust relationships and OAC signing flow.
  • Deployment Checklist: Pre-flight verification steps (OAC created, bucket policy attached, 403/404 fallback configured, npm ci enforced, --delete flag active, invalidation permissions granted).
  • Configuration Templates: Ready-to-use buildspec.yml, S3 bucket policy JSON, IAM inline policy JSON, and GitHub Actions CI workflow YAML with environment variable placeholders for rapid project onboarding.