Skip to content
~/chingyung

cat ./work/chingyung-dev-platform.mdx

project

chingyung.dev — personal knowledge platform

Why I rebuilt my personal site as a Git-based MDX platform on AWS — and how OIDC-federated CI/CD keeps the infrastructure tiny.

AWSTerraformPlatform EngineeringCI/CDRepository →

Overview

This website. It started life as a single-page resume — useful, but static in every sense. I wanted somewhere that could grow with me: blog posts, insights, half-formed ideas, and proper engineering case studies, all in one place. That meant I needed a genuinely versatile content system, not another page I'd hand-edit whenever I had something to say.

The constraint I set myself: the infrastructure underneath had to stay light, reusable, and aligned with security best practice. A content platform shouldn't require a fleet of servers to publish a paragraph.

The problem: I've watched CMS platforms get heavy before

I came up as a PHP developer. Ten years ago, if you wanted a content site, the answer was WordPress — and honestly, back then it earned its dominance. The open-source CMS ecosystem was genuinely complete: themes, plugins, an admin UI, a community for every problem. For getting content online, nothing came close.

But I also lived through the other side of that story. As the content grew and the layouts got more elaborate, the cost shifted. Whoever maintained the site had to learn the entire WordPress system — the template hierarchy, the plugin interactions, the database, the upgrade treadmill, the security patches. The maintenance overhead crept up quietly until it dominated. The CMS wasn't bad; it's that the ecosystem around it could make the whole thing feel bloated, slow, and overwhelming for what was, fundamentally, text and images.

So when I rebuilt my own site, I asked a different question: what's the lightest thing that still gives me a real content workflow? I don't need a database. I don't need an admin panel. I write in plain text and occasionally drop in an image or a diagram. The answer that kept coming back was Git-based MDX.

The solution: content as code, behind a contract

The whole platform comes down to one pipeline — an MDX file becomes a CDN-served page, validated and deployed automatically.

Content pipeline: an MDX file flows through Velite and Zod validation, into a typed Content Access Layer, into a Next.js static export, then deployed by OIDC-federated GitHub Actions to S3 and CloudFront.
One git push, end to end. Drawn in draw.io, exported as SVG.

Three pieces make it work, and each one is worth unpacking.

1. Type-checking content, not just code

In MDX, every post opens with YAML frontmatter — title, date, tags, and so on. The trap with plain Markdown is that this metadata is unvalidated: a typo in a tag, a missing description, a malformed date, and you don't find out until something renders wrong in production.

I close that gap with Velite, which validates every file against a Zod schema at build time. The schema is the contract. For example, tags aren't free text — they must come from a controlled vocabulary:

const tags = s
  .array(s.enum(TAG_SLUGS as [string, ...string[]]))
  .min(1)
  .max(4);

If I invent a tag that isn't in the taxonomy, the build fails — with a clear error naming the offending value. This is the same instinct as a Terraform validation block, just pointed at prose instead of infrastructure. Bad content can't reach the site, the same way bad config can't reach an apply.

The validated output is fully typed. Velite generates TypeScript types from the schema, so when a page asks for an article, TypeScript knows it has a title: string, a date, and tags — autocomplete and all. Pages never read files from disk; they go through a single typed module I call the Content Access Layer. That indirection is deliberate: if I ever swap the authoring backend (a Git CMS, an AI-assisted drafting flow), I reimplement one loader and every page keeps working untouched.

2. Static assets, served from a private bucket

There's no server. next build with output: 'export' turns the whole site — every article, tag page, and search index — into plain HTML, CSS, and JS in an /out directory. Pages aren't written one by one; a single template is multiplied over the content list at build time (the same idea as a Terraform for_each), so three posts or three hundred, the workflow is identical.

Those files land in a private S3 bucket — no public bucket policy, no website hosting endpoint. CloudFront reaches the bucket through an Origin Access Control identity, so the only way to read an object is through the CDN. On top of that I attach a response-headers policy: HSTS with preload, a strict Content-Security-Policy, and the usual hardening headers. Static assets get a one-year immutable cache; because every asset filename carries a content hash, changing a file changes its URL, so cache-busting falls out of the design for free.

3. The clever bit: the infrastructure is tiny because CI does the auth

Here's the part I find most satisfying. The running infrastructure is just two things: an S3 bucket and a CloudFront distribution. That's it. No CI runners to patch, no credentials sitting in a secrets store, no auth service.

How? All authorisation happens in OIDC-federated GitHub Actions. When a workflow runs on main, GitHub mints a short-lived OIDC token, and AWS exchanges it for temporary credentials by assuming an IAM role — if and only if the token's claims match a trust policy I pinned to this exact repository and branch:

condition {
  test     = "StringEquals"
  variable = "token.actions.githubusercontent.com:sub"
  values   = ["repo:zoe-chingyung/chingyung.dev:ref:refs/heads/main"]
}

There are no long-lived AWS access keys anywhere — not in GitHub secrets, not on my laptop. The deploy role itself can do exactly two things: sync the site bucket and invalidate the distribution. Nothing else.

Once I saw it working, the elegance clicked: the security boundary and the deployment mechanism are the same thing. I don't need heavy infrastructure to be secure, because the heavy lifting — proving who's allowed to deploy — is done by a federated trust relationship that costs nothing to run and rotates its own credentials automatically. Light and secure stopped being a trade-off.

Expansions: where this goes next

While building this I went down a rabbit hole of static content generators and Git-based CMS tools — things like GitCMS, which give you a Notion-like editing surface on top of the same Git-backed MDX. They're genuinely nice, and the architecture here is deliberately ready for one: because everything funnels through the Content Access Layer, adopting a CMS is additive, not a rewrite.

But for how I actually work, I don't need the editing UI yet — plain text and the occasional image already cover most of what I write. The one expansion I do care about is diagrams. A lot of my content is platform and architecture work, and that's hard to express in prose alone.

The diagram above is the proof of concept: drawn in draw.io, exported as SVG, and dropped into public/diagrams/, then referenced with a relative path. I went with exported SVG rather than a live draw.io embed on purpose — an embedded editor iframe would be blocked by my own Content-Security-Policy (frame-ancestors 'none', frame-options: DENY), and honestly that's the policy behaving correctly. A flat SVG is lighter, themeable, versioned in Git alongside the post, and needs zero third-party runtime. For a platform-heavy knowledge site, "draw it properly, export it static" is the sweet spot.

Outcomes

  • Publishing a post is one MDX file and a git push
  • Zero static cloud credentials anywhere in the pipeline
  • Running infrastructure: one bucket, one CDN — roughly the cost of a coffee a month
  • Architecture diagrams version-controlled as SVG, right next to the words