Why abandon WordPress
After more than a decade maintaining Orbit Network Regarding WordPress, the decision to migrate to 100% Static Site was not impulsive. WordPress is an extraordinary platform, but for a technical blog with more than 600 posts the operating cost had become unsustainable:
- Attack surface: PHP, MySQL, plugins, themes, xmlrpc.php, wp-login.php... each component is a vector. Keeping everything patched was constant work.
- Performance: No matter how much cache (W3 Total Cache, Varnish, Redis) you configure, a static site served from a CDN will always be faster.
- Cost: A VPS with MySQL, PHP-FPM, Nginx and automated backups is not cheap. Cloudflare Pages is free for static sites.
- Complexity: WordPress updates, plugins, PHP, MySQL, SSL certificates, database backups... too many moving parts for a personal blog.
The objective was clear: convert the more than 600 posts into static HTML files, keep the original URLs so as not to lose SEO positioning, and display everything in Cloudflare Pages with zero cost.
Migration strategy
There are several approaches to migrating WordPress to static. The most common are:
- Export plugins (Simply Static, WP2Static plugin): generate a static copy from within WordPress. They work well for small sites, but with +600 posts they usually fail due to timeouts or memory.
- External Crawlers (wget, HTTrack): download the entire site by following links. The result requires a lot of manual cleaning.
- Custom script: total control over the crawling process, transformation and final structure.
For Red Orbita we chose the third option: a Python script called wp2static which automates the entire process. The script evolved from a 200 line prototype to a 2000+ line modular tool, and is available as an open source project at GitHub.
wp2static architecture
The script is organized into independent modules, each responsible for a phase of the process:
wp2static/
├── wp2static.py # CLI principal
├── config.example.yaml # Plantilla de configuración
├── modules/
│ ├── config.py # Carga y validación de config YAML
│ ├── session.py # Sesión HTTP con reintentos
│ ├── auth.py # Autenticación (App Passwords, cookies)
│ ├── crawler.py # BFS crawl + descubrimiento por sitemap
│ ├── sanitizer.py # Limpieza y normalización de slugs
│ ├── assets.py # Descarga de imágenes y recursos
│ ├── transformer.py # Transformación HTML, reescritura de enlaces
│ ├── structure.py # Reorganización de directorios
│ ├── pagination.py # Generación de páginas de índice y categoría
│ ├── redirects.py # Generación de _redirects (Cloudflare)
│ ├── sitemap.py # Generación de sitemap.xml
│ ├── search_index.py # Generación de search-index.json
│ └── validator.py # Validación post-exportación
└── requirements.txtEach module can be run independently to debug problems or re-execute a specific phase without repeating the entire process.
Phase 1: Configuration
All configuration is centralized in a YAML file. This allows you to document exactly which parameters were used in each export and reproduce the process deterministically:
# config.yaml
source:
url: "https://tu-wordpress.com"
auth:
method: "app_password"
username: "admin"
# La password se lee de la variable de entorno WP2STATIC_PASSWORD
password_env: "WP2STATIC_PASSWORD"
output:
directory: "./output"
url: "https://tu-sitio-estatico.com"
crawl:
max_depth: 50
concurrent_requests: 5
timeout: 30
respect_robots: true
additional_urls:
- "/sitemap.xml"
- "/feed/"
transform:
remove_wp_elements: true
rewrite_urls: true
download_assets: true
clean_html: true
categories:
cloud-devops: "Cloud & DevOps"
cybersecurity: "Cybersecurity"
linux-systems: "Linux & Systems"
networks-infrastructure: "Networks & Infrastructure"
siem-monitoring: "SIEM & Monitoring"
dfir-threat-intel: "DFIR & Threat Intel"
development-other: "Development & Other"Authentication supports three methods: Application Passwords from WordPress (recommended), cookie authentication with Playwright for sites with 2FA, or a cookie file exported from the browser.
Phase 2: Crawling
The crawler uses a BFS (Breadth-First Search) algorithm to scan the entire WordPress site following internal links. Additionally, parse the sitemap.xml to discover URLs that might not be linked from any page (orphan posts, old pages).
# Ejemplo simplificado del crawler BFS
from collections import deque
from bs4 import BeautifulSoup
import requests
def crawl(start_url, session, max_depth=50):
visited = set()
queue = deque([(start_url, 0)])
pages = {}
while queue:
url, depth = queue.popleft()
if url in visited or depth > max_depth:
continue
visited.add(url)
response = session.get(url)
if response.status_code != 200:
continue
pages[url] = response.text
soup = BeautifulSoup(response.text, 'html.parser')
for link in soup.find_all('a', href=True):
href = normalize_url(link['href'], start_url)
if is_internal(href, start_url) and href not in visited:
queue.append((href, depth + 1))
return pagesKey aspects of crawling:
- Persistent session with retries: we use
requests.Sessionwith aHTTPAdapterconfigured withRetryto handle transient errors (429, 500, 502, 503). - Rate limiting: a configurable delay between requests to avoid saturating the WordPress server.
- Summary mode: If crawling is interrupted, it can be resumed from where it left off thanks to a checkpoint that is saved periodically.
- 404 recovery: URLs that return 404 are recorded in a separate file to decide whether to create redirects or delete them.
In the case of Red Orbita, crawling the more than 600 posts took approximately 45 minutes with 5 concurrent requests.
Phase 3: HTML Transformation
This is the most complex phase and where most of the work is concentrated. Every HTML page downloaded from WordPress needs a deep transformation:
Deleting WordPress elements
WordPress injects a huge amount of code that we don't need into a static site:
# Elementos a eliminar del HTML
REMOVE_SELECTORS = [
'link[rel="EditURI"]',
'link[rel="wlwmanifest"]',
'link[rel="pingback"]',
'meta[name="generator"]',
'script[src*="wp-includes"]',
'script[src*="wp-content"]',
'link[href*="wp-includes"]',
'link[href*="wp-content/plugins"]',
'#wpadminbar',
'.wp-block-spacer',
'noscript[id*="rocket"]', # WP Rocket artifacts
'style[id*="global-styles"]',
]
def clean_wp_elements(soup):
for selector in REMOVE_SELECTORS:
for element in soup.select(selector):
element.decompose()URL Rewriting
All internal URLs must point to the new domain and follow the new directory structure:
def rewrite_urls(soup, source_url, target_url):
# Reescribir href en enlaces
for a in soup.find_all('a', href=True):
a['href'] = a['href'].replace(source_url, target_url)
# Reescribir src en imágenes y scripts
for tag in soup.find_all(['img', 'script', 'source'], src=True):
tag['src'] = tag['src'].replace(source_url, target_url)
# Reescribir href en links CSS
for link in soup.find_all('link', href=True):
link['href'] = link['href'].replace(source_url, target_url)Slug normalization
WordPress generates slugs with characters that can cause problems in file systems or URLs. The sanitizer normalizes slugs by removing special characters, accents, and problematic sequences:
import unicodedata
import re
def sanitize_slug(slug):
# Normalizar Unicode (NFD) y eliminar diacríticos
slug = unicodedata.normalize('NFD', slug)
slug = slug.encode('ascii', 'ignore').decode('ascii')
# Convertir a minúsculas y reemplazar espacios
slug = slug.lower().strip()
slug = re.sub(r'[^a-z0-9\-]', '-', slug)
slug = re.sub(r'-+', '-', slug)
slug = slug.strip('-')
return slugEvery time a slug is modified, an entry is automatically generated in the file _redirects so that the original URL still works.
Phase 4: Asset download
The images and other static resources that WordPress serves from wp-content/uploads/ they must be downloaded and reorganized in the local structure:
# Estructura de assets resultante
# wp-content/uploads/2020/03/imagen.jpg
# → assets/images/posts/2020/03/imagen.jpg
def download_assets(soup, output_dir, session):
for img in soup.find_all('img', src=True):
src = img['src']
if 'wp-content/uploads' in src:
# Calcular ruta local
path_part = src.split('wp-content/uploads/')[-1]
local_path = os.path.join(output_dir, 'assets/images/posts', path_part)
# Descargar si no existe
if not os.path.exists(local_path):
os.makedirs(os.path.dirname(local_path), exist_ok=True)
response = session.get(src)
with open(local_path, 'wb') as f:
f.write(response.content)
# Actualizar la referencia en el HTML
img['src'] = f'/assets/images/posts/{path_part}'In Red Orbita this meant downloading more than 2 GB of images. The assets module implements parallel downloads with concurrent.futures and a local cache to avoid re-downloading images on successive runs.
Phase 5: Directory Structure
WordPress uses formatted URLs /YYYY/MM/slug/ that we want to preserve for SEO. The final structure of the static site replicates this scheme:
output/
├── index.html # Página principal
├── sitemap.xml # Sitemap para buscadores
├── search-index.json # Índice de búsqueda
├── _redirects # Redirecciones Cloudflare
├── _headers # Cabeceras de seguridad
├── assets/
│ ├── css/
│ │ ├── main.css
│ │ └── posts.css
│ ├── js/
│ │ ├── main.js
│ │ └── search.js
│ └── images/
│ └── posts/
│ ├── 2015/01/imagen1.jpg
│ ├── 2020/03/imagen2.png
│ └── unsplash/defaults/ # Imágenes por defecto
├── posts/
│ ├── 2015/
│ │ └── 01/
│ │ └── mi-primer-post/
│ │ └── index.html
│ └── 2025/
│ └── 04/
│ └── migrar-wordpress-a-sitio-estatico/
│ └── index.html
├── category/
│ ├── cloud-devops/
│ │ ├── index.html # Página 1
│ │ └── page/
│ │ ├── 2/index.html
│ │ └── 3/index.html
│ └── cybersecurity/
│ ├── index.html
│ └── page/...
└── page/
├── 2/index.html # Paginación global
├── 3/index.html
└── ...The key point is that each post becomes a index.html inside your directory, allowing URLs to work without a file extension and without the need for rewrite rules on the server.
Phase 6: Pagination
With more than 600 posts, pagination is essential. The paging module automatically generates:
- Global pagination:
/page/2/,/page/3/, etc. with 12 posts per page. - Pagination by category:
/category/cloud-devops/page/2/, etc. - Navigation between posts: "Previous" and "Next" links in each post.
Each pagination page includes the corresponding post cards, arranged chronologically from newest to oldest, with a total article count in the section title.
POSTS_PER_PAGE = 12
def generate_pagination(posts, output_dir, template):
total_pages = math.ceil(len(posts) / POSTS_PER_PAGE)
for page_num in range(1, total_pages + 1):
start = (page_num - 1) * POSTS_PER_PAGE
end = start + POSTS_PER_PAGE
page_posts = posts[start:end]
# Página 1 va en index.html, el resto en page/N/index.html
if page_num == 1:
path = os.path.join(output_dir, 'index.html')
else:
path = os.path.join(output_dir, f'page/{page_num}/index.html')
html = render_page(template, page_posts, page_num, total_pages)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
f.write(html)Phase 7: Redirects and SEO
Maintaining URLs is critical to not lose search engine positioning. For URLs that changed during slug normalization, we generate a file _redirects Cloudflare Pages compatible:
# _redirects (formato Cloudflare Pages)
/2020/03/mi-post-antiguo/ /posts/2020/03/mi-post-antiguo/ 301
/category/seguridad/ /category/cybersecurity/ 301
/?p=123 /posts/2020/03/mi-post-antiguo/ 301
/feed/ /sitemap.xml 301In addition to the redirect file, we automatically generate:
- sitemap.xml: with all site URLs, modification dates and priorities.
- search-index.json: a JSON index that feeds the site's JavaScript search engine, with title, URL, excerpt and categories of each post.
- Structured Data (JSON-LD)- Each post includes structured metadata for Google (BlogPosting, BreadcrumbList).
- Open Graph and Twitter Cards– Social sharing meta tags with image, title and description.
Phase 8: Safety headers
One of the advantages of a static site is that the attack surface is drastically reduced. Even so, we configure strict security headers in the file _headers from Cloudflare Pages:
# _headers
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; frame-src https://giscus.app https://www.youtube.com https://www.youtube-nocookie.com
Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadPhase 9: Validation
Before deploying, the validation module automatically verifies that the export is correct:
# Validaciones que ejecuta el módulo validator
def validate_export(output_dir, source_url):
errors = []
# 1. Verificar que cada post tiene index.html
for post_dir in glob.glob(f'{output_dir}/posts/*/*/*/'):
if not os.path.exists(os.path.join(post_dir, 'index.html')):
errors.append(f'Missing index.html: {post_dir}')
# 2. Verificar que no quedan referencias al dominio WordPress
for html_file in glob.glob(f'{output_dir}/**/*.html', recursive=True):
with open(html_file) as f:
content = f.read()
if source_url in content:
errors.append(f'WP URL found in: {html_file}')
# 3. Verificar que las imágenes referenciadas existen
for html_file in glob.glob(f'{output_dir}/**/*.html', recursive=True):
soup = BeautifulSoup(open(html_file).read(), 'html.parser')
for img in soup.find_all('img', src=True):
if img['src'].startswith('/'):
local_path = os.path.join(output_dir, img['src'].lstrip('/'))
if not os.path.exists(local_path):
errors.append(f'Missing image: {img["src"]} in {html_file}')
# 4. Verificar sitemap.xml
if not os.path.exists(f'{output_dir}/sitemap.xml'):
errors.append('Missing sitemap.xml')
return errorsDeployment to Cloudflare Pages
With the static site generated and validated, deployment to Cloudflare Pages is trivial:
# Inicializar repositorio Git
cd output/
git init
git add -A
git commit -m "feat(site): initial static export from WordPress"
# Añadir remote y push
git remote add origin git@github.com:tu-org/tu-sitio.github.io.git
git push -u origin mainIn Cloudflare Pages we configure:
- Repository- We connect the GitHub repository.
- branch:
main. - build command: none (the site is already built).
- Output directory:
/(the root of the repository). - Custom domain: we add the domain and configure the DNS records (CNAME).
Cloudflare Pages provides free SSL, global CDN, automatic deployments with every push to GitHub, and previews of every pull request. All at no cost for static sites.
Cloudflare Workers for advanced redirects
Cloudflare Pages supports a file _redirects for static redirects, but it has important limitations:
- Limit of 2000 rules (on the free plan) — with more than 600 posts and legacy URLs, we fell short.
- Does not support query strings– Formatted WordPress URLs
?p=123cannot be redirected with_redirects. - No conditional logic: You cannot do partial matching, URL normalization or dynamic fallback.
The solution was to create a Cloudflare Worker dedicated that intercepts all traffic before it reaches Pages and manages redirects programmatically.
Worker Architecture
The worker red-orbita-redirects It is deployed as a Worker Route that intercepts all traffic from red-orbita.com/* and www.red-orbita.com/*:
# wrangler.toml
name = "red-orbita-redirects"
main = "redirects-worker.js"
compatibility_date = "2026-04-26"
routes = [
{ pattern = "red-orbita.com/*", zone_name = "red-orbita.com" },
{ pattern = "www.red-orbita.com/*", zone_name = "red-orbita.com" }
]The three types of redirects
The worker manages three types of legacy URLs:
1. Slug redirects (652 entries)
WordPress served posts directly under /slug/. At the static site, the structure is /posts/YYYY/MM/slug/. The worker has a Map with the 652 redirects:
const REDIRECT_MAP = new Map([
["/apache-tomcat-balanceo-de-carga-y-alta-disponibilidad",
"/posts/2015/04/apache-tomcat-balanceo-de-carga-y-alta-disponibilidad/"],
["/bloquear-ataques-fuerza-bruta-con-wazuh",
"/posts/2020/05/bloquear-ataques-fuerza-bruta-con-wazuh/"],
// ... 650 entradas más
]);2. WordPress ?p=ID redirects (619 entries)
WordPress allows you to access any post by its numerical ID with ?p=123. Many external links (Twitter, forums, etc.) use this format. The file _redirects by Cloudflare Pages does not support query strings, so the worker is essential:
const PID_MAP = new Map([
["31", "/posts/2010/05/acceso-a-un-sistema-con-backtrack-4/"],
["425", "/posts/2011/02/analisis-de-trafico-con-wireshark/"],
// ... 617 entradas más
]);3. Generic patterns
Other legacy WordPress URLs are captured with conditional logic:
// ?feed=* → página principal
// ?paged=* → /categorias/
// /tag/* → /categorias/Request flow
Petición → Cloudflare CDN → Worker (red-orbita-redirects)
│
├─ ¿?p=ID? → 301 a /posts/YYYY/MM/slug/
├─ ¿/slug sin /posts/? → 301 a /posts/YYYY/MM/slug/
├─ ¿?feed=*? → 301 a /
├─ ¿/tag/*? → 301 a /categorias/
└─ No match → fetch(request) → Cloudflare Pages (origin)If no rule matches, the worker passes the request directly to the origin (Cloudflare Pages) with fetch(request), transparently.
Worker Deployment
cd workers/
npx wrangler deployThe worker is deployed independently of the static site. Every time a new redirect is added (for example, when creating a new post whose slug changed), the REDIRECT_MAP in the worker and it is redeployed.
Why a Worker and not just _redirects
| Feature | _redirects | Cloudflare Worker |
|---|---|---|
Query strings (?p=123) | Not supported | Supported |
| Rules limit | 2000 (free) | No practical limit |
| Conditional logic | No | Yes (JavaScript) |
| Added latency | 0ms | ~1-2 ms (edge) |
| Cost | Free | 100k req/day free |
In practice, we use both: the file _redirects for simple redirects that Cloudflare Pages can resolve without going through the worker, and the worker for ?p=ID and slug redirects that don't fit the limit or need logic.
Complete execution
With everything configured, the migration is executed with a single command:
# Exportación completa
export WP2STATIC_PASSWORD="tu_app_password"
python3 wp2static.py --config config.yaml
# Solo validación (sin exportar)
python3 wp2static.py --config config.yaml --validate-only
# Modo dry-run (simula sin escribir)
python3 wp2static.py --config config.yaml --dry-run
# Reanudar exportación interrumpida
python3 wp2static.py --config config.yaml --resumeThe entire process for Red Orbita (600+ posts, 2 GB of images) takes approximately 2 hours to complete from scratch.
Results
After the migration, the numbers speak for themselves:
- Charging time: from ~2.5 seconds (WordPress with cache) to ~400 ms (static on CDN).
- TTFB: from ~800 ms to ~50 ms.
- Monthly cost: from ~15 EUR/month (VPS) to 0 EUR (Cloudflare Pages free tier).
- Attack surface: from PHP + MySQL + 12 plugins + WordPress core to zero dynamic components.
- Maintenance: from weekly updates to practically zero.
- PageSpeed score: from 72 to 98 (mobile).
SEO was kept intact thanks to 301 redirects (both in _redirects as in Cloudflare Worker) for the URLs that changed, including the 619 URLs formatted ?p=ID that only the worker can redirect, and to the maintenance of the structure /YYYY/MM/slug/ for the rest.
Lessons learned
After migrating a blog that is more than a decade old with hundreds of posts, these are the most important lessons:
- Don't trust export plugins for large sites. They fail with timeouts, leave assets undownloaded, and generate dirty HTML.
- Redirects are essential. Any URL that changes without a 301 is traffic and ranking lost forever.
- Validate before deploying. A single broken link to an image or post is invisible until a user reports it (or never reports it).
- Crawl from a clean IP. If your WordPress has security plugins (Wordfence, etc.), they can block the crawler due to excessive requests.
- Save the original WordPress until the static site has been running without problems for at least a month. You will need to refer back to it to correct incorrect transformations.
- Automate everything possible. Each manual phase is a source of errors. The script should be able to run from start to finish without human intervention.
Conclusion
Migrating a mature WordPress blog to a static site is a project that requires planning and proper tools, but the result is absolutely worth it. A faster, safer, cheaper and easier to maintain site. If your blog is primarily content (posts, documentation, writeups), there is no technical reason to continue running a full LAMP stack.
The code of wp2static It is available as an open source project at GitHub for those who want to use it or adapt it to their needs. Happy hacking!
:wq!
Comments