4 min read
How I rebuilt my portfolio with Astro, S3 and CloudFront for less than $1/month

I’m a Cloud Architect. My personal website was running on a WordPress instance on EC2, manually updated, with no CI/CD, costing ~$15/month. Not exactly a great business card.

When I decided to get serious about freelancing, the first thing I did was fix that.

The problem with the old setup

The WordPress site worked — technically. But it was slow, hard to update, and every time I wanted to change something I had to SSH into the instance, fiddle with the admin panel, and hope nothing broke. For someone who builds cloud-native systems for a living, it felt embarrassing.

More importantly: it didn’t reflect who I am technically.

Choosing the stack

I evaluated a few options:

OptionCostControlEffort
Vercel / NetlifyFree tierLowMinimal
S3 + CloudFront~$0.50/monthFullMedium
Keep WordPress~$15/monthFullHigh

I chose S3 + CloudFront. Not because Vercel is bad — it’s excellent — but because managing my own AWS infrastructure is literally what I do for clients. It made sense to use my own stack, and frankly it’s a better demo of my skills than “I deployed to Vercel.”

For the static site generator I picked Astro Nano — a minimal template that scores 100/100 on Lighthouse out of the box, with no JavaScript bloat. Perfect for a portfolio that needs to load fast and look sharp.

The architecture

GitLab → Buildkite (EC2 agent) → S3 (eu-south-1) → CloudFront → sergiobelli.net

All infrastructure is defined as CloudFormation IaC: S3 bucket with OAC, CloudFront distribution with HTTP/3, ACM certificate, Route 53 DNS records. Reproducible, version-controlled, destroyable in one command.

One non-obvious detail: CloudFront’s DefaultRootObject only applies to the root path. For subdirectories like /projects/my-project, CloudFront returns a 403 because it looks for the file literally at that path rather than /projects/my-project/index.html. The fix is a CloudFront Function at the viewer-request stage that rewrites URIs:

function handler(event) {
  var request = event.request;
  var uri = request.uri;
  if (uri.endsWith("/")) {
    request.uri += "index.html";
  } else if (!uri.includes(".")) {
    request.uri += "/index.html";
  }
  return request;
}

Two lines of logic, saved hours of head-scratching.

Automated CV generation

Here’s the part I’m most happy about. My CV lives as a Markdown file in the repository. Every push triggers a Buildkite pipeline that:

  1. Builds the Astro site
  2. Generates PDF versions of the CV (IT and EN) using md-to-pdf + Puppeteer
  3. Syncs everything to S3
  4. Invalidates CloudFront cache

One push — site updated, CV PDFs regenerated and published automatically.

Getting Puppeteer to run on a t3.micro Amazon Linux instance took some work. The instance has 1GB RAM and no desktop libraries installed. The fix: a 2GB swapfile plus a handful of missing system dependencies (mesa-libgbm was the last one, found with ldd | grep "not found").

The result

  • Cost: ~$0.50/month (S3 + CloudFront + Route 53)
  • Lighthouse: 100/100 Performance, Accessibility, Best Practices, SEO
  • Deploy time: ~90 seconds from push to live
  • Maintenance: zero — it’s a static site

Would I recommend this setup? If you’re comfortable with AWS, absolutely. The control is total, the cost is negligible, and it’s a good story to tell.

If you’re not an AWS person, Vercel or Netlify will get you there in ten minutes with zero configuration. No shame in that.

The source is on GitLab and the infrastructure template is included in the repo.