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
- Terraform
- S3
- CloudFront
- Route 53
- ACM
- API Gateway
- Lambda
- SNS
- CloudWatch
- IAM/OIDC
- Python
- GitLab CI/CD
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:
- Load fast anywhere in the world, without paying real hosting money
- No AWS keys stored anywhere. Not in CI settings, not on my laptop
- Everything rebuildable from code. If the whole account disappeared, I could redeploy from the repo
- The system tells me when something breaks. I don't check dashboards
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
- No AWS keys exist anywhere in the deploy path. Temporary credentials only, and they expire in minutes
- The bucket is private and refuses any request that didn't come from my CloudFront distribution over HTTPS
- Certificates come from ACM and renew themselves. HTTP gets redirected at the edge
- The Lambda is allowed to do exactly three things: publish to one SNS topic, write its own logs, and push CloudWatch metrics, and even that last permission is locked to one metrics namespace so it can't write metrics anywhere else in the account
- The API in front of the Lambda is throttled to 2 requests per second (burst of 5), so flooding the form caps out fast, and a CloudWatch alarm tells me it happened. I'd have liked to also cap the Lambda's own concurrency, but this account's Lambda concurrency quota is too low for AWS to let me reserve any of it, so the API throttle is doing that job alone for now
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.