Meta · Architecture

How omerdengiz.com is built

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.

AWS S3 CloudFront Lambda@Edge ACM Route 53 Terraform GitHub Actions

01Architecture

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.

AWS account A · site hosting AWS account B · DNS Browser CloudFront ACM TLS OAC → S3 Lambda@Edge pretty URLs · headers S3 static site private bucket Route 53 omerdengiz.com ACM (us-east-1) cross-account DNS validation alias · ACM validation

02Why two AWS accounts

The domain lives in its own small AWS account, completely separate from the account that runs the website. Three reasons:

  • Blast radius. If I delete the wrong resource while iterating on the site account, the DNS zone and domain registration are unaffected.
  • Least-privilege IAM. The deploy role in the site account can't touch hosted zones. Route 53 changes need an explicit, separate role in account B.
  • Portability. If I rebuild the site on another account tomorrow, I just repoint the Route 53 alias record — the DNS account doesn't know or care how the site is hosted.

03Pretty URLs at the edge

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.

04Terraform layout

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.

05Deploy flow

  1. Commit to main in ofdengiz/omerdengiz-com.
  2. GitHub Actions assumes a scoped IAM role in account A via OIDC (no long-lived keys).
  3. aws s3 sync with --delete into the private site bucket.
  4. CloudFront invalidation of /* so changes hit all edge locations within a minute or two.
  5. Done. Total pipeline runtime is under 90 seconds for a typical content change.

06What it costs

For a personal portfolio with low traffic, this entire stack runs at practically AWS free-tier pocket change:

ServiceMonthly (typical)Notes
Route 53 hosted zone$0.50Fixed per zone
S3 storage< $0.01A few MB of static assets
CloudFront data out< $0.10Well under 1 TB tier
Lambda@Edge invocations< $0.01Free tier covers personal traffic
ACM certificate$0.00Public certs are free
Total~$0.60 / monthDomain registration is billed separately

07Decision log

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.

OAC, not the old OAI or public bucket policy

The bucket has no public access. CloudFront signs requests with SigV4 via Origin Access Control — the 2022 replacement for Origin Access Identity.

No build step

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.

Lambda@Edge, not CloudFront Functions

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.