I finally moved Explosive Cloud from WordPress to Hugo on Cloudflare Pages.
This site started on WordPress because WordPress is easy to get going. Install it, pick a theme, start writing. For a long time that was good enough.
Eventually though, the amount of infrastructure around the site felt silly for what it was doing. I did not need PHP, a database, plugin updates, cache plugins, and a dynamic admin just to publish the occasional homelab, Veeam, Cloudflare, or virtualization note.
So I moved it to Hugo, deployed from a private GitHub repo, and hosted it on Cloudflare Pages.
That sounds easy enough.
Why Hugo?
The main reason was simplicity.
With Hugo, the site is just content in Git and static files at the end of the build. Cloudflare Pages can build it from the repo and serve it without me maintaining a WordPress runtime. That also makes the site much faster to serve than the old WordPress stack, because there is no database lookup or PHP request path involved for normal page views.
The other important goal was keeping the existing URLs working. I did not want old links to turn into a redirect cleanup project. If someone had linked to a post, I wanted that URL to keep working after the migration.
Start With the Messy Stuff
The WordPress export is useful, but it is not something I wanted to casually commit to Git.
A WXR export can include more than public post content. It may contain comment metadata, author information, internal IDs, attachment records, and other data that is helpful during a migration but does not need to live in the repository forever.
I kept the raw export and temporary migration work under _migration/, which stayed ignored by git. Same idea for Hugo’s generated public/ directory, downloaded media caches, generated reports, credentials, cookies, and tokens.
The basic preflight was boring but useful:
git rev-parse --show-toplevel
git check-ignore _migration/input/wordpress-export.xml
git check-ignore public/index.html
hugo version
wp2hugo -h
I would do that again. It is much easier to keep private migration material out of Git from the beginning than to clean it up later.
The Starting Inventory
Before trusting any conversion output, I wanted to know what WordPress actually had.
The export had:
- 18 posts
- 2 pages
- 205 attachments
- 20 approved comments
- 10 categories
- 13 tags
- 227 referenced media URLs
- 20 public content URLs
That gave me a simple way to sanity check the migration. If the converted site had wildly different post counts or missing public URLs, something was wrong.
And something was wrong.
The Converter Was Only a Starting Point
I used the Go-based ashishb/wp2hugo release 1.30.0.
The first run was just a dry run:
wp2hugo -source _migration/input/wordpress-export.xml -output _migration/work/wp2hugo
That was useful, but I would not treat the converter output as final. In my case, the raw output included more post files than expected, so I filtered imported content against the public URL inventory from the WordPress export.
That kept the committed Hugo content aligned with the actual published site: 18 posts, 2 pages, and 20 public content URLs.
I also kept the old WordPress-style upload paths:
/wp-content/uploads/...
That may not be the prettiest Hugo-native path, but it was the practical one. Existing posts, image references, favicons, and external links already used those URLs. Rewriting all of that just to make the directory layout feel cleaner would have added risk without much benefit.
After import, I cleaned up the metadata block at the top of each Markdown file. WordPress-internal fields were removed, while useful fields like title, date, categories, tags, URL, cover image data, and later historical comments were preserved.
Media Was the First Real Gotcha
Media path handling needed more attention than I expected.
Some references needed URL-decoding before they matched files on disk. Once that was handled, the imported content had no missing media references.
The first media-focused converter run looked like this:
wp2hugo -source _migration/input/wordpress-export.xml `
-output _migration/work/wp2hugo-media `
-download-media `
-media-cache-dir _migration/work/wp2hugo-media-cache
Envira Galleries Needed Their Own Pass
Envira Gallery did not come across as finished static content.
The imported Markdown only had escaped shortcode markers like:
\[envira-gallery id='479'\]
Awesome!… wait… no.
The WXR export still had the gallery metadata, so I pulled the gallery definitions from there and replaced the shortcodes with static linked image grids using local /wp-content/uploads/... paths.
The gallery media download was also where I hit a small HTTP wrinkle. Some public media URLs returned HTTP 403 to a plain script download, but worked once the request used a browser-like User-Agent. That was not a Hugo problem, just the old site being picky about how files were requested.
That kept the old gallery content visible without bringing WordPress, Envira, or a runtime gallery plugin along for the ride.
Historical Comments
I did not want to lose the old comments, but I also did not want to publish WordPress comment metadata that did not belong in Git.
wp2hugo did not preserve comments in the committed Markdown, but the WXR export had 20 approved comments. I extracted only the public-safe parts:
- author
- date
- content
Everything else stayed out:
- IP address
- user-agent
- moderation metadata
The result was 20 approved comments attached across 8 matching Markdown files and rendered statically below the post content.
I preserved the historical comments, but I did not build a new active comment system for the Hugo site.
URL Preservation
This was the part I cared about most.
The final URL audit came back:
- 20 same URL
- 0 redirected
- 0 needs decision
That meant all published WordPress content URLs existed at the same paths in Hugo. No redirect map was needed for posts and pages.
I wrote a small local script to compare old URLs against the generated Hugo paths. The command looked like this:
python scripts/migration/url_audit.py `
--old-urls-json _migration/reports/old-urls.json `
--new-paths-json _migration/reports/hugo-public-paths.json `
--redirects-json _migration/reports/redirects.json `
--out _migration/reports/url-audit.json
Here is the script, trimmed into a self-contained version:
from __future__ import annotations
import argparse
import json
from pathlib import Path
from urllib.parse import urlparse
def write_json(path: Path, data: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _path(url: str) -> str:
if not url or not url.strip():
raise ValueError("empty URL cannot be audited")
parsed = urlparse(url)
value = parsed.path if parsed.scheme else url
if not value.startswith("/"):
value = "/" + value
if value != "/" and not value.endswith("/"):
value += "/"
return value
def classify_urls(
old_urls: list[str],
new_paths: set[str],
redirects: dict[str, str],
base_url: str,
) -> dict[str, object]:
for path in new_paths:
if not path or not path.strip():
raise ValueError("new_paths contains an empty path")
same_url: list[str] = []
redirected: list[dict[str, str]] = []
needs_decision: list[str] = []
normalized_new_paths = {_path(path) for path in new_paths}
normalized_redirects = {_path(source): target for source, target in redirects.items()}
for url in old_urls:
path = _path(url)
if path in normalized_new_paths:
same_url.append(url)
elif path in normalized_redirects:
redirected.append({"from": url, "to": normalized_redirects[path]})
else:
needs_decision.append(url)
return {
"base_url": base_url.rstrip("/"),
"same-url": same_url,
"redirected": redirected,
"needs-decision": needs_decision,
}
def main() -> None:
parser = argparse.ArgumentParser(description="Classify old URLs against Hugo output and redirects.")
parser.add_argument("--old-urls-json", type=Path, required=True)
parser.add_argument("--new-paths-json", type=Path, required=True)
parser.add_argument("--redirects-json", type=Path, required=True)
parser.add_argument("--base-url", default="https://explosive.cloud")
parser.add_argument("--out", type=Path, default=Path("_migration/reports/url-audit.json"))
args = parser.parse_args()
old_urls = json.loads(args.old_urls_json.read_text(encoding="utf-8"))
new_paths = set(json.loads(args.new_paths_json.read_text(encoding="utf-8")))
redirects = json.loads(args.redirects_json.read_text(encoding="utf-8"))
write_json(args.out, classify_urls(old_urls, new_paths, redirects, args.base_url))
print(f"wrote {args.out}")
if __name__ == "__main__":
main()
A Hugo build passing is not the same thing as old URLs still working. This little check made that visible.
Rebuilding the Look
I did not want the site to look like a completely different site after cutover.
The Hugo theme was rebuilt around the old WordPress Minimal Grid feel: desktop brand rail, post cards, compact metadata, sidebar widgets, mobile top bar, mobile card stack, favicon links, and mobile menu behavior.
The mobile baseline was useful here because it showed whether the rebuilt theme still felt like the same site once the desktop layout collapsed.

The important part was not pixel-perfect recreation. It was opening the rendered pages in a browser and making sure the site still felt familiar before cutover.
Cloudflare Pages
Cloudflare Pages was the easy part.
The important settings were:
Build command: hugo
Output directory: public
Environment variable: HUGO_VERSION=0.161.1
Pinning HUGO_VERSION mattered. The local build used Hugo Extended 0.161.1, and I did not want Cloudflare’s build image default Hugo version to change and cause me issues unexpectedly.
Before production cutover, the Pages preview answered correctly for the homepage, representative post, archive/list page, CSS, JavaScript, favicon, and representative media.
Production Cutover
The production URL is:
https://explosive.cloud
The www hostname redirects to the root domain.
The final cutover work happened in the Cloudflare dashboard: custom domain setup, DNS, and the www to root domain redirect.
After cutover, I checked the basics:
- homepage returned
200 - representative post returned
200 - category/archive page returned
200 - favicon asset returned
200 image/jpeg wwwreturned301to the root domain- representative
wwwpost paths redirected to the matching root-domain path and then returned200
The representative checks were basically:
curl.exe -I -L https://explosive.cloud
curl.exe -I -L https://explosive.cloud/accessing-veeam-13-web-ui-via-cloudflare-tunnels/
curl.exe -I -L https://explosive.cloud/category/backup-disaster-recovery/
curl.exe -I https://www.explosive.cloud
Do not forget to set up the Cloudflare Redirect Rule for www to the root domain if you want one canonical hostname. Also make sure the www DNS record is proxied through Cloudflare; otherwise the redirect rule will not see the request.
Final Thoughts
This migration is done.
The site is now a static Hugo site, deployed from a private GitHub repo to Cloudflare Pages, with the production domain on https://explosive.cloud and www redirecting to the root domain.
The big win is that the site is simpler now, and it is much faster to serve than the old WordPress stack. No WordPress runtime, no database, no plugin chain, no cache plugin doing cache plugin things. Just content in Git, Hugo building static files, and Cloudflare serving them.
That is much easier to reason about, and for this site, that is exactly what I wanted.