- The migration is a day. The pipeline is a week — and the pipeline is what decides whether the choice was right a year later.
- Merging is the deploy. No separate step to forget, and no way for the live site to drift from the repository.
- Gate the things that fail silently — stale content, drifted templates, broken form wiring. None of them error on their own.
- Retired URLs get files, not server rules, if your host is at all uncertain. A 500 is worse than a redirect that never fired.
- Green CI is not proof. Verify the live URL with a cache-buster before believing anything.
Moving a site from WordPress to static HTML is the easy part. You can do it in an afternoon with a crawler and a lot of patience.
The parts that take real work are the two nobody writes about: what replaces the deploy button, and what happens to every URL you are retiring. This is how we did both on our own site, and the specific things that broke.
Why static, and when not to
We are not going to argue that everyone should do this. Static is right for a specific shape of site: mostly-published content, a handful of forms, no logged-in users, no per-visitor personalisation, and a team small enough that "edit a file and push" is not a barrier.
If your client updates copy weekly and does not use git, a static build with a pull-request workflow is a downgrade dressed as an upgrade. We have talked clients out of this migration more often than into it. Our rebuild-or-repair rubric is the version of that conversation we have with anyone considering it.
What you get, when it does fit: pages that render as fast as the bytes arrive, because there is no server-side work between the request and the response. Every URL is a file that already exists. There is no database to compromise, no plugin surface to patch, and no version of the site that only exists in an admin panel. If you are staying on WordPress instead, the edge security setup we run covers the patching surface you keep.
The thing that actually replaces the deploy button
In WordPress, publishing is a button. Remove WordPress and you have removed the button, and if you do not consciously replace it with something at least as safe, you have made the site worse.
What replaced it for us is a workflow that fires on push to the default branch:
# .github/workflows/deploy-production.yml
on:
push:
branches:
- main
workflow_dispatch:
Merging is what deploys. There is no separate deploy step to remember, and no way for the live site to drift from the repository.
The workflow_dispatch is there for one specific reason worth knowing about: commits made by a CI job using the default token do not trigger downstream workflows. That is deliberate on GitHub's part — it prevents a workflow from recursively triggering itself — but it means any automation that commits to your repository will silently fail to deploy. The manual trigger is the escape hatch for exactly that case.
Gates, not hope
A static deploy is fast and total. That is the appeal and it is also the risk: a bad commit is live everywhere in seconds. So the pipeline earns its keep in what it refuses to ship.
Ours runs four gates before anything is uploaded, and the ordering is deliberate — cheapest and most likely to fail first:
| Gate | What it catches | Why it exists |
|---|---|---|
| Data freshness | A page asserting something that stopped being true | Content with a shelf life goes stale silently — nothing errors, it is just no longer accurate |
| Template drift | A page whose header has diverged from the shared partial | Static sites have no request-time engine forcing consistency, so duplication is the default failure |
| Build | Malformed frontmatter, broken markdown | Fails loudly and early |
| Form regression | A broken third-party form integration | The highest-consequence thing on the site and the easiest to break invisibly |
1. A data-freshness gate. One of our pages publishes real build-week availability from a hand-edited JSON file. A calendar nobody has refreshed asserts something false about our availability every day it goes unnoticed, so the deploy fails if the file's updated date is more than 21 days old, if it contains no future weeks, or if every upcoming week is marked as taken. That last check exists because the file's own instructions tell the operator to delete weeks once they are past — which is precisely how you end up publishing an empty calendar.
The general principle: if a page asserts a fact with a shelf life, put the shelf life in CI. Content that silently goes stale is a class of bug that no test suite catches, because nothing is broken. It is just no longer true.
2. A template-drift gate. Every page's header must match a single shared partial:
python3 _local/build/sync_header.py --check
The site had fourteen hand-copied navigation blocks before this existed. The point of the gate is not to fix them — it is that a fifteenth can never quietly appear. On a static site, duplicated markup is the default failure mode, because there is no template engine at request time forcing consistency. Something has to check at build time.
3. A build. Markdown to HTML, sitemap generation, schema injection.
4. A real browser test of the one thing that must not break. Static sites have no server, so forms post to a third party. That integration is the single highest-consequence thing on the site and the easiest to break invisibly — a changed key, a renamed field, a typo in an endpoint. So the pipeline serves the built site locally, drives it with Playwright, intercepts the outbound request before it leaves the browser, and asserts the payload is correctly formed. No real message is sent. If the wiring is broken, the deploy aborts.
That test has never once been the thing we were worried about when we pushed, which is exactly why it is worth having.
Redirects: the part that will bite you
This is where a static migration on shared hosting goes wrong, and it is worth being precise because the failure is counterintuitive.
We retired a set of old case-study URLs. The obvious move is a 301 in .htaccess. On a host configured for WordPress but now serving static files, redirect and rewrite rules can fail in ways that are much worse than not working — you can end up with a 500 on a URL that previously just needed to point somewhere else. Turning a retired page into a server error is strictly worse than leaving it alone.
So we did not use server rules. Every retired URL is a real HTML file that does three things at once:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>DecisionBrain Case Study — Rough Works</title>
<link rel="canonical" href="https://roughworks.ca/case-study/">
<meta http-equiv="refresh" content="0; url=/case-study/">
<script>location.replace('/case-study/');</script>
</head>
<body><p>Redirecting to <a href="/case-study/">/case-study/</a>…</p></body>
</html>
Three mechanisms, deliberately layered:
| Mechanism | Handles | Limitation |
|---|---|---|
rel=canonical |
Search engines — passes the ranking signal | Ignored by browsers; does not move the visitor |
meta refresh at 0 |
Browsers with JavaScript disabled | Weaker ranking signal than a real 301 |
location.replace() |
Everyone else, without polluting history | Requires JavaScript |
| A visible link in the body | The case where all three fail | Requires the visitor to act |
rel=canonicalis the search-engine signal. It tells Google which URL should hold the ranking, and it works whether or not any redirect executes.meta refreshat0handles browsers with JavaScript disabled.location.replace()handles everyone else, andreplacerather thanhrefmatters — it does not add an entry to the history stack, so the back button does not trap the visitor in a redirect loop.- A visible link in the body is the final fallback, and it is not decorative. If all three mechanisms fail, the page still tells a human where to go.
This is not as good as a 301. A meta refresh is a weaker ranking signal, and you should use a real server redirect wherever your host reliably supports one. But a static redirect page that definitely works beats a server rule that returns a 500, and it is portable across hosts.
Verify these after deploy, not before. Fetch each retired URL and confirm it returns 200 with the canonical you expect. A redirect stub that 404s is invisible in local testing and obvious to a crawler.
Green CI is not proof the change is live
The most useful habit we picked up from this migration: a successful deploy workflow proves the workflow succeeded, not that the site changed.
Two things break that assumption regularly.
We wrote up the reasoning behind our own move when we made it, and headless WordPress with the Claude API covers the middle path — keeping the CMS, replacing only the front end.
Caching. Production sits behind a cache with a meaningful TTL. A deploy does not necessarily purge it. For up to ten minutes, the live URL can serve the previous version while every dashboard reports success. If you check immediately, see the old content, and conclude the deploy failed, you will "fix" something that was never broken. Always verify with a cache-buster:
curl -sS "https://example.com/page/?cb=$RANDOM" | grep "the thing you changed"
Build artefacts that CI regenerates. Our built blog output is gitignored and produced during deploy; the homepage and sitemap are tracked but also regenerated. The consequence is that the committed copies drift out of date while the live site is perfectly correct. We found our own committed homepage was two weeks stale against production — not a bug, just an artefact of where the build happens. Worth knowing before you spend an afternoon debugging a difference that does not exist.
The rule we ended up with: after any deploy, fetch the live artefact with a cache-buster and confirm both that the change landed and that nothing adjacent broke. A correct workflow will happily deploy an incomplete edit.
What we would do differently
Once the pipeline runs, the next question is what to automate around it — AI SEO agent: what to automate and what to leave alone covers the audit, validation and release stages that sit on top of a deploy like this.
Decide where the build happens, once, and write it down. Half of our early confusion came from not being certain whether a given file was authored, generated, or both. Generated files in version control are a legitimate choice and so is gitignoring them — but mixing the two without a stated rule guarantees somebody eventually edits a file that gets overwritten on the next deploy.
Put the redirect map in the repository before you delete anything. We built ours reactively. A crawl of the old site, exported to a list of old-URL-to-new-URL pairs, committed before the migration starts, is an hour of work that prevents a long tail of 404s you discover months later.
Add the freshness gates on day one. Every gate in our pipeline was added after the thing it checks had already gone wrong once. That is a normal way to build a pipeline and a slow way to learn.
Do not migrate the CMS out from under someone who needs it. The strongest reason not to do this is a client who publishes regularly and does not want a git workflow. Static is a genuine improvement for a site the developers maintain. It is a genuine regression for a site the marketing team maintains, and no amount of tooling fully closes that gap.
The short version
The migration is a day. The pipeline is a week, and it is the part that determines whether the decision was a good one a year later.
Build gates for the things that fail silently — stale content, drifted templates, broken form wiring. Handle retired URLs with files rather than server rules if your host is at all uncertain. And treat "the workflow went green" as the beginning of verification rather than the end of it.
If you want the reasoning behind the platform choice itself rather than the mechanics, we compared WordPress, Webflow, Shopify and static by what each costs in year three. For the migration itself, our WordPress development page describes how we run one.
Common questions
Should I move my WordPress site to static HTML?
Only if the site fits the shape: mostly-published content, a handful of forms, no logged-in users, no per-visitor personalisation, and a team comfortable with a git-based workflow. Static is a genuine improvement for a site developers maintain and a genuine regression for one the marketing team updates weekly. We have talked clients out of this migration more often than into it, and no amount of tooling fully closes the editor-experience gap.
How do I deploy a static site without a publish button?
Make merging to your default branch the deploy. A workflow that fires on push means there is no separate step to remember and no way for the live site to drift from the repository. Keep a manual dispatch trigger as well, because commits made by CI using the default token deliberately do not trigger downstream workflows — that recursion guard will otherwise silently prevent automated commits from ever deploying.
How should I redirect old URLs after a static migration?
Use a real 301 wherever your host reliably supports one. Where it does not — and a host configured for WordPress but now serving static files is a common case — publish a redirect page instead: a canonical link for search engines, a meta refresh at zero for browsers without JavaScript, and location.replace for everyone else, plus a visible link in the body as the final fallback. It is a weaker ranking signal than a 301, but a redirect that definitely works beats a server rule that returns a 500.
Why use location.replace instead of location.href for a redirect?
Because replace does not add an entry to the browser history stack. With href, pressing back returns the visitor to the redirect page, which immediately forwards them again — trapping them in a loop they cannot escape with the back button. It is a one-word difference with a real usability consequence.
What should a static site's deploy pipeline check before publishing?
The things that fail silently rather than loudly. Content with a shelf life, so a page cannot keep asserting something that stopped being true. Template drift, because duplicated markup is a static site's default failure mode with no request-time engine enforcing consistency. And the third-party form integration, which is usually the highest-consequence thing on the site and the easiest to break invisibly — drive it with a real browser and assert the outbound payload.
Does a green CI run mean my change is live?
No. It means the workflow succeeded. Production caches can serve the previous version for several minutes after a successful deploy, so an immediate check can show old content and send you debugging something that was never broken. Always verify with a cache-buster on the live URL, confirm the change landed, and check that nothing adjacent broke — a correct workflow will happily deploy an incomplete edit.
Should generated files be committed to the repository?
Either choice works; mixing them without a stated rule does not. Decide once whether each file is authored, generated, or both, and write it down. Our built blog output is gitignored and produced during deploy, while the homepage and sitemap are tracked but also regenerated — which means the committed copies drift stale while the live site is perfectly correct. That is expected behaviour, but it will cost you an afternoon if nobody documented it.

