← Back to blog

How I deploy erandel.com: S3, CloudFront and prerender

A look under the hood at the deployment pipeline: a Vue SPA, prerendered with Puppeteer, served from S3 behind CloudFront. One bucket, several subdomains, deployments measured in minutes.

Minimal erandel.com mark for technical notes

One of the first decisions when I started erandel.com was how I'd deploy everything. As I mentioned in why I make handmade web games, the "spot it → fix it → ship it" loop is the most valuable thing I have working solo. That only holds up if shipping a new version is trivial. This is the piece that makes it trivial.

The stack in one sentence

S3 + CloudFront + Lambda@Edge, all provisioned with AWS CDK, plus a static prerender layer with Puppeteer on top of each Vue SPA. No servers of my own, no containers, no database to serve the website.

One bucket, several apps

The public site lives in a single S3 bucket — prod.erandelcom-webapp — but it contains several different applications:

  • landing at the root: /, /blog, /games/..., etc.
  • triara under /triara/, served from triara.io.
  • picturim under /picturim/, served from picturim.erandel.com.
  • vectron under /vectron/, served from vectron.erandel.com.

The trick is that each subdomain hits CloudFront with a different behaviour: the origin is always the same bucket, but the path is rewritten so each subdomain "sees" its folder as the root. This saves me from maintaining four separate distributions and four parallel infrastructures.

Lambda@Edge for clean routing

SPAs don't have a physical file at every route. If a user lands directly on /blog/welcome-to-erandel, S3 won't find that file and would return a 404. A small Lambda@Edge (in us-east-1, as CloudFront requires) intercepts the request and rewrites it to the matching index.html. It's the equivalent of nginx's try_files, but distributed across every AWS edge.

Keeping the lambda in us-east-1 while the rest of the infra lives in eu-west-1 forced me to bootstrap the CDK twice — a small but annoying detail the first time around.

The prerender: why and how

A freshly mounted SPA only sends the crawler an empty <div id="app"></div>. For SEO, AdSense and any social media preview, that's invisible. My solution is a Node script, scripts/prerender.mjs, that runs right after vite build:

  1. Spins up a local vite preview on port 4319.
  2. Launches Puppeteer in headless mode.
  3. Walks a list of routes (including each blog post).
  4. Waits for the page to finish hydrating and for the title to be non-empty.
  5. Dumps the resulting HTML into dist/<route>/index.html.

After that, every public URL has real, indexable HTML behind it. The SPA still hydrates on top in the browser, so the client experience doesn't change — only what crawlers see on the first fetch does.

An important detail with the blog

Because every blog post has its own route, every time I add one (like this one) I also have to add it to the prerender route list. That step is manual on purpose: I'd rather have that friction than generate the list dynamically and risk prerendering drafts or half-written entries.

The deploy command

The app/deploy.sh script ties it all together. Each app has its own npm run deploy:prod that does roughly four things:

  1. Bumps the package.json version with npm version patch.
  2. Builds the app (vite build) and, for the landing, runs the prerender.
  3. Syncs the assets to S3 with aws s3 sync using --size-only for binaries.
  4. Re-syncs the .html files in a second pass, this time without aggressive caching, so HTML changes are visible immediately.

The last step is invalidating the CloudFront cache for the .html files and the root. The _assets/ are hash-versioned in the filename, so they never need invalidating.

How long does a change take to go live?

From the moment I run ./deploy.sh landing until the change is visible for everyone, it normally takes under three minutes. That's exactly the property I care about: friction to fix something is so low I don't think twice. If I spot a typo in this very post while I'm writing it, I fix it, ship it, and within minutes it's corrected for everyone.

What's not here (and why)

There's no automated CI/CD yet. I trigger the deploy manually from my machine with AWS_PROFILE=juanlu. For a solo project it works — it forces me to be conscious of every deploy and to avoid breaking things at midnight by accident. When the project grows or someone else joins, the next step will be moving this pipeline to GitHub Actions with a dedicated IAM role. Not before.

If you're curious about how the bucket, the distribution or the lambdas are wired up, the full code lives in the infra/cdn folder of the repo. It's CDK in TypeScript, no tricks.