Static hosting beats SSR for most products. Why I default to S3 plus CloudFront, preview deploys per PR, content-hashed assets, and rollback as a flip.
Wednesday afternoon at the live-video creator platform I led engineering at, a Cloudflare Worker version I’d reviewed and approved went live. It was a small refactor of cache key composition. Twenty minutes later, a German creator tweeted a screenshot of someone else’s profile photo appearing on his own profile preview. The thread had 200 retweets in an hour. The only thing that saved us from a much longer Twitter day was that Cloudflare Workers rollback is instant. One button. Previous version back in production in under a minute.
That incident is why I think about frontend deploys the way I think about backend deploys. Atomic. Versioned. Instantly reversible. Most teams I talk to do not. They treat the frontend as “just a static upload” and discover during their first real incident that there is no rollback story, the CDN has cached the broken version for an hour, and the only fix is to deploy forward.
OK so. Here’s where I land on this stuff after running it in production at a few different scales.
Default stack for most products: S3 plus CloudFront. Or Vercel if it’s Next.js and the team wants preview infra out of the box without building it. Netlify if the team is already there. The shared property is that you’re shipping a build artifact, not a server.
SSR platforms are sold as the modern default. They’re not. They’re a tool you reach for when your product genuinely needs request-time rendering. Per-visitor personalization on a marketing page. Server-side auth gating before first paint. SEO that requires fully-formed HTML at request time. If your app is a typical authenticated SPA, none of that applies. Render a static shell, hydrate on the client, fetch from your API, skip the entire server-rendering operational surface.
No cold starts, no origin scaling, no platform-specific bundler quirks. The artifact is a directory of files. The build runs in CI and the deploy is a dumb file upload. This blog runs on Astro deployed to GitHub Pages, twelve lines of YAML, and I’ve never thought about origin scaling. That’s the property I want.
The one piece of frontend infra that pays for itself fastest is a unique URL for every pull request. Designers click it. PMs click it. The founder clicks it. Bugs get caught before merge, not after.
Vercel and Netlify do this out of the box. If you’re on S3 plus CloudFront, you need a small workflow. Here’s roughly what I run:
name: preview-deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.PREVIEW_DEPLOY_ROLE }}
aws-region: us-east-1
- name: Sync to preview prefix
run: |
PREFIX="pr-${{ github.event.pull_request.number }}"
aws s3 sync ./dist "s3://${PREVIEW_BUCKET}/${PREFIX}/" \
--delete \
--cache-control "public, max-age=300"
- name: Comment preview URL
uses: actions/github-script@v7
with:
script: |
const url = `https://preview.example.com/pr-${context.issue.number}/`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview ready: ${url}`,
});
CloudFront sits in front of the bucket with a behavior that maps pr-* paths through. A cleanup job deletes old previews when PRs close. The whole thing is maybe an afternoon to set up properly, and after that, every PR has a clickable URL forever.
If you skip this, you’re shipping changes that nobody but the engineer who wrote them has actually seen in a browser. You will regret that.
I do not deploy in-place over existing files. Every asset gets a content hash in the filename. The HTML document references the hashed filenames. Deploy uploads the hashed assets first with a one-year immutable cache header, then flips index.html last with a no-cache header.
The reason is simple. Mid-deploy, a user requesting the page can land in a state where they got the new index.html but the new app.[hash].js hasn’t propagated yet, or vice versa. If your filenames are content-hashed, this is not a problem. The old hashed JS is still on the CDN at its old URL. The new hashed JS is at a new URL. Both work. Nothing breaks during the deploy window.
#!/usr/bin/env bash
set -euo pipefail
BUILD_DIR="./dist"
BUCKET="$1"
DIST_ID="$2"
# Hashed assets first. Immutable, one year.
aws s3 sync "$BUILD_DIR" "s3://$BUCKET" \
--exclude "index.html" \
--exclude "*.html" \
--cache-control "public, max-age=31536000, immutable"
# HTML last. No cache. This is the atomic flip.
aws s3 sync "$BUILD_DIR" "s3://$BUCKET" \
--exclude "*" \
--include "*.html" \
--cache-control "no-cache, no-store, must-revalidate" \
--content-type "text/html; charset=utf-8"
# Targeted invalidation, only the HTML.
aws cloudfront create-invalidation \
--distribution-id "$DIST_ID" \
--paths "/index.html" "/"
Hashed assets cache forever. The HTML is the only thing that needs to refresh, and it’s the last thing to flip. If the deploy fails halfway, the worst case is some new hashed assets sitting unreferenced in S3. They cost nothing and nobody can see them.
Strong opinion. If your deploy pipeline routinely calls create-invalidation against a wildcard path, your cache key composition is wrong. Invalidation is a band-aid over the fact that you’re reusing the same URL for content that has changed.
Content-hashed asset filenames remove invalidation entirely from the asset path. The only thing left to invalidate is the HTML, and that’s a single URL.
If you reach for invalidation as a regular tool, the answer is usually a URL that should have been versioned and wasn’t.
The last piece. Keep the previous N deploys live in S3 under versioned prefixes. The active deploy is whichever prefix the CloudFront origin points at. Rollback is one CLI call that flips the origin path back.
#!/usr/bin/env bash
set -euo pipefail
DIST_ID="$1"
TARGET_PREFIX="$2" # e.g. "releases/a3f9c1"
ETAG=$(aws cloudfront get-distribution-config \
--id "$DIST_ID" \
--query 'ETag' --output text)
aws cloudfront get-distribution-config \
--id "$DIST_ID" \
--query 'DistributionConfig' \
| jq --arg p "/$TARGET_PREFIX" \
'.Origins.Items[0].OriginPath = $p' \
> /tmp/dist-config.json
aws cloudfront update-distribution \
--id "$DIST_ID" \
--if-match "$ETAG" \
--distribution-config file:///tmp/dist-config.json
aws cloudfront create-invalidation \
--distribution-id "$DIST_ID" \
--paths "/index.html" "/"
This is the difference between “we shipped a bad deploy and it took us 40 minutes to git-revert, rebuild, and redeploy” and “we shipped a bad deploy and we flipped to the previous version in 90 seconds while we figured out what went wrong.”
index.html last. Never write over existing files.Thanks for reading. If you’ve got thoughts, send them my way.