CD-05: Frontend Deployment Pipeline
User Story
As a Frontend Developer on the MangaAssist AI Chatbot team, I want to establish an automated pipeline that builds, tests, and deploys the React chat widget to S3 and CloudFront, So that frontend changes (UI improvements, WebSocket enhancements, new chat features) reach customers within minutes — with instant cache invalidation and automatic rollback capability.
Acceptance Criteria
- Every merge to
main(frontend paths) triggers build → test → deploy → invalidate - React app is built with production optimizations (minification, tree-shaking, code splitting)
- Static assets are uploaded to S3 with content-hash filenames for cache busting
- CloudFront cache is invalidated selectively (only changed paths, not wildcard)
- Smoke tests validate chat widget loads, WebSocket connects, and first message sends
- Preview deployments are created automatically for every pull request
- Pipeline completes in < 5 minutes (commit to live)
- Rollback is instant by re-pointing CloudFront to previous S3 prefix
- Bundle size is tracked per deploy — alert if > 10% increase
- Lighthouse CI score is validated (Performance > 90, Accessibility > 95)
High-Level Design
Frontend Pipeline Overview
flowchart LR
subgraph "Source"
A[GitHub PR/Merge] --> B{Branch?}
end
subgraph "CI Phase (2 min)"
B -->|Feature Branch| C1[Lint + Type Check]
B -->|Main| C2[Lint + Type Check]
C1 --> D1[Unit Tests — Jest]
C2 --> D2[Unit Tests — Jest]
D1 --> E1[Build — Preview]
D2 --> E2[Build — Production]
end
subgraph "Preview Deploy"
E1 --> F1[Upload to S3 /preview/PR-123/]
F1 --> G1[CloudFront preview.mangaassist.com/PR-123]
G1 --> H1[Comment preview URL on PR]
end
subgraph "Production Deploy"
E2 --> F2[Upload to S3 /releases/v1.42/]
F2 --> G2[CloudFront Selective Invalidation]
G2 --> H2[Smoke Tests]
H2 -->|Pass| I2[Live at mangaassist.com]
H2 -->|Fail| J2[Rollback to Previous Release]
end
style I2 fill:#1B660F,color:#fff
style J2 fill:#DD344C,color:#fff
Deployment Architecture
graph LR
subgraph "CDN Edge"
CF[CloudFront Distribution]
CF -->|"/chat-widget/*"| S3_WIDGET["S3: Chat Widget<br/>(React SPA)"]
CF -->|"/api/*"| APIGW["API Gateway<br/>(WebSocket + REST)"]
CF -->|"/*"| S3_STATIC["S3: Static Assets<br/>(images, fonts)"]
end
subgraph "S3 Bucket Structure"
S3_WIDGET --> R1["/releases/v1.42/ (current)"]
S3_WIDGET --> R2["/releases/v1.41/ (previous)"]
S3_WIDGET --> R3["/preview/PR-123/"]
S3_WIDGET --> R4["/preview/PR-456/"]
end
style CF fill:#ff9900,color:#000
Low-Level Design
1. Build Configuration
// vite.config.ts — Production build with content hashing
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig(({ mode }) => ({
plugins: [
react(),
mode === 'production' && visualizer({
filename: 'dist/bundle-stats.html',
gzipSize: true,
}),
],
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
// Content-hash filenames for cache busting
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
manualChunks: {
vendor: ['react', 'react-dom'],
websocket: ['./src/lib/websocket.ts'],
chat: ['./src/components/ChatWidget/index.tsx'],
},
},
},
},
}));
2. GitHub Actions Pipeline
name: frontend-deploy
on:
push:
branches: [main]
paths: ['frontend/**']
pull_request:
paths: ['frontend/**']
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
build-and-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- name: Lint + Type Check
run: |
npm run lint
npm run type-check
- name: Unit Tests
run: npm test -- --coverage --ci
- name: Build
run: npm run build
env:
VITE_API_URL: ${{ github.event_name == 'push' && 'https://api.mangaassist.com' || 'https://api-staging.mangaassist.com' }}
VITE_WS_URL: ${{ github.event_name == 'push' && 'wss://ws.mangaassist.com' || 'wss://ws-staging.mangaassist.com' }}
- name: Bundle Size Check
run: |
TOTAL=$(du -sb dist | cut -f1)
echo "Bundle size: $TOTAL bytes"
if [ "$TOTAL" -gt 524288 ]; then # 512KB limit
echo "::warning::Bundle size exceeds 512KB"
fi
- uses: actions/upload-artifact@v4
with:
name: frontend-build
path: frontend/dist
preview-deploy:
if: github.event_name == 'pull_request'
needs: build-and-test
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::role/github-actions-frontend
aws-region: us-east-1
- uses: actions/download-artifact@v4
with:
name: frontend-build
path: dist
- name: Deploy to preview
run: |
aws s3 sync dist/ s3://mangaassist-frontend/preview/PR-${{ github.event.number }}/ \
--delete --cache-control "no-cache"
- name: Comment preview URL
uses: marocchino/sticky-pull-request-comment@v2
with:
message: |
### Preview Deployment
URL: https://preview.mangaassist.com/PR-${{ github.event.number }}/
Build: ${{ github.sha }}
production-deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: build-and-test
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::role/github-actions-frontend
aws-region: us-east-1
- uses: actions/download-artifact@v4
with:
name: frontend-build
path: dist
- name: Upload to S3 (versioned release)
run: |
VERSION=$(echo ${{ github.sha }} | cut -c1-8)
aws s3 sync dist/ s3://mangaassist-frontend/releases/$VERSION/ \
--cache-control "public, max-age=31536000, immutable"
# Update pointer file to current release
echo $VERSION > /tmp/current-version.txt
aws s3 cp /tmp/current-version.txt s3://mangaassist-frontend/current-version.txt \
--cache-control "no-cache, no-store"
- name: CloudFront Selective Invalidation
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DIST_ID }} \
--paths "/index.html" "/current-version.txt" "/chat-widget/*"
- name: Smoke Tests
run: |
# Wait for invalidation propagation
sleep 15
# Check chat widget loads
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://mangaassist.com/chat-widget/)
if [ "$STATUS" != "200" ]; then
echo "Smoke test failed: HTTP $STATUS"
exit 1
fi
- name: Lighthouse CI
uses: treosh/lighthouse-ci-action@v11
with:
urls: 'https://mangaassist.com/'
budgetPath: frontend/lighthouse-budget.json
3. CloudFront Cache Strategy
flowchart TD
subgraph "Cache TTLs"
A["index.html<br/>Cache-Control: no-cache<br/>(always fresh)"]
B["assets/*-[hash].js<br/>Cache-Control: immutable, max-age=1yr<br/>(forever cached)"]
C["assets/*-[hash].css<br/>Cache-Control: immutable, max-age=1yr<br/>(forever cached)"]
D["images/*<br/>Cache-Control: max-age=86400<br/>(1 day)"]
end
E[User Browser] --> F{Cache Hit?}
F -->|"index.html"| G[Always re-validate with origin]
F -->|"hashed assets"| H[Serve from edge cache — never re-validate]
F -->|"images"| I[Serve from cache, re-validate after 1 day]
4. Rollback Mechanism
# Rollback by re-pointing to previous release
import boto3
def rollback_frontend(distribution_id: str, s3_bucket: str):
s3 = boto3.client('s3')
cf = boto3.client('cloudfront')
# Read current version
current = s3.get_object(
Bucket=s3_bucket, Key='current-version.txt'
)['Body'].read().decode().strip()
# List all release versions, find the one before current
response = s3.list_objects_v2(
Bucket=s3_bucket, Prefix='releases/', Delimiter='/'
)
versions = sorted([
p['Prefix'].split('/')[1]
for p in response['CommonPrefixes']
])
current_idx = versions.index(current)
if current_idx == 0:
raise ValueError("No previous version to rollback to")
previous = versions[current_idx - 1]
# Update pointer to previous version
s3.put_object(
Bucket=s3_bucket,
Key='current-version.txt',
Body=previous.encode(),
CacheControl='no-cache, no-store',
)
# Invalidate CloudFront to pick up new pointer
cf.create_invalidation(
DistributionId=distribution_id,
InvalidationBatch={
'Paths': {'Quantity': 2, 'Items': ['/index.html', '/current-version.txt']},
'CallerReference': f"rollback-{previous}",
},
)
return previous
Critical Decisions
Decision 1: Cache Invalidation Strategy — Wildcard vs Selective vs Versioned Assets
| Strategy | How It Works | Cache Hit Ratio | Invalidation Cost | Deploy Speed |
|---|---|---|---|---|
Wildcard /* |
Invalidate everything on every deploy | 0% immediately after deploy | $0.005/path (but many edge locations) | Fast — simple |
| Selective paths | Invalidate only /index.html + changed paths |
~95% — hashed assets stay cached | Minimal | Fast — targeted |
| Versioned assets only | Hash-based filenames, never invalidate assets | ~99% — only index.html re-fetched |
$0 for assets | Fastest |
Decision: Versioned assets (content-hash filenames) + selective invalidation of index.html only
Rationale: Content-hash filenames (main-a1b2c3d4.js) are globally unique — they never need cache invalidation because the filename changes when the content changes. Only index.html (which references the hashed files) needs to be invalidated. This gives us a 99%+ cache hit ratio on edge while deploying new code in seconds. Wildcard invalidation would nuke the entire edge cache (~100+ locations globally) on every deploy, causing a thundering herd of origin requests.
Decision 2: Preview Deployments — Per-PR Preview vs Shared Staging
| Criteria | Per-PR Preview | Shared Staging |
|---|---|---|
| Isolation | Complete — each PR has its own URL | None — PRs share one environment |
| Conflict Risk | Zero — independent deployments | High — PRs overwrite each other |
| Cost | ~$0.10/PR (S3 storage only) | Free (one environment) |
| PM/Designer Review | Excellent — share PR-specific URL | Poor — must coordinate staging access |
| Cleanup | Need automation (delete on PR close) | No cleanup needed |
Decision: Per-PR preview deployments
Rationale: At ~$0.10/PR for S3 storage, the cost is negligible. Per-PR previews dramatically improve the review workflow — designers and PMs can test a specific change without waiting for "staging to be free." Preview URLs are auto-commented on the PR, making feedback loops instant. Cleanup is handled by a GitHub Action that deletes preview files when PRs are closed/merged.
Decision 3: S3 Versioning — S3 Object Versioning vs Path-Based Versioning
| Criteria | S3 Object Versioning | Path-Based Versioning (Current) |
|---|---|---|
| Rollback | Restore previous object version | Point current-version.txt to previous path |
| Storage Cost | Higher — all versions of every file stored | Lower — old releases deleted after 7 days |
| Simplicity | Built-in AWS feature | Custom logic but transparent |
| Debugging | Harder — which version is live? | Easy — current-version.txt tells you |
| Cleanup | Lifecycle policies on versions | Lifecycle policies on paths |
Decision: Path-based versioning (/releases/{sha}/)
Rationale: Path-based versioning makes deployment state explicit — the current-version.txt file is a single pointer that tells you exactly what version is live. S3 object versioning hides this behind AWS internals. For debugging a production issue at 3 AM, having a clear /releases/a1b2c3d4/ path is much more transparent than navigating S3 version IDs.
Tradeoffs
The Debate: Cache Performance vs Deployment Speed
graph TD
subgraph "Frontend Developer"
FE1["Want instant deployment visibility"]
FE2["Hate waiting for cache invalidation"]
FE3["Preview URLs for every PR"]
end
subgraph "Architect"
AR1["Cache hit ratio must be > 95%"]
AR2["Global edge caching = low latency"]
AR3["Aggressive caching saves origin costs"]
end
subgraph "Product Manager"
PM1["Hotfix must reach customers in < 5 min"]
PM2["Feature flags preferred over delayed deploys"]
PM3["A/B test new UI without deploy"]
end
FE2 ---|"Tension"| AR2
PM1 ---|"Tension"| AR1
FE3 ---|"Costs"| PM3
Resolution
| Concern | Solution | Compromise |
|---|---|---|
| FE: Instant visibility | Hash-based assets + no-cache for index.html = visible in < 15 sec |
Hashed assets may take a few seconds to propagate globally |
| Architect: High cache ratio | Only index.html is un-cached; all JS/CSS/images cached for 1 year |
index.html adds one extra origin request per user session |
| PM: Hotfix in < 5 min | Pipeline runs in < 5 min; CloudFront propagation adds ~15 sec | 5 min pipeline + 15 sec propagation = 5.25 min total |
| PM: Feature flags > deploys | Chat widget reads flags from AppConfig at runtime (see CD-06) | Adds ~20ms to initial load (flag fetch) |
Key Tradeoff: Preview Environments Cost vs Developer Velocity
Monthly cost with per-PR previews:
- S3 storage: ~20 active PRs × ~500KB × $0.023/GB = ~$0.001/month
- CloudFront: Preview subdomain already on same distribution = $0
- Total: < $1/month
Value delivered:
- Designers review directly (saves ~30 min/PR in communication)
- PMs test features before merge (catches UX issues earlier)
- No "staging is busy" bottleneck
- ROI: ~$1/month for ~20 hours/month saved in review cycles
The math overwhelmingly favors per-PR previews. The only real cost is the CI pipeline minutes (~2 min per preview build), which is covered by GitHub Actions free tier.