From WordPress to a static site: how we moved this blog to Astro

If you are reading this, you are looking at the result: this blog is no longer a WordPress site. It is a pile of static files generated by Astro from Markdown. This article explains why we did it, what we found in the old blog while moving it, and how we made sure that nothing got lost on the way.

Why we moved

The old blog ran on a WordPress installation on an EC2 instance that someone had set up by hand, in an AWS account of its own. The content lived in a MySQL 8.0 database on RDS. That database version is outdated, and AWS recently moved it to Extended Support, which is paid and only a stopgap. Altogether, this setup cost us roughly 300 € a month.

For what the blog actually does, that is a lot of machinery. We publish articles and have a handful of static pages. There are no comments, no forms and nothing personalized. Every visit made a server and a database render the same HTML again, and both of them needed patching, backups and upgrades.

So we set ourselves a simple goal: write articles in Markdown, let a pipeline compile them into a static website, and host the result on S3 behind CloudFront.

Choosing a framework

We looked at a few popular static site generators:

  • Astro (JavaScript/TypeScript)
  • Hugo (Go)
  • Eleventy (JavaScript)
  • Zola (Rust)
  • Jekyll (Ruby)

We chose Astro. It is built for content sites, and its content collections check every article’s metadata against a schema. If someone misspells an author or forgets the title, the build fails instead of publishing a broken page. Astro also ships no JavaScript to the browser unless you ask for it, optimizes images, and has official integrations for RSS and sitemaps. Hugo would have been our pick if we had wanted no Node.js in the toolchain at all.

Taking inventory of the old blog

The old WordPress still had its REST API enabled, so we could ask it what it contained: 18 articles and 7 pages, with their categories, tags and authors. Instead of converting directly from the live site, we first saved a complete snapshot into the repository: the API responses as JSON, plus the rendered HTML of every page. That gave us two things: a repeatable import that works offline, and a reference we could compare the new site against at any time.

The rendered HTML turned out to matter. Some things never show up in the API because plugins add them to the page. The author box with profile picture below each article, and the image credits (“Photo: …”) shown on images, were both only visible in the HTML. We only noticed the image credits because we compared a finished page with the original, not because anyone remembered them.

Converting the content

A script reads the snapshot, converts each article’s HTML into Markdown and downloads its images. Every article ends up in its own folder, which also determines its URL:

src/content/posts/php-barcelona/
  index.md                  → https://ottonova.tech/php-barcelona/
  php-conf-barcelona-2019.jpg

The front matter at the top of each file is validated by Astro:

---
title: "php.barcelona"
description: "I and the rest of the PHP engineers at ottonova had the pleasure of attending the PHP.Barcelona Conference…"
pubDate: "2019-12-13T16:09:43Z"
author: "alin-purcaru"
categories:
  - "culture"
  - "ottonova"
tags:
  - "barcelona"
  - "conference"
  - "php"
heroImage: "./php-conf-barcelona-2019.jpg"
---

Most of the work was in the details: code blocks keep their language, tables become Markdown tables, links between articles become relative links, and YouTube embeds stay embeds. Little quirks needed handling too. For example, WordPress produced italic text that ended in a line break (<em>special.<br></em>), which Markdown renders as literal underscores.

What we found along the way

Going through the old blog page by page revealed a few things that nobody had noticed:

  • Broken images. Twelve images in two articles, cross-posted from dev.to, were served through an image proxy that no longer works, so they were broken on the live site. The original files were still reachable, so we restored them.
  • Dead widgets. The Tech Stack page embedded StackShare widgets from a host that no longer resolves, so it showed empty space. The Events page printed the raw text [meetup_events], a shortcode from a plugin that was gone. The Tech Radar page was completely empty. One article linked to a meetup page that returned a 404.
  • An outdated privacy policy. It described Google Analytics, cookies, premium calculators and a chat. It was largely a copy of the policy of our insurance website, and none of that exists on this blog. The old site also still loaded Google tracking scripts, although we don’t evaluate analytics for this blog.

We removed what was already dead and rewrote the privacy policy (more on that below).

Making sure nothing got lost

The part we cared about most was proving that the new site contains the same content as the old one. “It looks about right” is not good enough for 18 articles, so we wrote a checker that compares the finished website with the snapshot and fails if anything differs. It verifies that:

  • every URL of the old site still exists. That includes all 116 URLs we know from its sitemap plus the date archives and pagination pages WordPress generated, so existing links keep working;
  • all internal links and images resolve, about 3,000 references across the whole site;
  • every article and page keeps its title, its number of images and its complete text. The checker compares the words of the original with the words of the new page, so a lost sentence is found;
  • image credits and author pictures match the original;
  • all listings are sorted newest first;
  • no unconverted Markdown syntax is visible.

Then we tested the checker itself. A check that cannot fail is worthless. We deleted one sentence from an article and rebuilt: the checker reported the missing words. We changed one digit of the commercial register number in the Impressum: it reported the difference. Only after that did we trust the green result.

Not everything was found by the checker. The stray underscores mentioned above were spotted in a screenshot. But once found, we turned them into a check, so they cannot come back unnoticed.

Same URLs, plus a few improvements

We kept the WordPress URL structure: /<article-slug>/, /category/techstack/go/, /tag/…/, /author/…/, /2019/12/ and /page/2/. Search engines and everyone who linked to us shouldn’t notice the move.

A few things are new:

  • Search. A magnifier icon in the header opens a search field. It uses Pagefind, which builds an index during the build, so the search runs entirely in your browser and no search term is sent anywhere.
  • Profile pictures. Authors’ pictures are fetched from Libravatar (and Gravatar) while the site is built, and then served from our own domain. Authors without a picture show their initials.
  • Image credits now appear in the bottom-right corner of the image they belong to.
  • A clean design in the ottonova colors.

Privacy by construction

A blog that only serves files can be very cautious with your data. The new site sets no cookies, has no analytics and loads nothing from third-party servers when you open a page.

The one exception is embedded YouTube videos, and we handle it with a click-to-load placeholder. Until you press “Play video”, your browser does not talk to YouTube at all. We verified this by recording the network requests of a real browser: nothing on page load, and only the video player after the click. Authors still paste a normal YouTube iframe into their Markdown; the build turns it into the placeholder.

To make sure this stays true, the checker also fails if any page loads a resource from a third-party server. The privacy policy is rewritten to describe what the site really does.

The new hosting setup

The new blog is a folder of static files. We will host it in a private S3 bucket in Frankfurt (eu-central-1), with CloudFront in front of it for the domain and TLS. CloudFront is not optional here: S3’s website hosting only speaks plain HTTP, and HTTPS on your own domain needs CloudFront. A small CloudFront function will map URLs like /php-barcelona/ to the right file, and access logging stays off.

There is no server to patch and no database to upgrade anymore. We haven’t calculated the exact costs yet, but we expect them to be only a fraction of the 300 € a month we paid before.

What we would do again

  • Snapshot the source before you convert. Saving the API responses and the rendered pages made the import repeatable and gave us something to compare against.
  • Look at the rendered pages, not just the API. Plugin data hides in the HTML.
  • Write the checker early and test it by breaking things. It caught more than we expected.
  • Distrust silent failures. Astro logs an error and still publishes an empty page when an article fails to render. We made the build fail instead.
  • Prove privacy claims in a browser. “We don’t load anything from Google” is easy to say and easy to get wrong.

We did the migration in one working session with Claude Code. It wrote the converter and the checker; we made the decisions. Both scripts live in the migration/ folder of the blog’s repository, so you can read exactly what they do.