Field manualNo. 01Web infrastructureTwo passes, no plugins
Unplug the database
Most WordPress sites are a database, a PHP runtime and thirty plugins doing the work of
a folder of files. This is the field manual for taking one apart — what to keep,
what genuinely breaks, and how to land the move without losing a single URL.
0Database queries per request
0PHP workers to keep alive
1Artifact per URL, diffable in git
∞Cache lifetime at the edge
Approvedfor press
01
The case
why bother
A static site is not a downgrade. It is the same HTML your visitors were already receiving —
minus the machinery that assembled it on every single request.
Speed
No bootstrap, no plugin init, no query loop. Time-to-first-byte stops being an application
problem and becomes a network problem — and networks are the part somebody else already optimised.
Surface
No admin login to brute-force, no PHP to execute, no xmlrpc.php, no database to inject.
The single most common WordPress incident — a vulnerable plugin — stops being a category of event.
Cost
Static hosting is free-to-trivial at most volumes. No managed-WP invoice, no staging clone
spun up to test a plugin bump, no WAF tuned to guess at bad requests.
Life
HTML written in 2005 still renders today. A site pinned to PHP 7.4 and a retired page builder
does not still run. Static output ages the way documents age, not the way software rots.
Portable
The entire site becomes a directory. Zip it, mirror it, version it, hand it to the client,
serve it from a laptop. Nothing about it is tied to one host's control panel.
Quiet
No Tuesday-morning security release. No auto-update that changes the layout at 03:00.
A frozen site stays exactly as you left it, which for most brochure sites is the whole point.
The honest counterweight. Static is not free of cost — it moves the cost.
Publishing becomes a build, not a click. Anything that wrote to the database at request time now needs
a home somewhere else. If the people who edit this site will never accept a two-minute deploy between
writing and seeing, keep WordPress and make it fast instead.
And some sites should simply not be migrated: carts and checkout, logged-in accounts, per-user
dashboards, live inventory or pricing, real-time booking, forums. Those are applications. Freezing
an application produces a broken application.
02
Triage
is this site a candidate
Sort the site into one of three piles before you touch a single export button. The amber column is
where the actual work lives — every item there is solvable, and every item there costs a day.
Ship it
Brochure & company sites
Portfolios and case studies
Blogs and editorial archives
Documentation
Campaign microsites
Restaurants, menus, venues
Conference and event sites
Sites that are simply finished
Costs a day each
Contact and enquiry forms
On-site search
Comment threads
Newsletter signup
Multilingual URL trees
Daily publishing cadence
Editors who refuse a build step
Ten years of shortcode debris
Do not migrate
WooCommerce cart & checkout
Membership / gated content
User accounts and profiles
Live stock or pricing
Real-time booking calendars
Forums and community boards
Anything writing to a DB on view
LMS and course platforms
Mixed site? Split the domain. Marketing goes static; the application keeps its own subdomain and its own stack.
03
Three routes
pick one, commit
Every WordPress migration is one of three shapes. They differ in how much of WordPress survives
the trip — and in who has to learn something new.
Route B keeps WordPress alive as a private editor behind a firewall; a publish webhook triggers the build. Routes A and C end with WordPress switched off.
A · FreezeLOW EFFORT
Crawl the running site, capture what it emits, deploy the folder. WordPress is archived, not
consulted. The generated HTML becomes the source of truth, warts and all.
The right answer for a site that is finished, or changes four times a year and can afford
a re-export each time.
Tools
Simply Static · WP2Static · Staatic · wget
Editing after
Re-crawl, or edit HTML directly
Watch for
Baked-in absolute URLs and query strings
B · HeadlessMEDIUM EFFORT
WordPress keeps the content and the editor everybody already knows. A static site generator
pulls from the REST API or WPGraphQL at build time and emits the public site.
The right answer for teams who publish weekly and will not be retrained — but who want
nothing public running PHP.
Tools
WP REST / WPGraphQL + Astro · Eleventy · Hugo · Next
Editing after
Publish in WP, webhook fires a build
Watch for
WP still needs patching — lock it down and hide it
C · Cut overHIGH EFFORT
Export the WXR, convert posts to Markdown, rebuild templates in a generator, retire WordPress
permanently. The most work up front and the only route with no PHP left anywhere.
The right answer for a site you intend to own for the next decade.
Tools
wordpress-export-to-markdown · Hugo · Eleventy · Jekyll
Editing after
Markdown in git, or a git-backed CMS
Watch for
Shortcodes and page-builder markup need hand conversion
04
The playbook
ten steps, in order
The order matters more than the tooling. Skip step one and you will discover the missing URLs
from a search-console email six weeks later.
Inventory every URL
Pull the XML sitemap, twelve months of access logs, the Search Console page list and a full
crawl of internal links. Deduplicate into one flat file. This list is the contract: every URL on
it must resolve after the move, with the same status code or a deliberate redirect.
Logs beat sitemaps. They contain the URLs the plugin forgot to list and the ones people still link to.
Freeze and archive
Announce an editorial freeze. Then take three artifacts: the WXR export from Tools → Export,
a full mysqldump of the database, and a tarball of wp-content/uploads.
Store them somewhere that is not the web server.
This is your archive, not your site. You will need the database again for the ?p=ID redirect map.
Choose the route
A, B or C from the previous section — decided by who edits the site and how often, not by which
tool looks most interesting. Write the decision down along with the reason; in nine months somebody
will ask why the site is not in WordPress any more.
Generate the build
Crawl or build, then check the output actually contains the long tail: paginated archives,
category and tag pages, author pages, the feed, robots.txt, sitemap.xml,
the favicon and manifest, and the whole uploads directory at its original paths.
Keep /wp-content/uploads/… paths intact. Other people's sites hotlink your images and their links should not break.
Replace the dynamic parts
Forms point at an endpoint. Search becomes a prebuilt index. Comments move to a third party or
are retired. Anything talking to admin-ajax.php is either replaced or removed. The
ledger in section 05 lists the usual suspects and their substitutes.
Settle the URL shapes
Pretty permalinks map cleanly to /path/index.html, so directory-style URLs keep
working. Decide the trailing-slash policy once, enforce it in one place, and make sure canonical
tags, the sitemap and internal links all agree with it.
Two URLs for one page is not a cosmetic issue — it splits your ranking signals in half.
Map the redirects
Every legacy shape needs a rule: ?p=123 permalinks, /index.php/ prefixes,
an earlier permalink structure, uppercase paths, /feed/ variants. Everything under
/wp-admin/, /wp-json/ and /xmlrpc.php should return 410, not 404 —
it tells crawlers and scanners to stop asking.
Diff old against new
Crawl both the live site and the staging build. Compare status code, title, H1, canonical tag,
meta description and word count for every URL in the inventory. Investigate every row that moved.
This single step catches more regressions than all manual QA combined.
Cut over
Lower the DNS TTL to 300 seconds a day in advance. Deploy, run the diff once more against the
real hostname, confirm TLS is issued, then move the record. Keep the old host reachable by IP for
a few days in case something needs comparing.
Decommission slowly
Take WordPress off the public internet immediately, but keep it running privately for a few
weeks. Watch 404 logs daily for the first fortnight and add redirects as real gaps appear. Then
shut it down and keep the archive forever.
The archive is cheap. The one time you need a 2014 post nobody exported, it is priceless.
05
What breaks
the honest ledger
Everything WordPress did at request time has to happen somewhere else — at build time, in the
browser, or at somebody else's endpoint. Nothing here is a blocker; all of it is work.
Feature
Why it stops working
Static replacement
Contact form
PHP mailer with nothing left to POST to
A form endpoint — Netlify Forms, Formspree, Basin — or twenty lines in a Cloudflare Worker / Lambda
--mirrorRecursive, unlimited depth, timestamped. The whole tree in one pass.
--page-requisitesPulls the CSS, JS, fonts and images each page actually needs.
--no-parentStops the crawl wandering up above your starting path.
--reject-regexSkips admin, login and the comment-reply URLs that explode combinatorially.
--waitBe kind to the origin. A hard crawl looks exactly like an attack in the logs.
no --convert-linksThat flag rewrites links for offline browsing — wrong for a hosted site. Rewrite the domain yourself instead, in the next step.
02 · Make links root-relative & strip WP fingerprints
# macOS/BSD sed shown; on GNU sed drop the empty '' after -i
grep -rl 'https://example.com' ./snapshot \
| xargs sed -i '''s|https://example\.com||g'# drop the emoji loader, the generator meta and the API discovery tags
grep -rl 'wp-emoji-release' ./snapshot \
| xargs sed -i '' -E '/wp-emoji-release\.min\.js/d'
grep -rl 'name="generator"' ./snapshot \
| xargs sed -i '''/<meta name="generator"/d'# then verify by hand. regex is a blunt instrument on HTML.
grep -rn 'wp-json\|xmlrpc\|admin-ajax' ./snapshot | head -50
# legacy path shapes
/index.php/* /:splat 301
/feed /feed/index.xml 301
# tell scanners and crawlers to stop asking, permanently
/wp-admin/* / 410
/wp-login.php / 410
/wp-json/* / 410
/xmlrpc.php / 410
/comments/feed/* / 410
# ?p=ID permalinks — one line per post, generated from the DB
/ p=42 /notes/first-post/ 301!
/ p=87 /notes/on-static-sites/ 301!
# and the catch-all, last
/* /404.html 404
04 · Generate the ?p=ID map from the database
# run against the archived dump, not production
mysql -N -B wordpress_archive -e "
SELECT CONCAT('/', CHAR(9), 'p=', ID, CHAR(9),
REPLACE(guid,'https://example.com',''), CHAR(9), '301!')
FROM wp_posts
WHERE post_status = 'publish' AND post_type IN ('post','page');
" >> _redirects
05 · Same thing on nginx
server {
root /var/www/example.com;
# serve pretty permalinks straight from the folder tree
location / {
try_files $uri $uri/ $uri/index.html =404;
}
location = /feed { return 301 /feed/index.xml; }
location ^~ /wp-admin/ { return 410; }
location ^~ /wp-json/ { return 410; }
location = /xmlrpc.php { return 410; }
# hashed assets are immutable; html is not
location ~* \.(css|js|woff2|png|jpg|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location ~* \.html$ {
add_header Cache-Control "public, max-age=0, must-revalidate";
}
}
# urls.txt: one path per line, from step 01 of the playbook
while read -r path; do
old=$(curl -o /dev/null -sL -w '%{http_code}'"https://example.com$path")
new=$(curl -o /dev/null -sL -w '%{http_code}'"https://staging.example.com$path")
[ "$old" = "$new" ] || printf '%s\t%s -> %s\n'"$path""$old""$new"
done < urls.txt | tee status-diff.tsv
# then the same loop comparing <title> and <h1>, not just status codes
07
Where it lives
five reasonable answers
All five serve the same folder. They differ in what happens at the edges — redirects, headers,
build limits and who you call when it stops.
Cloudflare Pages
Global edge by default, git-connected builds, native support for _redirects and _headers.
Watch: per-deployment file-count and file-size caps — large media archives can hit them.
Netlify
The smoothest redirect and form story of the five. Deploy previews per branch make the diff step easy.
Watch: bandwidth and build-minute pricing once the site gets popular.
GitHub Pages
Free, versioned, custom domain with automatic TLS. The whole site lives in a repository you already have.
Watch: no server-side redirect rules at all — you are stuck with meta refresh.
S3 + CloudFront
Cheap at any scale, complete control of caching and headers, no vendor build step in the way.
Watch: subdirectory index documents need a CloudFront Function. You assemble it yourself.
Caddy or nginx
One config file on a box you own. Automatic HTTPS with Caddy; total predictability with nginx.
Watch: you are patching a server again — though no longer an application.
08
Landing checklist
before and after the switch
Before you move DNS
Status-code diff across the full URL inventory comes back clean
Redirect rules tested against the real hostname, not localhost
Forms confirmed delivering to a real inbox in production
robots.txt no longer disallows anything it shouldn't
Sitemap regenerated, submitted, and matching the trailing-slash policy
Canonical tags absolute and pointing at the new URLs
Feed validates and keeps its original path
A real 404 page exists and is wired up
Favicon, manifest, Open Graph images all present
TLS certificate issued for the new host in advance
DNS TTL lowered to 300s at least a day earlier
Rollback plan written down and understood by someone else
After the switch
404 logs reviewed daily for the first fortnight
Missing URLs turned into redirects the same day they appear
Indexation and impressions watched for four weeks, not four days
Core Web Vitals rechecked from field data
WordPress off the public internet, still running privately
WXR + database dump + uploads tarball in cold storage
Analytics confirmed still recording after the cutover
Old host's invoice cancelled — deliberately, with a date
Build pipeline documented for whoever inherits this
Security headers verified against the live domain
Cache headers verified: HTML short, assets immutable
Calendar reminder set to delete the old host in 90 days