This website has been running on WordPress since 2007. For most of that time it lived on a self-managed VPS (first on DigitalOcean, then moved to Hetzner in 2020). The stack evolved from a bare LAMP setup, through Salt configuration management, to a full Kubernetes cluster with a GitOps pipeline.

It worked well, until I had to ask myself whether all that complexity was actually earning its keep.

The case for leaving WordPress Link to heading

A personal blog is mostly read-only. Visitors read a post, occasionally follow a link. There is no user authentication, no dynamic content, nothing that requires a database query on every page load. Yet WordPress was doing all of that anyway: booting PHP, hitting MySQL, running through a plugin stack of 22 items on every single request.

Those plugins tell a story of their own. Wordfence for security scanning. UpdraftPlus for backups. Autoptimize to undo some of the performance damage WordPress does to itself. Akismet to fight comment spam. A broken link checker. A caching layer to make the whole thing bearable. Each plugin is a maintenance surface: updates to apply, compatibility to verify, occasional conflicts to debug.

Then there is the server itself. Running a VPS to host a personal blog in 2026 means paying for compute, keeping the OS patched, monitoring disk usage, renewing TLS certificates, and staying on top of whatever security advisory comes out this week. I have been doing this since 2007 and it was genuinely interesting for a long time; learning how the pieces fit together was part of the point. But at some point maintaining the infrastructure became more work than writing.

The final push was a planned decommission of the Hetzner VPS as part of a broader infrastructure consolidation. Rather than migrate WordPress to another server, it made more sense to ask whether WordPress was the right tool at all.

The answer for a static blog is obviously no. The content is Markdown. The output is HTML. There is no reason for a runtime.

flowchart TD Browser -->|HTTP request| nginx subgraph vps["Hetzner VPS"] nginx -->|FastCGI| php["PHP-FPM"] php --> wp["WordPress + 22 plugins"] wp -->|query| mysql["MySQL"] end

Choosing a static site generator Link to heading

The main options I considered were Hugo and Jekyll. Hugo won for two reasons: it is a single binary (no Ruby dependency chain to manage) and it is fast: builds with 100+ posts complete in under two seconds.

For the theme I tried PaperMod, Stack, and Coder. Coder won: clean typography, no sidebar clutter, dark/light mode via prefers-color-scheme.

Exporting from WordPress Link to heading

WordPress has a built-in XML exporter under Tools → Export. The export includes posts, pages, categories, tags, and metadata, but not images.

To convert the XML to Markdown I used wordpress-to-hugo-exporter, a PHP script you run directly on the server:

cd /srv/www/michelebologna.net
php hugo-export.php

This produces a hugo-export.zip with a content/ directory of Markdown files and a hugo.yaml. The conversion is not perfect (shortcodes, embedded HTML, and some formatting need manual cleanup), but it handles the bulk of the work.

Images Link to heading

Images were stored under /wp-content/uploads/ in year/month subdirectories. I flattened them into static/images/ organised by year, then updated all the Markdown references:

# Example: move uploads into static/images preserving year structure
rsync -av wp-content/uploads/ static/images/

# Update references in all posts
find content/ -name "*.md" -exec sed -i \
  's|/wp-content/uploads/|/images/|g' {} \;

The old static/wp-content/ directory (7,438 files, 175 MB), was loading into memory on every Hugo build and making the dev server unusable. Removing it entirely brought build times back to normal.

Hugo configuration Link to heading

A few things worth calling out in hugo.yaml:

outputs:
  home: ["HTML", "RSS"]
  section: ["HTML", "RSS"]
  page: ["HTML"]

This enables RSS feeds at /posts/index.xml, replacing the old WordPress /feed/.

The exported Markdown contained a fair amount of raw HTML left over from years of writing in the WordPress classic editor (iframes, <figure> tags, inline <script> blocks, stray angle brackets in text) rather than enable unsafe: true in the Goldmark renderer to let all of that through, I cleaned it up: YouTube iframes became {{< youtube >}} shortcodes, figures became {{< figure >}} shortcodes, and everything else was rewritten as proper Markdown. The result is a clean content tree with no raw HTML, and the renderer stays at its safe default.

URL preservation Link to heading

permalinks:
  posts: "/:year/:slug/"

WordPress used /%year%/%monthnum%/%postname%/ for most of its life. /:year/:slug/ matches the canonical form WordPress itself redirected to. But those old month-format URLs are still indexed in search results, linked from other blogs, and saved in bookmarks. Cloudflare Pages supports a _redirects file in static/ that handles them natively.

The bulk of the file strips the month component from old post URLs:

/2007/:month/:slug/  /2007/:slug/  301
/2008/:month/:slug/  /2008/:slug/  301
...
/2020/:month/:slug/  /2020/:slug/  301

One line per year, 2007 through 2020. Posts from 2021 onward already used the /:year/:slug/ format, so they need no redirect.

The file also covers a few other WordPress artifacts:

# Tag URLs changed from /tag/X to /tags/X/
/tag/:slug        /tags/:slug/  301

# Pages deleted during the migration
/package-maintainership/  /projects/  301

