← Blog

Leaving WordPress: migrating my site to Astro and SQLite

DEENESRUUK

For years andrew.khanoff.com ran on WordPress: PHP-FPM, a MySQL database, a pile of plugins, and the steady background hum of "your site needs updating." It worked — but it was 475 MB of files, a 160 MB database with 301 tables, and an attack surface I never asked for (xmlrpc.php, wp-login.php, the plugin-of-the-week). For a personal CV that mostly shows text, that's a lot of moving parts to patch and worry about.

So I replaced it. Here's the why and the how.

Why move off WordPress

  • Attack surface. A static-ish CV doesn't need a login form on the public internet, a comment system, or remote-code-execution-by-plugin.
  • Footprint. PHP + MySQL + WordPress core + plugins, just to render a page that changes a few times a year.
  • Control. I wanted the content in a format I own, rendered by code I can read, with no admin panel reachable from the open web.

The replacement is deliberately small: a single Astro app (server-rendered, Node adapter) in one Docker container, with SQLite as the only source of truth. No PHP. No MySQL. The admin is passkey-only and locked to the LAN at the reverse proxy. Projects are drafted by a daily GitHub→local-LLM pipeline, and analytics are first-party with a local GeoIP database.

The rule: nothing destructive without a way back

The whole migration was done in phases, and the first phase wasn't code — it was backups. Before touching anything:

  1. tar of the full WordPress docroot (350 MB compressed).
  2. mysqldump --single-transaction of the WordPress database (18 MB).
  3. Checksums, and a test extraction to prove the archives were real.

Only then did I build the new site next to WordPress, on a temporary port, without touching the live vhost. WordPress kept serving the domain the entire time the new app was being built and filled with content.

Cutover without downtime

The switch itself was a reverse-proxy change, not a data migration. nginx went from serving /var/www/andrew.khanoff.com via PHP-FPM to proxying the new container on 127.0.0.1:8090:

location / {
    proxy_pass http://127.0.0.1:8090;
}

The old vhost was backed up first, nginx -t gated the reload, and if anything had looked wrong the rollback was a single cp of the old config plus a reload. WordPress files and database were left completely intact — the domain just stopped pointing at them. Rollback time: seconds.

I ran the new site live for a while, verified everything (public pages, passkey login, the project pipeline, PDF export, analytics), and only then dropped the WordPress database — after taking one more fresh dump immediately before the DROP. The 475 MB of WordPress files were removed last, once the backups were re-verified.

What's better now

  • Security: no public login, no PHP, no plugins. The admin is behind WebAuthn passkeys and an allow 10.0.20.0/24 rule at nginx. HSTS is on.
  • Performance: mobile Lighthouse is 92 / 96 / 96 / 100 (Performance / Accessibility / Best-Practices / SEO), LCP ~1.4 s. The server response is ~30 ms because there's no database round-trip dance — just SQLite and Astro.
  • Operations: the entire site is one container and one SQLite file. Backups are trivial. The interesting automation (GitHub + a local LLM) lives in an offline batch path — if it breaks, the site doesn't notice.
  • Ownership: content is in SQLite and Markdown, in a git repo, rendered by ~2k lines of code I can actually read.

WordPress is a fine tool. It just wasn't the right amount of machinery for a CV that I want to be fast, boring to operate, and safe to leave alone for months at a time. This post, and the site you're reading it on, are the result.

Transparency: most posts here are drafted with AI assistance, and the non-English versions are machine-translated by a local LLM and then reviewed. Spotted an error? Please let me know.