← Back to profile

You're Looking at It

The AWS setup behind this site — S3, CloudFront, Lambda, all written as Terraform. How it works, what it costs, and one mistake worth reading about.

Live · < $2/month


This site runs on AWS, and the site itself is the proof: the page you're reading comes from a locked-down S3 bucket through CloudFront, the contact form runs on Lambda, and everything was set up with Terraform code instead of clicking around the console. Below is how it fits together, what it costs me a month, and one mistake that taught me a lot.

< $2/mo · 0 stored credentials · 100% Terraform

Context and goals

I wanted my portfolio to do more than list cloud skills, so the site is the demo. What I was aiming for:

Things I deliberately skipped: running in more than one region (a portfolio doesn't need disaster recovery), server-side rendering (it's a static site), and user accounts (the only thing a visitor can send is the contact form).

Requirements and constraints

What Target Why
Budget Under $5/month It's a personal site. Keeping the bill boring is part of the point
Speed Cached at the CDN edge, worldwide A recruiter might open this from anywhere
Security No stored keys, bucket stays private Leaked keys are how most cloud accounts get burned
Recovery Redeploy in minutes, lose nothing The repo is the source of truth. S3 just holds the latest build
Upkeep Close to zero It's one person. Alarms should find problems, not me

Tech stack

Layer What I used
Infrastructure as code Terraform
Hosting and delivery S3, CloudFront, Route 53, ACM
Contact form API Gateway (HTTP API), Lambda (Python 3.12 on ARM), SNS
Monitoring CloudWatch logs, custom metrics, alarms
CI/CD GitLab CI, authenticated to AWS with OIDC
The site itself React, TypeScript, Vite, Tailwind, GSAP

Architecture

Architecture diagram: pending completion.

From the visitor's side. Route 53 answers DNS for the domain and points at CloudFront. CloudFront handles HTTPS, serves the site from edge caches, and hands 404s back to index.html so React's router can take over the URL.

The origin. The S3 bucket is completely private. There's no way to reach a file except through the CDN. S3 checks that every request actually came from my CloudFront distribution, and refuses anything that isn't HTTPS.

The contact form. The form posts to a small API Gateway endpoint, which runs a Python Lambda. If the message passes validation it goes to SNS, which emails me. CloudWatch keeps the logs, and three alarms watch for errors, failed sends, and someone flooding the form.

Behind the scenes. Terraform describes every resource. The state file lives in its own S3 bucket with a DynamoDB lock, so a deploy from CI and one from my laptop can't run over each other.

Design decisions

HTTP API over the classic REST API

The contact endpoint is one POST route. API Gateway comes in two flavors, and the classic REST version costs about 3.5x more per request. The extras that money buys (usage plans, API keys, request mapping) are things I wasn't going to use. So I went with the newer HTTP API. If the form ever gets hammered, throttling and a CloudWatch alarm cover it.

No stored keys in the pipeline

The deploy pipeline doesn't keep an AWS key. GitLab proves who it is to AWS and gets a temporary pass that expires in minutes (OIDC — the same trust setup larger orgs use). If someone got into my pipeline settings, there would be nothing to steal. The usual alternative is pasting an IAM key into CI variables, and the problem with those keys is that they never expire and nobody rotates them on a solo project.

The bucket only talks to CloudFront

S3 can host a website by itself, but only over plain HTTP, and only if the bucket is public. I kept the bucket private instead and gave exactly one reader permission: my CloudFront distribution. Nobody can hot-link files, search engines can't index the raw bucket, and there's no second unmanaged URL for the same content floating around.

Versioning off, on purpose

S3 can keep old versions of every file, and I turned it off. Every deploy is rebuilt from git anyway, so paying S3 to store old builds is paying twice for the same history. Rolling back means redeploying an older commit. The only cleanup rule on the bucket is one that throws away half-finished uploads after a week.

Security

Cost

These are estimates from my bill, rounded up. The domain itself is a yearly registrar fee and isn't counted here.

Service What it does $/mo
Route 53 DNS $0.50
S3 Holds the build (under 1 GB) ~$0.05
CloudFront CDN and HTTPS ~$0.10
API Gateway + Lambda + SNS Contact form ~$0.00
ACM, IAM, OIDC Certificates and auth $0.00
Total < $1

What happens at scale

Today 10k visitors/mo 1M visitors/mo
S3 ~$0.05 ~$0.05 ~$0.10
CloudFront ~$0.10 ~$1 ~$40–80
Contact form ~$0 ~$0 Grows with submissions, not visits

Even at a million visitors, the only bill that really grows is data leaving the CDN. The bucket barely notices, because CloudFront serves almost everything from cache. A server-based setup would scale cost with every request. This one scales with bytes, and caching keeps bytes cheap.

Failure modes and recovery

The site is stateless and everything rebuilds from the repo, so for most failures the fix is re-running the pipeline. A few minutes, nothing lost.

Component What goes wrong Who feels it How it gets fixed
Deploy A broken build ships Visitors see a broken site Redeploy the last good commit
Contact form Someone floods it My inbox, slightly my bill Flood alarm emails me; throttling caps the damage meanwhile
Lambda Errors spike Form stops working, site is fine Error alarm fires; redeploy the function
Terraform state Lock left stuck after a killed run Deploys blocked, live site unaffected Release the DynamoDB lock, plan again
AWS region The origin's region goes down Cached pages keep working, new visits to uncached pages fail Accepted risk for a portfolio site

Deployment pipeline

Push to main, and the pipeline builds the site, runs terraform plan, applies it, syncs the build to S3, and tells CloudFront to drop its cache. Rollback isn't a separate procedure. It's the same pipeline pointed at an older commit, which is exactly why the bucket doesn't need versioning.