We replaced the blog
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 pushis 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-inlinebecause 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 fortitle,date,description,tags.unplugin-vue-markdownturns each.mdinto a Vue component at build time.vite-ssgprerenders 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.devandtools.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:
- 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. - Don’t proxy across hosts inside the same nginx if you can avoid it. Our
blog.upwatch.devserver block now proxies to a sibling container; we briefly considered just mounting the staticdist/directly, but keeping the same Docker boundary astools/webmade the deploy story uniform. - 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.
- Keep the tooling close to what you already run. We already had Vite SSG for
web/andtools/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.