I recently wrote about why I'm ditching WordPress for static sites. This is the companion piece: the technical play-by-play of how I actually migrated perezbox.com from a full WordPress installation to a flat PHP site with zero external dependencies.

No database. No frameworks. No build tools. No package managers. No node_modules. No Composer. No plugins. Just PHP, HTML, CSS, and a CDN-hosted copy of Bootstrap, with NOC.org handling the CDN and WAF.

Here's why that matters, and how I did it, in about 95 minutes.

The Case for Reducing Your Footprint

This migration wasn't about aesthetics or performance benchmarks. It was about two things: maintenance burden and supply chain security.

Every dependency I add to a project is a liability I have to manage. Every WordPress plugin is code I didn't write, maintained by someone I don't know, with update cycles I can't control. Every npm package pulls in a tree of transitive dependencies that could be compromised at any level. I touched on this in my last article: the supply chain problem is real, and the simplest way to reduce supply chain risk is to reduce the supply chain.

Here's what the two stacks look like side by side:

ConcernWordPressFlat PHP
DatabaseMySQL required, needs backups, can be injectedNone
Core updatesMonthly, can break plugins/themesNothing to update
Plugin dependencies< 10 plugins, each a supply chain riskZero plugins
Attack surfaceAdmin panel, REST API, XML-RPC, wp-cronApache serves files
AuthenticationLogin page, sessions, user rolesNone needed
Build toolsOften npm, Composer, WebpackNone
SEOPlugin (Yoast, RankMath, etc.)PHP variables in each file
SearchDatabase queries or pluginStatic JSON index
DeploymentFTP, or git + DB syncgit pull
BackupDatabase dump + file backupcp -r or git clone
Content versioningContent in DB, files in gitEverything in git
Server resourcesMySQL process (RAM + CPU), PHP + DB queries per pagePHP reads a file, no DB overhead
Disk footprintWP core (~60 MB) + DB + plugins + cache tablesJust your content files

Yes, you can put WordPress files in git. Many teams do. But your content still lives in the database. You still need database dumps, database syncing, and database backups as separate concerns. With flat PHP, the content is the file. git log shows you every change to every article. There's no split between "code" and "content" to reconcile.

All of that overhead to serve what is fundamentally a collection of articles and a few static pages. That's what I wanted to eliminate.

A Note on AI and Tooling

I used AI heavily in this migration, and one of the first things it suggested was frameworks: Astro, Next.js, Hugo. Modern static site generators with slick developer experiences. But each of those would introduce a different kind of dependency. Astro needs Node.js and npm. Next.js needs React and a build pipeline. Hugo needs Go. Each comes with its own ecosystem, its own update treadmill, and its own supply chain.

I didn't want to trade one set of dependencies for another. I wanted to go the other direction: fewer moving parts, not different moving parts. Plain PHP runs natively on the server that's already there. No build step. No compilation. No package manager. That's the whole point.

The Migration Process

Step 1: Export the Content

The first step was getting 291 articles out of WordPress. I exported them as a CSV from the database: title, content (HTML), date, category, and slug.

SELECT
  p.ID,
  p.post_title,
  p.post_name AS slug,
  p.post_content,
  p.post_excerpt,
  p.post_date,
  p.post_type,
  GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR ', ') AS categories
FROM wp_posts p
LEFT JOIN wp_term_relationships tr ON p.ID = tr.object_id
LEFT JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = 'category'
LEFT JOIN wp_terms t ON tt.term_id = t.term_id
WHERE p.post_status = 'publish'
  AND p.post_type IN ('post', 'page')
GROUP BY p.ID
ORDER BY p.post_date DESC
INTO OUTFILE '/tmp/export.csv'
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\n';

The gotcha here was that WordPress stores content with its block editor markup baked in: <!-- wp:paragraph --> wrappers everywhere, image paths pointing to /wp-content/uploads/, and all sorts of shortcode artifacts. The export was the easy part. Cleaning it up was the real work.

Step 2: Convert to Flat PHP Files

I wrote a Python script that parsed the CSV and generated one .php file per article. The script handled the block markup cleanup using regex: stripping <!-- wp:paragraph --> and <!-- /wp:paragraph --> comment pairs, removing <!-- wp:heading --> wrappers, cleaning up block-level <div> wrappers that Gutenberg adds around images and lists, and normalizing whitespace. It wasn't perfect on the first pass, but it got 95% of the way there, and the remaining edge cases were easy to spot and fix.

Each article file follows the same pattern:

<?php
$content_title = "Article Title | PerezBox";
$content_description = "Article description...";
$article_date = "March 09, 2026";
$article_category = "Technical";
$include_base = "../";
include $include_base . "includes/header.php";
?>

