We replaced the blog

metainfra

We had a Ghost blog. We never used it.

For about a year blog.upwatch.dev ran a full Ghost CMS in a sidecar container — its own SQLite store, its own admin UI, its own update cadence — and we wrote exactly zero posts. Every make prod-up shipped it. Every memory tuning conversation included it. Every “what’s running on this box” audit had to account for it.

This week we ripped it out and replaced it with a static Markdown site built into the same Vite SSG pipeline that powers the rest of the marketing surface. The new blog is what you are reading right now.

Why static beats a CMS for our case

The decision came down to honest accounting:

  • We write posts in our IDE, not in a browser. A WYSIWYG editor has zero value when the author already lives in code.
  • We commit on a schedule, not on a calendar. We do not need scheduled publishing — git push is publishing.
  • Operational cost was non-zero. ~256MB of resident memory, a SQLite volume to back up, a JS runtime to keep current, a CSP that had to allow unsafe-inline because Ghost ships inline scripts in its admin UI.
  • Feature surface vs use. Members, paid newsletters, embedded analytics, multi-author workflows, themes-as-code — we used none of it.

The honest version: keeping a CMS we did not use was a sunk-cost decision dressed up as flexibility.

What the new setup looks like

  • blog/posts/*.md — one file per post, YAML frontmatter for title, date, description, tags.
  • unplugin-vue-markdown turns each .md into a Vue component at build time.
  • vite-ssg prerenders the whole site to static HTML.
  • nginx serves the prerendered output. No application runtime on the blog host.
  • Same Tailwind theme, same fonts, same nav as upwatch.dev and tools.upwatch.dev.

The compose service that replaces Ghost has a 64MB memory ceiling and no persistent volume. Adding a post is a git commit and a make prod-up.

Notes if you are tempted to do the same

A few things that bit us on the way:

  1. Set canonical URLs. Static blogs are easy to mirror, and Google will index whichever copy it sees first. We added <link rel="canonical"> per route via the head pipeline.
  2. Don’t proxy across hosts inside the same nginx if you can avoid it. Our blog.upwatch.dev server block now proxies to a sibling container; we briefly considered just mounting the static dist/ directly, but keeping the same Docker boundary as tools/web made the deploy story uniform.
  3. Migrate old URLs intentionally. If your CMS already had indexed posts, set up 301s before tearing it down. In our case there was nothing to migrate — the blog had no posts.
  4. Keep the tooling close to what you already run. We already had Vite SSG for web/ and tools/web/. Adopting it for the blog meant no new build system, no new dev workflow, no new mental model.

That is the entire story. If you have a Ghost install you have not posted to in months, consider whether you are running it for yourself or out of habit.