S3 + CloudFront over Amplify / Vercel
Amplify is faster to ship, but I wanted a practical exercise in cross-account Terraform, OAC, and Lambda@Edge — the same primitives I'd use at work.
Meta · Architecture
A static portfolio served from AWS S3 behind CloudFront, with pretty URLs and security headers injected at the edge by Lambda@Edge. Every piece — buckets, distributions, certificates, DNS — is provisioned with Terraform, spread across two separate AWS accounts for blast-radius control.
Request flow is boring in the best way — browser hits the edge, edge decides what bucket key to read, bucket serves bytes. The two AWS accounts keep the zone-apex DNS separate from the hosting surface so an error in the site account can't accidentally take down the domain.
The domain lives in its own small AWS account, completely separate from the account that runs the website. Three reasons:
S3 static website hosting can do directory index resolution but requires
the bucket to be public. I wanted the bucket private and fronted only by
CloudFront (Origin Access Control), so I solved the index-document problem
in Lambda@Edge on the viewer-request event:
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
let uri = request.uri;
// /foo/ -> /foo/index.html
// /foo/bar -> /foo/bar/index.html (no extension)
if (uri.endsWith('/')) {
uri += 'index.html';
} else if (!uri.includes('.')) {
uri += '/index.html';
}
request.uri = uri;
return request;
};
A second Lambda runs on viewer-response and stamps on security
headers (HSTS, X-Content-Type-Options, Referrer-Policy, a minimal CSP) so
the S3 objects themselves stay header-free.
Two root modules, one per account. State is stored in S3 + DynamoDB locking in the respective account — the accounts never share a state file.
infra/
├── site/ # account A
│ ├── main.tf # S3 + OAC + CloudFront + Lambda@Edge
│ ├── acm.tf # cert in us-east-1, DNS-validated via account B
│ ├── providers.tf
│ ├── variables.tf
│ └── outputs.tf
└── dns/ # account B
├── main.tf # hosted zone + alias record to CloudFront
├── providers.tf
└── outputs.tf
ACM validation is the one cross-account dance: the site account requests
the certificate, the DNS account writes the validation CNAME, the site
account waits for ISSUED. Two providers, one apply.
main in
ofdengiz/omerdengiz-com.
aws s3 sync with --delete into
the private site bucket.
/* so changes
hit all edge locations within a minute or two.
For a personal portfolio with low traffic, this entire stack runs at practically AWS free-tier pocket change:
Amplify is faster to ship, but I wanted a practical exercise in cross-account Terraform, OAC, and Lambda@Edge — the same primitives I'd use at work.
The bucket has no public access. CloudFront signs requests with SigV4 via Origin Access Control — the 2022 replacement for Origin Access Identity.
Plain HTML, CSS, and a little vanilla JS. No framework means no
dependency drift, no Node version pinning, and a deploy that's just
s3 sync. The canvas animation is all hand-written.
CloudFront Functions would be cheaper and faster, but Lambda@Edge lets me reuse the same handler for response-side header injection and keeps the code in plain Node.js I can lint locally.