<!-- Article HTML content -->

<?php include $include_base . "includes/footer.php"; ?>

The PHP variables at the top of each file are how I handle SEO. $content_title and $content_description populate the meta tags, Open Graph tags, and schema.org structured data. No SEO plugin needed, no admin screen to configure. The metadata lives right next to the content it describes.

Step 3: The Include Pattern

The site uses a simple PHP includes architecture that I've used across CleanBrowsing, NOC.org, and other projects:

  • header.php: HTML head, meta tags, navbar
  • footer.php: Footer content, Bootstrap JS, search script
  • sidebar.php: Project quick links and category navigation

A single $base_url variable in the header controls all internal link paths. One variable change switches between development and production environments.

Step 4: CSS Cache-Busting Without Build Tools

In a world of Webpack, Vite, and asset pipelines, here's how I handle CSS cache-busting:

<?php $css_version = filemtime(__DIR__ . '/../css/style.css'); ?>
<link href="<?php echo $base_url; ?>/css/style.css?v=<?php echo $css_version; ?>" rel="stylesheet" />

PHP's filemtime() returns the file's last modified timestamp. Every time you edit the CSS, the query string changes, and browsers fetch the new version. No build step. No hash generation. No manifest files.

Step 5: Search Without a Database

Search is powered by a static JSON index file. A Python script crawls all 291 article files and generates search-index.json with titles, descriptions, keywords, categories, and dates. The PHP search endpoint loads this JSON, runs a weighted scoring algorithm (title matches weighted 3x, category 2x, description 1.5x), and returns results.

The frontend is a single JavaScript file with a 300ms debounce on the input field. No React. No Vue. No jQuery. Just fetch() and DOM manipulation.

Step 6: AI and LLM Readiness

Since I'm building for the modern web, the site includes:

  • ai-index.json: Structured content index for AI crawlers
  • llms.txt: A plain-text site description for large language models
  • schema.php: JSON-LD structured data on every page

These are just flat files. No plugins required. No configuration screens. You edit them directly.

The Gotchas

No migration is without its surprises. Here are the ones that bit me:

Embedded Images

WordPress stores images in /wp-content/uploads/YYYY/MM/ with auto-generated thumbnail variants (-300x200.jpg, -768x512.jpg, etc.). I copied the original images over, filtering out 2,000+ thumbnails from 2,844 total files, and rewrote the image paths in all 291 articles.

What I did not do in this pass was migrate images that were embedded inline in article content. WordPress renders those as full <img> tags pointing to the old upload paths. The path rewriting handled the references, but many of the original images referenced in older articles were screenshots, diagrams, or photos that added context to the writing. For now, those references point to the images directory but some may be missing. It's something I'll address in a future pass. The lesson: if your articles rely heavily on inline images, plan for a separate image audit as part of the migration.

Redirects for Old URL Structures

This is critical and easy to overlook. WordPress uses a permalink structure like /2024/01/article-slug/. My new site uses /articles/article-slug. Every old URL indexed by Google, linked from other sites, or bookmarked by readers would break without proper redirects.

A single .htaccess rule handles the mapping:

# Redirect old WordPress permalinks to new structure
RewriteRule ^[0-9]{4}/[0-9]{2}/(.+?)/?$ /articles/$1 [R=301,L]

This catches any URL matching the /YYYY/MM/slug/ pattern and 301 redirects it to /articles/slug. I also added rules to handle old WordPress artifacts like feed URLs, wp-admin, and wp-login.php:

# Redirect old WordPress artifacts
RewriteRule ^feed/?$ / [R=301,L]
RewriteRule ^wp-content/.*$ / [R=301,L]
RewriteRule ^wp-admin/?$ / [R=301,L]
RewriteRule ^wp-login\.php$ / [R=301,L]

Don't skip redirects. Your SEO depends on it, and your readers will thank you.

Link Archaeology

291 articles spanning 15 years contained a web of links pointing to my previous company (Sucuri), self-referencing WordPress permalink URLs, and outdated resources. I ran a comprehensive audit across every article file. The findings:

  • 68 unique Sucuri URLs across 69 files: replaced with NOC.org equivalents where applicable (WAF, monitoring, guides), or converted to plain text where no equivalent existed
  • 103 self-referencing perezbox.com URLs using the old WordPress permalink structure, all converted to internal relative links using the new path format
  • WordPress attachment URLs wrapping images in links to ?attachment_id= pages, all cleaned up

