After years of running my tech blog on WordPress, I finally pulled the trigger and migrated everything to Hugo — a blazing-fast static site generator — hosted on Cloudflare Workers. The result? A site that loads in milliseconds, costs nothing to host, and requires zero server maintenance.
Here’s exactly how I did it, what went wrong, and what I’d do differently.
Why Leave WordPress?
WordPress served me well for years. But as my post count grew to 278 articles (mostly VMware VCF guides with dozens of screenshots each), the cracks started showing:
- Performance: Even with caching plugins, page loads were sluggish. Each request hit PHP and MySQL.
- Maintenance overhead: WordPress core updates, plugin updates, PHP version bumps, SSL renewals, database backups — it never ends.
- Security surface: Every plugin is a potential vulnerability. I was running a firewall plugin just to block brute-force login attempts.
- Cost: Hosting a WordPress site with decent performance isn’t free. My VPS bill added up.
A static site eliminates all of these problems. No database, no PHP, no server — just HTML files served from a CDN.
Choosing the Stack
Hugo over Jekyll, Gatsby, Next.js
I evaluated several static site generators:
| Generator | Pros | Cons |
|---|---|---|
| Jekyll | Ruby-based, GitHub Pages native | Slow builds for large sites |
| Gatsby | React-based, rich plugin ecosystem | Heavy, complex, Node dependency hell |
| Next.js | Full-stack capable | Overkill for a blog |
| Hugo | Single binary, sub-second builds, Go templating | Steeper template learning curve |
Hugo won because of build speed. With 278 posts and 13,000+ images, Hugo builds the entire site in under 7 seconds. Jekyll would take minutes.
PaperMod Theme
I went with PaperMod — a clean, fast, SEO-optimized Hugo theme. It ships with:
- Dark/light mode toggle
- Built-in search (via Fuse.js)
- Table of contents
- Share buttons
- Reading time estimates
- Archive and taxonomy pages
I customized it with a dark-first infrastructure theme — muted teal accents, JetBrains Mono for metadata, and Inter for body text.
Cloudflare Workers over GitHub Pages, Netlify, Vercel
Cloudflare Workers (formerly Cloudflare Pages) was the obvious choice:
- Free tier handles my traffic easily
- Global CDN — content served from 300+ edge locations
- Git integration — push to GitHub, site deploys automatically
- Custom domain + SSL — handled natively
- Security headers — configured via
_headersfile
The Migration Process
Step 1: Extract the WordPress Backup
My WordPress backup was in the proprietary .wpress format (from the All-in-One WP Migration plugin). This isn’t a standard ZIP — it has a custom binary header format.
I wrote a Python script to parse the binary headers and extract the contents:
def extract_wpress(filepath, output_dir):
with open(filepath, 'rb') as f:
while True:
header = f.read(HEADER_SIZE)
if len(header) < HEADER_SIZE:
break
name = header[0:255].replace(b'\x00', b'').decode('utf-8')
size = int(header[255:269].replace(b'\x00', b'').decode('utf-8'))
# ... extract file data
This gave me two critical files:
database.sql— the full WordPress MySQL dumpuploads/— all 13,000+ media files
Step 2: Convert Posts to Markdown
The core of the migration was a Python script that:
- Parsed the SQL dump to extract posts (title, content, slug, date, categories, tags, excerpt)
- Converted HTML content to Markdown using the
markdownifylibrary - Generated Hugo-compatible front matter with proper YAML escaping
- Rewrote image URLs from
wp-content/uploads/to Hugo’s/images/path
The trickiest part was YAML front matter sanitization. WordPress post titles and descriptions contain all sorts of special characters — quotes, ampersands, colons, tabs — that break YAML parsing. I wrote a yaml_safe_string() helper:
def yaml_safe_string(value):
if not value:
return '""'
value = value.replace('\t', ' ').strip()
if any(c in value for c in ':{}[]&*?|>!%@`"\',#'):
escaped = value.replace('\\', '\\\\').replace('"', '\\"')
return f'"{escaped}"'
return f'"{value}"'
Step 3: Scaffold the Hugo Site
hugo new site cosmin-gq
cd cosmin-gq
git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod
Then I configured hugo.toml with:
- SEO metadata (description, keywords, author, Open Graph)
- JSON-LD structured data via a custom
extend_head.htmlpartial - Sitemap configuration (304 URLs generated)
- RSS feed (278 items)
- Security headers (
_headersfile for HSTS, CSP, X-Frame-Options) - Feed redirects (
/feed/→/index.xmlfor WordPress RSS subscribers)
Step 4: Deploy to Cloudflare
Cloudflare recently merged Pages into Workers, so the deployment uses Wrangler:
Build settings:
- Build command:
hugo --minify - Deploy command:
npx wrangler deploy
wrangler.jsonc:
{
"name": "cosmin-gq",
"compatibility_date": "2025-09-27",
"assets": {
"directory": "public"
}
}
The workflow is simple: git push → Cloudflare clones the repo → Hugo builds → Wrangler uploads 14,000+ static files to the edge.
Gotchas and Lessons Learned
1. The paginate Deprecation
Hugo v0.128.0+ removed the paginate config key. Cloudflare’s build environment uses a newer Hugo version, so this broke the build:
ERROR deprecated: site config key paginate was deprecated in Hugo v0.128.0
Fix: Replace paginate = 10 with:
[pagination]
pagerSize = 10
2. Cloudflare Workers Don’t Support 404 Redirects
My _redirects file had rules to block old WordPress admin paths:
/wp-admin/* /404.html 404
/wp-login.php /404.html 404
Workers only supports redirect status codes (200, 301, 302, 303, 307, 308). Hugo’s built-in 404 page handles missing routes anyway.
3. The baseURL Matters
Hugo generates all links using the baseURL from hugo.toml. If your production domain isn’t set up yet and you’re testing on the .workers.dev URL, all links will point to the wrong place. Set baseURL to match wherever the site is currently served.
4. Don’t Commit the public/ Directory
Hugo’s build output goes into public/. Since Cloudflare rebuilds this on every deploy, there’s no reason to track it in git. Add it to .gitignore:
public/
.DS_Store
resources/
node_modules/
.wrangler/
5. Ad Blockers Hide LinkedIn Share Buttons
If you add LinkedIn share buttons, be aware that most ad blockers (uBlock Origin, AdGuard) will hide them because the share URL contains linkedin.com. Nothing you can do about this from the site side.
The Results
| Metric | WordPress | Hugo + Cloudflare |
|---|---|---|
| Build time | N/A (dynamic) | ~7 seconds |
| Page load | 2-4 seconds | < 200ms |
| Hosting cost | ~$20/month | Free |
| Server maintenance | Weekly | None |
| Security updates | Constant | None needed |
| Uptime | 99.5% | 99.99% (Cloudflare SLA) |
The site now loads almost instantly from anywhere in the world, costs nothing to run, and the only “deployment” step is git push.
What I’d Do Differently
- Start with
.gitignore— I accidentally committed thepublic/directory on the first push (13,000+ files). Set up.gitignorebefore the first commit. - Use
relativeURLs— This avoids thebaseURLmismatch issue entirely during testing. - Test the Cloudflare build first — The Workers deployment flow has quirks (no 404 redirects, Wrangler auto-detection). A quick test deploy before migrating all content saves debugging time.
Final Thoughts
If you’re running a technical blog on WordPress and it’s mostly content + screenshots (no e-commerce, no user accounts, no dynamic forms), there’s very little reason to stay on WordPress. Hugo gives you better performance, better security, and zero ongoing costs.
The migration took about a day for 278 posts and 13,000 images. The hardest part was sanitizing the YAML front matter — everything else was straightforward.
The site is live at cosmin.gq. All the old content is preserved, SEO metadata is intact, and the RSS feed still works at the same path.
If you have questions about the migration process, feel free to reach out on X or LinkedIn.