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.

Three migration routes from WordPress to a static host WordPress branches into three routes — Freeze, Headless and Cut over. Each produces a folder of HTML, and all three converge on a static edge host. WORDPRESS php · mysql · plugins A · FREEZE crawl it, ship the copy LOW B · HEADLESS wp edits, ssg builds MED C · CUT OVER wxr → markdown, wp retired HIGH snapshot/ html exactly as it was build/ regenerated on publish repo/ content you own EDGE HOST static files only

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.

FeatureWhy it stops workingStatic 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
Site search /?s= is a database query Prebuilt client index: Pagefind, Lunr.js, Fuse.js. Hosted: Algolia, Typesense
Comments Visitors writing to your database Giscus (GitHub Discussions), Cusdis, self-hosted Isso — or drop them and link an email
Newsletter signup Plugin POST handler is gone The provider's own hosted form action — Mailchimp, Buttondown, Listmonk
RSS / Atom feed Generated per request by WP Emit it at build time, serve as application/rss+xml, keep the URL byte-identical
Sitemap Yoast or core generated it live Generate at build; keep /sitemap.xml and /sitemap_index.xml both resolving
Pagination /page/2/ is a query offset Write real pages at build time. They are indexed — silently dropping them loses traffic
Related / popular Live queries against post meta Compute related posts at build from tags. "Popular" needs an analytics feed, or gets cut
Dynamic year PHP date() in the footer A build-time value, or three lines of inline JavaScript. Not worth more than that
Shortcodes Expanded by PHP on render Whatever HTML they emitted is now the source. Audit it, simplify it, delete the dead ones
Page builders Layout lives in serialised post meta The rendered output is all you get. Budget real time for rebuilding complex pages by hand
WP-Cron jobs No PHP left to run on a schedule A scheduled build — GitHub Actions cron, or a host's scheduled deploy
Login / members Sessions need a server Move it off the marketing domain entirely, or do not migrate this part at all
Analytics Injected by a plugin One script tag in the layout — or drop the script and read edge / server logs instead
06

Command shelf

copy, read, then run

The plugin route is usually less painful, but a crawl is the tool that always works — including on the site whose admin password nobody can find.

01 · Crawl the live site
# mirror everything reachable from the homepage
wget \
  --mirror \
  --page-requisites \
  --no-parent \
  --restrict-file-names=windows \
  --reject-regex '(wp-admin|wp-login|xmlrpc|\?replytocom)' \
  --user-agent='StaticMigration/1.0' \
  --wait=0.25 --random-wait \
  --directory-prefix=./snapshot \
  https://example.com/
  • --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
03 · Redirects — Netlify / Cloudflare Pages _redirects
# 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";
  }
}
06 · Headers you get for free now — _headers
/*
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Strict-Transport-Security: max-age=31536000; includeSubDomains
  Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'

/assets/*
  Cache-Control: public, max-age=31536000, immutable

/*.html
  Cache-Control: public, max-age=0, must-revalidate
07 · The diff that catches everything
# 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