# WordPress outgoing link tracker
/outgoing/*       /             301

Deploying to Cloudflare Pages Link to heading

Rather than using Cloudflare’s GitHub integration (which requires OAuth and only works at project creation time), I used a direct upload with Wrangler for the initial deploy:

hugo --noBuildLock --cleanDestinationDir
wrangler pages deploy public \
  --project-name michelebologna-net \
  --branch main

For ongoing deploys I set up a GitHub Actions workflow that triggers on every push to main:

name: Deploy to Cloudflare Pages
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.161.1'
          extended: true      # required: Coder theme uses SCSS
      - run: hugo --minify
      - uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy public --project-name michelebologna-net --branch main

Note the extended: true flag on Hugo; the Coder theme compiles SCSS at build time and will fail silently with the standard binary.

Staging with a subdomain Link to heading

Before cutting over www, I tested the new site at new.michelebologna.net by adding a DNS record in Terraform:

resource "cloudflare_dns_record" "new" {
  zone_id = local.zone_id
  name    = "new"
  type    = "CNAME"
  content = "michelebologna-net.pages.dev"
  ttl     = 1
  proxied = true
}

This let me verify images, redirects, and the overall layout without touching the live site. Once I was satisfied, the cutover was a single Terraform change: swapping www from the Hetzner VPS to Cloudflare Pages and removing the staging record:

# before
resource "cloudflare_dns_record" "www" {
  name    = "www"
  type    = "CNAME"
  content = "server1.example.com"
  proxied = false
}

# after
resource "cloudflare_dns_record" "www" {
  name    = "www"
  type    = "CNAME"
  content = "michelebologna-net.pages.dev"
  proxied = true
}

Because Cloudflare manages the DNS and the Pages deployment is in the same account, the propagation was instant, with no TTL wait, no traffic gap. The new subdomain was removed at the same time. After nineteen years on self-managed servers, this website now runs on infrastructure I do not operate at all. The whole cutover took an afternoon.

RSS feed continuity Link to heading

The old WordPress feed lived at /feed/. Hugo generates its feed at /posts/index.xml. Rather than ask RSS readers to update their subscriptions, I added a redirect in the same _redirects file:

/feed/   https://www.michelebologna.net/posts/index.xml  301
/feed    https://www.michelebologna.net/posts/index.xml  301

Cloudflare Pages processes _redirects natively, with no server config, no nginx rewrite rules, just a file in static/.

The result Link to heading

The new stack is: a Git repository, a Hugo binary, and Cloudflare Pages. That is the entire thing.

flowchart LR subgraph repo["Git repository"] md["Markdown files"] end md -->|git push| gha["GitHub Actions\nHugo build"] gha -->|deploy| cf["Cloudflare Pages\nglobal CDN edge"] cf -->|static HTML| Browser

Static files served from Cloudflare’s edge network, with no PHP, no database, no plugin chain in the way. The old WordPress setup used Autoptimize and a caching layer to approximate this; now it is the default. The attack surface is gone too: no admin panel to brute-force, no PHP interpreter to exploit, no database to inject into. Wordfence was watching for all of those things; now none of them exist.

The cost is zero. Cloudflare Pages’ free tier covers the traffic of a personal blog. The VPS was a fixed monthly cost regardless of how much I wrote. And unlike a single VPS, Cloudflare Pages is a globally distributed CDN with an SLA I could never match self-hosting.

Writing is simpler. Posts are Markdown files in a Git repository. Create a file, write in any editor, git push. The diff is the content. The history is the history of the site.

The blog was not the only thing the VPS was running. Four small services (a day-of-year calendar, a sunrise-sunset calendar, a Trakt-to-Toggl sync job, and a Reddit RSS fetcher) were also hosted there, running as a mix of PHP/Apache processes and cron scripts directly on the VPS. Those moved to Google Cloud as part of the same consolidation: all four now run as Cloud Run services with Cloud Storage for persistence and Cloud Scheduler for timed triggers, staying within the Always Free tier. That migration is worth a post of its own.

Comments Link to heading

The WordPress database contained 542 comments spread across 141 posts, going back to 2007. Losing them was not an option: some of those threads have useful corrections, follow-up questions, and context that is part of the post.

The approach that fit best with a static site: bake the comments into the build. No JavaScript widget, no third-party service, no server-side rendering, just data files that Hugo reads at build time and turns into HTML.

The comments were exported from the WordPress MySQL database, converted into one YAML file per post under data/comments/, and then re-sorted into threaded order by a Python script that reconstructs the reply tree from the depth and parent_name fields. WordPress stores comments chronologically, so without this step replies would appear at their posting date rather than nested under their parent.

# data/comments/2012_fizzbuzz-una-sfida-per-programmatori.yaml
url: "https://www.michelebologna.net/2012/fizzbuzz-una-sfida-per-programmatori/"
comments:
  - id: "42"
    date: "2012-04-15"
    name: "Andrea"
    text: "Bella sfida. La mia soluzione in Python..."

A Hugo partial reads the corresponding data file at build time by mapping the page’s URL path to a key:

{{- $key := replace (strings.Trim .RelPermalink "/") "/" "_" -}}
{{- $postComments := index site.Data.comments $key -}}
{{- if $postComments -}}
  {{- range $postComments.comments -}}
    <div class="static-comment">
      <strong>{{ .name }}</strong> · <time>{{ .date }}</time>
      <div>{{ .text | markdownify }}</div>
    </div>
  {{- end -}}
{{- end -}}

Posts with comments get the full thread rendered as plain HTML, no JavaScript, no external requests, no comment widget to maintain. Posts without comments get just the footer. The template is twelve lines and has nothing to break.

New comments are closed. Ongoing discussion happens on Mastodon.

Wrapping up Link to heading

I do run my own Kubernetes clusters for personal infrastructure, and that is deliberate: those workloads benefit from being self-hosted and are worth the operational cost. A blog is not. Removing the VPS means one fewer machine to patch, one fewer thing to monitor, and one fewer fixed monthly cost. The right tool for the job is sometimes no server at all.

The tradeoff is that dynamic features require more thought. Comments, search, contact forms (things WordPress handles with a plugin) now need external services or purpose-built solutions. For a personal blog that is an acceptable trade. The content is what matters, and the content is now just files in a repository.