DEV Community

Rajaram Yadav
Rajaram Yadav

Posted on

Deploy React (Vite) to AWS the Right Way — S3 + CloudFront + CodePipeline

Stop paying for nginx containers to serve static files. Here's the complete S3 + CloudFront setup with automated CI/CD — costs under $2/month.


Your React app is a collection of static files after npm run build. HTML. JavaScript. CSS. Images.
Serving static files from a Docker container on ECS is paying a chef to microwave leftovers. S3 + CloudFront costs less than a coffee per month, serves files from 400+ global edge locations, and scales to millions of requests automatically.
Here's the complete setup — S3 bucket, CloudFront distribution, CodePipeline CI/CD — so every push to main automatically builds and deploys your Vite app in under 3 minutes.

Why Not a Container?
ECS + nginx container:
EC2 instance : ~$15/month
ALB (for HTTPS) : ~$16/month
Total : ~$31/month
Regions served : 1

S3 + CloudFront:
S3 storage : ~$0.12/month
CloudFront : ~$0.88/month
Total : ~$1/month
Regions served : 400+ edge locations worldwide
The only case for containerising React: you're using Next.js with server-side rendering. Vite + React? Static files. S3 + CloudFront.

The Complete Pipeline
Push to main

CodeStar webhook → CodePipeline

Stage 1: SOURCE
Pulls repo → zips → S3 artifact bucket

Stage 2: BUILD (CodeBuild)
npm ci
npm run build → /dist
aws s3 sync dist/ → frontend S3 bucket
CloudFront cache invalidation

Users hit CloudFront URL
Served from nearest of 400+ edge locations
Under 3 minutes from push to live

Step 1 — S3 Bucket
bashaws 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
json{
"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:
json{
"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:
yamlversion: 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)"
Enter fullscreen mode Exit fullscreen mode

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# ❌ 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:
yaml# .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 this fails

  - name: Upload dist
    if: success()
    uses: actions/upload-artifact@v4
    with:
      name: dist
      path: dist/
      retention-days: 1
Enter fullscreen mode Exit fullscreen mode

Every PR gets:

Lint check
Full production build

Broken TypeScript, bad imports, missing dependencies — caught before merge. main always has a working build.

Step 8 — CodePipeline
Create pipeline → "Build custom pipeline" (not Starter Template):
Pipeline settings:
Name : your-app-frontend-pipeline
Type : V2
Execution mode : SUPERSEDED
Service role : your CodePipeline service role
Artifact store : Custom → your artifacts S3 bucket

Source stage:
Provider : GitHub (via GitHub App)
Connection : your-github-connection
Repository : your-org/your-repo
Branch : main
Output : SourceArtifact

Build stage:
Provider : AWS CodeBuild
Input : SourceArtifact
Project : your-app-frontend-build
Output : BuildArtifact

Deploy stage:
Provider : Amazon S3
Input : BuildArtifact
Bucket : your-app-frontend-ACCOUNTID
Extract file : ✅ YES ← without this, the zip stays zipped in S3
The S3 Permission Error You Will Hit
If you reuse a CodePipeline service role from another pipeline, it won't have access to the frontend S3 bucket:
not authorized to perform: s3:PutObject on resource:
arn:aws:s3:::your-app-frontend-ACCOUNTID/...
Fix — add the frontend bucket to the role's S3 policy:
json"Resource": [
"arn:aws:s3:::your-artifacts-bucket",
"arn:aws:s3:::your-artifacts-bucket/",
"arn:aws:s3:::your-app-frontend-ACCOUNTID",
"arn:aws:s3:::your-app-frontend-ACCOUNTID/
"
]

Connecting to Your Backend API

.env.production

VITE_API_BASE_URL=https://api.yourdomain.com
javascriptconst api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
});
Set VITE_API_BASE_URL as an environment variable in your CodeBuild project — injected at build time by Vite. Never hardcode API URLs.

Cost Reference
ItemCostS3 (5GB stored)$0.12/monthCloudFront (1M requests)$0.01/monthCloudFront (10GB transfer)$0.85/monthCodePipeline$1.00/monthCodeBuild (10 builds × 2min)$0.10/monthTotal~$2.08/month
vs ECS + nginx + ALB: ~$50/month minimum.

Error Reference
ErrorCauseFixApp breaks on page refreshSPA routing fix missingAdd 403/404 → /index.html in CloudFront error pagess3:PutObject deniedFrontend bucket not in CodePipeline role S3 policyAdd both bucket ARNs to resource listYAML_FILE_ERROR: Expected Commands[N] to be string typeBackslash continuation in buildspec.ymlOne command per lineOld files served after deployNo CloudFront cache invalidationAdd create-invalidation --paths "/*" to post_buildBuild uses different deps than localnpm install used instead of npm ciAlways npm ci in CI

What's Next
Custom domain: ACM certificate + Route 53 → app.yourdomain.com → your CloudFront distribution. HTTPS is automatic.
Multi-environment:
merge to main → deploy STAGING → manual approval → deploy PRODUCTION
Two separate S3 + CloudFront pairs. Same CodePipeline, two deploy stages.
Cache headers: fine-grained control — index.html never cached, hashed JS/CSS cached for a year.

This is the frontend half of a full-stack AWS deployment. The backend — Spring Boot on ECS with CodePipeline — is the companion article.
Drop a ❤️ if this helped. Questions in the comments.

Top comments (0)