Skip to content
Synthyze
Go back

The Blog That Wouldn't Stay Built

A road stretching into the distance, representing the migration journey

Photo by Bernd Dittrich / Unsplash

This blog has lived three completely different lives. It started as a Jekyll site on GitHub Pages, became a Ghost blog on a server at home, and now lives as an Astro site deployed to Cloudflare Pages. Each generation had its own stack, its own pain points, and its own way of breaking when I least expected it. The repo is still called synthyze-com because somewhere along the way I stopped believing names actually mattered. What started as a simple side project turned into a running experiment in how many ways you can migrate the same 30 posts without losing your mind.

🏠 Generation 1: Jekyll on GitHub Pages

The beginning was simple. A GitHub Pages site with the Chirpy theme, hosted at devblog.periodicc.com. I was using my real name back then, writing about beginner programming challenges — LeetCode solutions, HackerRank problem breakdowns, PicoCTF writeups. The kind of content you write when you’re still figuring out what a blog is supposed to be.

# Old _config.yml
title: DevBlog
tagline: Press Any Key to Continue…
url: 'https://devblog.periodicc.com/'
author: [redacted]

Jekyll was fine. It worked. Posts were markdown files in a _posts directory that I’d edit in vim, commit with a message like “add two-sum solution”, push to GitHub, and the site appeared a minute later. No servers to manage, no databases to back up, no images to worry about. Looking back, it was the most stable version of this blog, and I didn’t appreciate it at the time.

The git log from those days tells the whole story — small commits with messages like “fix formatting in binary-search post” and “add picoctf writeup”. Beginner content written by someone who was still learning how to explain things. The posts aren’t remarkable, but they mark a period where writing was purely about learning out loud. There wasn’t any infrastructure anxiety yet. No deployment scripts to maintain, no SSL certificates to renew, no disk space to monitor. Just a git push and done.

I remember the exact moment I decided to move away from Jekyll. I was drafting a post about binary search in the browser-based prose editor that GitHub Pages used to offer, and it crashed, losing about twenty minutes of work. The rational response would have been to install a local markdown editor. The actual response was to start researching CMS platforms, which is how I ended up down the Ghost rabbit hole.

But the itch for something prettier got me eventually. The Chirpy theme was clean but felt generic. I wanted a proper editor, rich text formatting, the ability to schedule posts and manage media from a browser tab. I wanted Ghost.

🖥️ Generation 2: Ghost on a Local Server

Server rack with glowing LEDs, representing the self-hosted Ghost era

Photo by Tyler / Unsplash

This is where things got complicated. Instead of paying for a VPS, I decided to host Ghost on a machine at home and expose it through Cloudflare Tunnel. No monthly bill, just electricity and an internet connection. In theory it was clever — zero ongoing costs, full physical control over the hardware. In practice it meant my blog went down every time the ISP hiccuped or the machine decided it needed a restart at 3 AM, and I’d be debugging from my phone while lying in bed wondering why I didn’t just pay the $5 a month for a VPS.

The setup was delicate. I ran Ghost in a Docker container with a MySQL backend, and Cloudflare Tunnel in another container that acted as the reverse proxy. The tunnel service would occasionally crash if the internet dropped at the wrong moment during a restart sequence. When it worked, it worked beautifully — fast loads, clean URLs, no exposed ports. When it broke, I had to SSH in from my phone to restart a container and hope the tunnel came back before Cloudflare’s health checks marked the domain as offline.

Ghost itself was a joy to write in. The editor was genuinely beautiful — block-based editing before most platforms had it, clean typography, smooth image handling with drag-and-drop uploads. Publishing was a single click that rebuilt the site and invalidated the cache. I could write from any browser on any device, which was a revelation after years of editing markdown files in vim. I published 31 posts and had 4 more in various states of draft. Everything felt professional and modern. The writing experience was so frictionless that I produced more content in the Ghost era than in the Jekyll era despite the Ghost era being shorter — I’d open the editor, write for twenty minutes, publish, and move on. That ease of use was the whole reason I migrated in the first place.

The problem was the machine it ran on. When I decommissioned that server, I copied the database export but never thought about the images. They sat in /var/www/ghost/content/images/ on a machine that no longer exists and probably never will again.

# What I should have done
scp -r local-server:/var/www/ghost/content/images/ ./backup/

# What I actually did
# ... nothing. Server went dark. Images gone.

[!warning] 13 images gone. Not backed up. Not recoverable. Screenshots from CTF competition dashboards, architecture diagrams for tutorials, a featured image I was genuinely proud of. All sitting on a disk that’s probably been reformatted twice by now. The database was easy to export because Ghost has a built-in JSON export in the admin panel. The images were just files in a directory named images that I walked past a hundred times and never once thought to back up.

