Skip to main content
Omiodjo Donadje

Blog

How I built this website

8/17/2026

Architecture

This site is static: Astro build output on S3, served through CloudFront. Everything else exists to support two things a static site doesn’t normally need: a real contact channel, and enough visibility to know what’s actually happening in production.

Serving the page, and watching how it performs

Diagram: a site visitor's browser reaches CloudFront, which serves the page from a private S3 bucket; separately the browser gets temporary credentials from Cognito to send performance data to CloudWatch RUM. What happens when someone visits the site: CloudFront serves the page from a private S3 bucket, and the browser separately reports performance data to CloudWatch RUM using temporary, narrowly-scoped credentials.

The S3 bucket holding the built site is never public. CloudFront reaches it through Origin Access Control, so the only way to the content is through the CDN, which is also where CSP and the rest of the security headers actually get enforced.

RUM is the other half of this picture: real user monitoring, actual page-load and error data from actual visitors, not synthetic checks. The browser calls Cognito directly for temporary credentials before it can report anything, and those credentials are scoped to exactly one action (PutRumEvents) on exactly one resource. None of that exchange goes through CloudFront or touches the site’s own infrastructure.

The contact-email pipeline

Diagram: the contact-email pipeline. SES receives inbound mail, stores it in S3, which triggers a Lambda function that relays the email via SES to the owner's inbox. The contact-email pipeline: SES receives inbound mail, stores it in S3, which triggers a Lambda that relays it on to my inbox via SES again.

There’s no contact form. The “Get in touch” link is a plain mailto:, so the actual delivery mechanism is real email, routed through SES: an MX record points the domain at SES, SES writes anything it receives into an S3 bucket, and an S3 event triggers a Lambda that reads that raw email and relays it onward with SendRawEmail. SES — receive and SES — send in the diagram are the same AWS service doing two different jobs in one flow.

What runs without a visitor involved

Diagram: GitHub Actions deploys new site content to S3 and invalidates CloudFront on every push; separately a CloudWatch alarm notifies the owner by email via SNS if AWS costs exceed twenty dollars. What runs automatically, with no visitor involved: GitHub Actions deploys new content on every push, and a billing alarm emails me if costs run away.

Two things happen without anyone visiting the site. A GitHub Actions workflow, authenticated via OIDC rather than stored AWS keys, builds and syncs the site to S3 on every push to master, then invalidates the CloudFront cache. Both steps are necessary, since S3 having new files doesn’t mean CloudFront’s edge caches do. And a CloudWatch alarm watches estimated AWS charges, publishing to SNS (and from there, email) if they cross $20, a cheap tripwire for a solo account.

Security decisions

Every one of these was a deliberate choice, not a default.

Origin Access Control, not a public bucket. A surprising number of static sites still make their S3 bucket world-readable and call CloudFront a CDN in front of it rather than the only door in. Here, the bucket policy only trusts CloudFront’s origin access identity. There’s no direct path to the content that skips the CDN, which means there’s no path that skips the security headers either.

A real CSP, not just the managed header set. CloudFront’s managed SECURITY_HEADERS policy covers HSTS, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy, but not CSP. Adding one meant every script had to become an external file, since browsers won’t execute inline <script> content under a CSP that doesn’t explicitly allow it, and the handful of inline style="" attributes I’d been using for section backgrounds had to move into real CSS classes. Small changes, but they’re what “no unsafe-inline” actually costs in practice: a lot of sites that claim CSP quietly carry an unsafe-inline exception because cleaning up the last few inline bits isn’t worth it. On a personal site, there’s no excuse not to finish the job.

IAM scoped to exactly what a resource needs. The Lambda that forwards contact emails has ses:SendRawEmail on the two exact identity ARNs it needs (the domain it sends as, and the address it forwards to), never a wildcard. That specificity is also how a real bug got found and fixed: the forwarder had been silently failing for months, because that grant only covered one of the two identities, and the resulting access-denied error pointed straight at what was missing.

Rewriting the forwarded email’s headers, not relaying it raw. Forwarding a contact email byte-for-byte keeps the original sender’s domain in the From: header, which fails DKIM/SPF alignment at strict-DMARC providers (Gmail included) and can get silently rejected or spam-filtered. The forwarder rewrites From: to the verified sending domain and sets Reply-To: back to the original sender before relaying, so DMARC passes regardless of who’s writing in, and replying still goes to the right place.

Log retention and a billing alarm, treated as security hygiene, not just cost cleanup. Lambda’s default log retention is “keep forever” unless told otherwise: a small, boring, entirely avoidable case of unbounded data retention. The billing alarm gets framed as a cost control, but its real job is the same as a smoke detector: catching the moment something’s gone wrong, cheaply, before it’s expensive to fix.

What I deliberately left out

Adding controls is the easy failure mode in security work. The harder discipline is knowing what doesn’t earn its place yet.

No WAF. Putting a CDN in front of a site doesn’t automatically mean a web application firewall belongs behind it too. WAF earns its cost when there’s something to filter: forms, an API, a login page, arbitrary user input. This site has none of that. The closest thing to a request-processing surface is the contact-email pipeline, and a WAF doesn’t inspect that at all, since it operates over SMTP, not HTTP. Paying for managed rule groups to protect a site that’s just serving files isn’t security, it’s a checkbox.

No customer-managed KMS key. Both buckets use S3’s default encryption. A customer-managed key buys you control over the key itself: who can use it, when it rotates, an audit trail independent of S3. That’s worth the overhead when something calls for it, a compliance requirement, an audit process, or data sensitive enough on its own to need that level of control. None of that applies here. On another project I used a customer-managed key for exactly that reason, because one of those actually existed. Here, none does, so the default is enough.

No custom MAIL FROM domain. DMARC only needs one of SPF or DKIM to align with the sending domain, and DKIM alignment is already solid here: that’s most of what makes the contact-email pipeline land reliably at all. A custom MAIL FROM domain would add SPF alignment too, which is belt-and-suspenders redundancy, not a gap. Worth doing eventually. Not worth doing now.

Summary

Deployment is fully automated: GitHub Actions builds and syncs the site to S3 on every push to master, then invalidates the CloudFront cache. The architecture is three pieces: CloudFront serving a private S3 bucket for the site itself, SES and a Lambda relay for the contact-email pipeline, and CloudWatch RUM plus a billing alarm for visibility into performance and cost. Security comes from Origin Access Control instead of a public bucket, a CSP with no unsafe-inline exceptions, IAM scoped to exact resources, and header rewriting so forwarded email passes DMARC. WAF and a customer-managed KMS key were left out for the same reason the rest was built the way it was: neither matches a real requirement here.

Back to blog ->