But I didn't stop at fixing broken links. I also used the migration as an opportunity to add UTM tracking parameters to every outbound link to my properties (CleanBrowsing, NOC.org, Trunc, DNS Archive) so I can track cross-site traffic in analytics. I also parsed all the content to find natural interlinking opportunities: articles about WAF now link to NOC's WAF product, DNS security articles cross-reference each other, and the OSSEC and HTTPS article series are properly linked as a sequence. This kind of content optimization is nearly impossible to do systematically in WordPress without a plugin. With flat files and a script, it's straightforward.

This is the kind of debt that accumulates silently in any long-running site. A migration is the perfect time to clean it up.

What I Eliminated

Let's be explicit about what's gone:

ComponentStatusWhy It Matters
MySQLRemovedNo connections, no queries, no injection risk, no backups to manage
WordPress coreRemovedNo updates, no security patches, no compatibility testing
Plugins (10+)RemovedNo third-party code, no supply chain risk
wp-cronRemovedNo background processes consuming resources
XML-RPCRemovedNo legacy API endpoint to protect
REST APIRemovedNo unauthenticated endpoints leaking data
User authenticationRemovedNo login page, no admin panel, no sessions
Build toolsNever addedNo npm, Webpack, Vite, or Composer
Node.jsNever addedNot installed, not needed

The attack surface went from "everything a full CMS exposes" to "Apache serves files." There's nothing to exploit because there's nothing running. No admin panel to brute force. No database to inject. No plugins with vulnerabilities. No REST endpoints to enumerate.

What I Kept

I kept what matters:

  • PHP: For includes, templating, and server-side search. It's already on the server.
  • Bootstrap: Loaded from a CDN. No local installation. Responsive design out of the box.
  • Git: The entire site is version controlled. Content and code together. Deployment is git pull.
  • Apache: Already running. Clean URL rewrites via .htaccess.

Four things. All of them were already installed on the server. Zero new dependencies added.

The Result

The site loads faster because there's no database query on every page load. It's more secure because there's nothing to attack. It's easier to maintain because there's nothing to update. And it's trivially portable: I can move the entire site by copying a directory.

Is this approach right for everyone? No. If you need user accounts, e-commerce, or dynamic content that changes per-request, you need a real application. But for a personal site with articles? A portfolio? A company blog? You probably don't need WordPress. You probably don't need a framework. You probably don't even need a database.

The Ongoing Workflow

The migration wasn't the end. It changed how I publish going forward.

When I want to write a new article, I use my Claude instance to perform the research, draft the content, and edit it. When it's ready, it automatically generates the article page using the PHP template, slots it into the article index, and pushes it live via git.

But publishing an article isn't just one file. With every new article, the agent knows it must update all ancillary files: the schema markup, the .htaccess redirects, the ai-index.json, the search-index.json, the llms.txt, and the sitemap.xml. One command, everything stays in sync.

This is the real payoff of a flat file architecture. There's no admin panel, no "publish" button, no database row to insert. The entire publishing pipeline is a set of file operations that an AI agent can execute reliably and repeatably. The site becomes a deployment target, not an application to manage.

The Goal Never Changed

Fifteen years ago, I was introduced to WordPress and it made perfect sense. It made getting online easier. You didn't need to know HTML. You didn't need to understand servers. You installed it, picked a theme, and started writing. That was the promise, and for a long time it delivered.

But over the years, the platform has grown in ways that moved further from that original simplicity. Plugins, updates, security patches, database management, build tools, staging environments. What started as "anyone can publish" gradually became "you need a webmaster." We always hated that term, so 90's, but it became the reality. WordPress made it easy to get online, then slowly made it hard to stay there without technical know-how.

The thing is, the goal never changed. For the average person building a personal site, a portfolio, or a small brand, the "how" doesn't matter. Nobody cares whether their site runs on WordPress, flat PHP, or stone tablets. They want the baby, not the labor.

That's what makes today's technology transformational. AI doesn't just help developers migrate sites faster. It closes the gap that opened over the last decade. The original promise of WordPress was "you don't need to be technical to publish online." AI delivers on that same promise, but without the complexity that WordPress accumulated along the way. You describe what you want, and the tools handle the rest: the files, the templates, the deployment, the maintenance. The technology disappears, and you're left with what matters: your content, your brand, your voice.

The one caveat is design. AI can generate markup, structure pages, and wire up functionality, but producing polished, production-ready visual design is not completely there yet. It's not far off, and some frameworks like Astro are doing a very good job bridging that gap with component-based design systems and pre-built themes. But for now, if you care about how your site looks and feels, expect to put in some hands-on design work. That last mile of visual craft is still a human problem.

Sometimes the best architecture is the one with the fewest moving parts.