When I exported the Ghost content as JSON, I got everything I expected — posts, tags, metadata, post-tag relationships — but the images were just URLs pointing at a server that no longer answered. The export itself is a compact snapshot of the entire site structure, but it only contains references, not assets.

{
  "db": [{
    "meta": { "exported_on": 1748888888, "version": "5.130.6" },
    "data": { "posts": [], "posts_tags": [], "tags": [] }
  }]
}

[!tip] If you’re running Ghost anywhere — a VPS, Docker, even a Raspberry Pi — set up a cron job that regularly rsyncs /content/images/ to external storage before you need it. The database export is easy to remember. The images are the trap that everyone walks into at least once.

Cloudflare Tunnel was a neat trick — it kept the blog accessible without opening firewall ports or maintaining a static IP, and it handled SSL termination automatically — but it didn’t solve the fundamental problem of hosting a blog on a machine in your house. If the machine dies, the blog dies with it, and no amount of clever tunneling will bring those files back. I learned that lesson the hard way, and I learned it permanently.

☁️ Generation 3: Astro on Cloudflare Pages

After the Ghost machine went dark, the blog sat offline for probably six months. I’d look at the domain occasionally and think about bringing it back, but every option felt wrong. WordPress? Too heavy, too much surface area to maintain. Another Ghost instance? Too risky — I didn’t trust myself to manage the backups this time either. A static site on Netlify? Closer, but something didn’t click.

Then I found Astro. The idea was straightforward: static site generator, markdown-based content, deploy to Cloudflare Pages for free. No server, no database, no tunnel, no SSH. Just a build step and a git push. The AstroPaper theme was clean and fast, and the content model — markdown files with YAML frontmatter, exactly like Jekyll — felt like coming home.

The migration was where OpenCode with DeepSeek came in. I asked it to take the Ghost export and convert every post to Astro markdown format. It worked surprisingly well — all 28 posts migrated correctly, frontmatter was properly formatted with the right date fields and tags, image references were translated to relative paths from the absolute Ghost URLs. The Astro config came together without issues, with the standard integrations for MDX, sitemaps, and Tailwind CSS.

import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://synthyze.com",
  output: "static",
  integrations: [mdx(), sitemap()],
});

The build passed on the first try. The development server rendered all 28 posts in the browser with correct formatting, working links, and proper metadata. Everything was ready to go. And that’s when things broke.

Pain Point: Wrong Deployment Instructions

OpenCode got the content migration right. The config files were correct. The build was green. But when I asked how to deploy, it told me to use a Cloudflare Worker.

# What OpenCode told me (WRONG)
npx wrangler deploy

Workers and Pages are not the same thing, and the distinction matters enormously. Workers run serverless functions on the Cloudflare edge — think request handlers, middleware, API backends running in isolates. Pages host static sites with optional serverless function support bolted on as an enhancement. The wrangler deploy command pushes a Worker script to the Workers runtime. You need wrangler pages deploy to push a Pages site to the Pages platform. This distinction is obvious if you’ve worked with Cloudflare before, but when you’re staring at a terminal at midnight and an AI confidently told you a command would work, you type the command and assume the error message is your fault.

I spent the better part of an evening chasing Worker errors — cryptic messages about module formats and entry points that made no sense for a static site. I tried adding an adapter. I tried changing the output format. I tried configuring a Worker route. None of it worked because the entire approach was wrong. The blog didn’t need a Worker. It needed a Pages project pointed at a build output directory.

I had to figure out Pages deployment myself — writing the GitHub Actions workflow from scratch, discovering the wrangler pages deploy command through the Cloudflare documentation, and setting up the custom domain in the Cloudflare dashboard so synthyze.com pointed at the right project.

# What actually works (RIGHT)
npx wrangler pages deploy dist --project-name=synthyze-blog --branch=main

[!tip] If your Astro site is configured with output: "static", use wrangler pages deploy dist/ and skip the @astrojs/cloudflare adapter entirely. Don’t add it unless you actually need Worker functionality for server-side rendering, API routes, or middleware.

This is a key pain point with AI-assisted infrastructure that I don’t see discussed enough: the easy part (content migration, config files, boilerplate generation) gets done perfectly. The hard part (understanding how platforms actually work, knowing which deployment model to use, debugging infrastructure-level issues) is where it falls apart. OpenCode handled the text and the content like a champ — I didn’t have to manually convert a single post. It failed at the infrastructure because infrastructure requires experience and platform knowledge, not pattern matching on documentation snippets that may be outdated or incomplete.

The experience reinforced something I already suspected but now have evidence for: AI tools are excellent at transforming data and writing code within well-defined boundaries. When the task shifts to understanding deployment architectures, platform capabilities, and the operational nuances of different hosting models, they’re working from context that’s too shallow to get it right. I got a perfect content migration and a completely wrong deployment strategy from the same interaction, and the contrast was striking.

Pain Point: @astrojs/cloudflare v13

The @astrojs/cloudflare adapter that ships with recent Astro versions only supports the Workers runtime in its current form. It doesn’t integrate with Pages at all — at least not in the way you’d expect for a static deployment. If you add it to your config and set output: "static", the adapter still generates Worker boilerplate and runtime configuration that breaks the Pages deployment. The error messages don’t tell you this is happening. They point toward seemingly unrelated issues — missing dependencies, incorrect module resolution, syntax errors in files you didn’t write.

The fix was to remove the adapter from the project entirely, along with its entry in the integrations array, and simply set output: "static". No adapter. No runtime configuration. Just static HTML files generated at build time and served from the Cloudflare edge through Pages.

name = "synthyze-blog"
compatibility_date = "2026-06-01"
pages_build_output_dir = "dist"

This wrangler.toml file is literally all you need for a static Pages site. Three configuration keys. No adapter package in package.json. No special build plugins. No Worker entrypoint. Just tell the Pages CLI where the build output lives and it handles the rest — deployment, caching, SSL, custom domains, the whole thing.

Pain Point: Sharp and libvips

Every Astro developer eventually runs into this one, and if you haven’t yet, you will. The Sharp image processing library needs libvips as a native dependency, and the CI environment — GitHub Actions in my case — doesn’t have it installed by default. The build fails with a cascade of errors that look like a JavaScript dependency resolution problem but are actually about missing native binaries that npm can’t install.

SHARP_IGNORE_GLOBAL_LIBVIPS=true

That single environment variable saved the CI build. Set it in the GitHub Actions workflow environment, and Sharp stops looking for a globally installed libvips and uses the bundled N-API version that ships with the npm package instead. Without it, the build passes locally every single time — because your dev machine has the system libraries — and fails in CI every single time. The classic “works on my machine” problem, fully automated and completely reproducible.

Content Inventory

CategoryCountDetails
Active posts25Migrated from Ghost (24) + survived from Jekyll (1)
Archived posts11Beginner content, preserved as drafts
Ghost export files28Raw markdown export from Ghost CMS
Jekyll reference posts17Original Jekyll posts kept for reference
Lost images13On the local Ghost server, unreachable
HTML pages generated80Static build output

Looking at that table, the numbers tell a story I can’t escape. Twenty-five active posts from two migrations, thirteen images I’ll never see again. The ratio of content I have to content I’ve lost is good enough to call the project a success, but the losses are specific and they sting more than the numbers suggest. Every missing image was attached to a post I cared about. The inventory is honest about what survived and what didn’t.

A Real Closing

Minimalist desk with a laptop and coffee, representing three generations of writing

Photo by Nenad Kaevik / Unsplash

Infrastructure choices compound over years, and you feel the weight of old decisions every time you touch the system. The Jekyll site was simple and reliable but I wanted more control and a better editing experience. The Ghost server was powerful and beautiful to write in but fragile in ways I only understood after it was gone. The Astro site is stable and fast, running on infrastructure that costs nothing and requires no maintenance, but it took a painful migration to get here — one that cost me images I can’t replace and posts I need to manually reconstruct. Each version solved the problems of the previous one while introducing new issues I hadn’t considered, and each migration carried a tax I didn’t fully account for upfront.

I think about those 13 lost images more often than you’d expect. They were screenshots from CTF competition leaderboards, architecture diagrams for tutorials I spent hours writing and editing, a featured image I recall being genuinely good. They’re gone because I didn’t have a backup strategy that accounted for the machine being physically decommissioned, and that’s not a technical failure — it’s a planning failure, a failure of foresight. I knew the server was temporary. I even told myself I’d back up the images “sometime.” I just never acted on that knowledge when it mattered. The export button was right there in the Ghost admin panel, plain as day. I just never pressed it for the right thing.

The repo is still called synthyze-com even though the domain is now synthyze.com and the blog is on Cloudflare Pages and none of the original infrastructure — not the Jekyll config, not the Ghost server, not the Chirpy theme — remains in any meaningful form. I could rename it. I should rename it, honestly, before I forget that it’s wrong. But it’s become a kind of artifact by now — proof that this project has been through enough transformations that even its own name is a fossil of an earlier decision. The naming convention is outdated by two full generations and I no longer have the energy to care.

Maybe one day I’ll replace those images. Maybe I’ll finally rename the repository. Or maybe I’ll just keep writing — three generations of infrastructure decisions behind me, on whatever platform comes next, carrying forward the lessons I’ve learned and trying not to lose anything else along the way.


Share this post:

Previous Post
THEM?!CTF 2026 Writeup
Next Post
Leveraging Agentic AI Harnesses for CTF Challenges