Por qué abandonar WordPress
Después de más de una década manteniendo Red Orbita sobre WordPress, la decisión de migrar a 100% Static Site no fue impulsiva. WordPress es una plataforma extraordinaria, pero para un blog técnico con más de 600 posts el coste operativo se había vuelto insostenible:
- Superficie de ataque: PHP, MySQL, plugins, temas, xmlrpc.php, wp-login.php... cada componente es un vector. Mantener todo parcheado era un trabajo constante.
- Rendimiento: por mucho caché (W3 Total Cache, Varnish, Redis) que configures, un sitio estático servido desde un CDN siempre será más rápido.
- Coste: un VPS con MySQL, PHP-FPM, Nginx y backups automatizados no es barato. Cloudflare Pages es gratuito para sitios estáticos.
- Complejidad: actualizaciones de WordPress, de plugins, de PHP, de MySQL, certificados SSL, backups de base de datos... demasiadas piezas móviles para un blog personal.
El objetivo era claro: convertir los más de 600 posts en ficheros HTML estáticos, mantener las URLs originales para no perder posicionamiento SEO, y desplegarlo todo en Cloudflare Pages con coste cero.
Estrategia de migración
Existen varias aproximaciones para migrar WordPress a estático. Las más comunes son:
- Plugins de exportación (Simply Static, WP2Static plugin): generan una copia estática desde dentro de WordPress. Funcionan bien para sitios pequeños, pero con +600 posts suelen fallar por timeouts o memoria.
- Crawlers externos (wget, HTTrack): descargan el sitio completo siguiendo enlaces. El resultado requiere mucha limpieza manual.
- Script personalizado: control total sobre el proceso de crawling, transformación y estructura final.
Para Red Orbita elegimos la tercera opción: un script en Python llamado wp2static que automatiza todo el proceso. El script evolucionó desde un prototipo de 200 líneas hasta una herramienta modular de más de 2000 líneas, y está disponible como proyecto open source en GitHub.
Arquitectura de wp2static
El script se organiza en módulos independientes, cada uno responsable de una fase del proceso:
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.txtCada módulo se puede ejecutar de forma independiente para depurar problemas o re-ejecutar una fase concreta sin repetir todo el proceso.
Fase 1: Configuración
Toda la configuración se centraliza en un fichero YAML. Esto permite documentar exactamente qué parámetros se usaron en cada exportación y reproducir el proceso de forma determinista:
# 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"La autenticación soporta tres métodos: Application Passwords de WordPress (recomendado), autenticación por cookies con Playwright para sitios con 2FA, o un fichero de cookies exportado del navegador.
Fase 2: Crawling
El crawler utiliza un algoritmo BFS (Breadth-First Search) para recorrer todo el sitio WordPress siguiendo los enlaces internos. Además, parsea el sitemap.xml para descubrir URLs que podrían no estar enlazadas desde ninguna página (posts huérfanos, páginas antiguas).
# 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 pagesAspectos clave del crawling:
- Sesión persistente con reintentos: usamos
requests.Sessioncon unHTTPAdapterconfigurado conRetrypara manejar errores transitorios (429, 500, 502, 503). - Rate limiting: un delay configurable entre peticiones para no saturar el servidor WordPress.
- Modo resume: si el crawling se interrumpe, se puede retomar desde donde quedó gracias a un checkpoint que se guarda periódicamente.
- Recuperación de 404: las URLs que devuelven 404 se registran en un fichero aparte para decidir si se crean redirecciones o se eliminan.
En el caso de Red Orbita, el crawling de los más de 600 posts tardó aproximadamente 45 minutos con 5 peticiones concurrentes.
Fase 3: Transformación HTML
Esta es la fase más compleja y donde se concentra la mayor parte del trabajo. Cada página HTML descargada de WordPress necesita una transformación profunda:
Eliminación de elementos WordPress
WordPress inyecta una cantidad enorme de código que no necesitamos en un sitio estático:
# 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()Reescritura de URLs
Todas las URLs internas deben apuntar al nuevo dominio y seguir la nueva estructura de directorios:
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)Normalización de slugs
WordPress genera slugs con caracteres que pueden causar problemas en sistemas de ficheros o URLs. El sanitizer normaliza los slugs eliminando caracteres especiales, acentos y secuencias problemáticas:
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 slugCada vez que un slug se modifica, se genera automáticamente una entrada en el fichero _redirects para que la URL original siga funcionando.
Fase 4: Descarga de assets
Las imágenes y otros recursos estáticos que WordPress sirve desde wp-content/uploads/ deben descargarse y reorganizarse en la estructura local:
# 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}'En Red Orbita esto supuso descargar más de 2 GB de imágenes. El módulo de assets implementa descargas paralelas con concurrent.futures y un caché local para evitar re-descargar imágenes en ejecuciones sucesivas.
Fase 5: Estructura de directorios
WordPress utiliza URLs con formato /YYYY/MM/slug/ que queremos preservar para SEO. La estructura final del sitio estático replica este esquema:
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
└── ...El punto clave es que cada post se convierte en un index.html dentro de su directorio, lo que permite que las URLs funcionen sin extensión de archivo y sin necesidad de reglas de rewrite en el servidor.
Fase 6: Paginación
Con más de 600 posts, la paginación es imprescindible. El módulo de paginación genera automáticamente:
- Paginación global:
/page/2/,/page/3/, etc. con 12 posts por página. - Paginación por categoría:
/category/cloud-devops/page/2/, etc. - Navegación entre posts: enlaces "Anterior" y "Siguiente" en cada post.
Cada página de paginación incluye las tarjetas de los posts correspondientes, ordenadas cronológicamente de más reciente a más antigua, con un contador total de artículos en el título de la sección.
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)Fase 7: Redirecciones y SEO
Mantener las URLs es crítico para no perder el posicionamiento en buscadores. Para las URLs que cambiaron durante la normalización de slugs, generamos un fichero _redirects compatible con Cloudflare Pages:
# _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 301Además del fichero de redirecciones, generamos automáticamente:
- sitemap.xml: con todas las URLs del sitio, fechas de modificación y prioridades.
- search-index.json: un índice JSON que alimenta el buscador JavaScript del sitio, con título, URL, extracto y categorías de cada post.
- Structured Data (JSON-LD): cada post incluye metadatos estructurados para Google (BlogPosting, BreadcrumbList).
- Open Graph y Twitter Cards: metaetiquetas para compartir en redes sociales con imagen, título y descripción.
Fase 8: Cabeceras de seguridad
Una de las ventajas de un sitio estático es que la superficie de ataque se reduce drásticamente. Aun así, configuramos cabeceras de seguridad estrictas en el fichero _headers de 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; preloadFase 9: Validación
Antes de desplegar, el módulo de validación verifica automáticamente que la exportación es correcta:
# 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 errorsDespliegue en Cloudflare Pages
Con el sitio estático generado y validado, el despliegue en Cloudflare Pages es 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 mainEn Cloudflare Pages configuramos:
- Repositorio: conectamos el repositorio de GitHub.
- Branch:
main. - Build command: ninguno (el sitio ya está construido).
- Output directory:
/(la raíz del repositorio). - Dominio personalizado: añadimos el dominio y configuramos los registros DNS (CNAME).
Cloudflare Pages proporciona SSL gratuito, CDN global, despliegues automáticos con cada push a GitHub, y previews de cada pull request. Todo sin coste para sitios estáticos.
Ejecución completa
Con todo configurado, la migración se ejecuta con un solo comando:
# 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 --resumeEl proceso completo para Red Orbita (más de 600 posts, 2 GB de imágenes) tarda aproximadamente 2 horas en completarse desde cero.
Resultados
Después de la migración, los números hablan por sí solos:
- Tiempo de carga: de ~2.5 segundos (WordPress con caché) a ~400 ms (estático en CDN).
- TTFB: de ~800 ms a ~50 ms.
- Coste mensual: de ~15 EUR/mes (VPS) a 0 EUR (Cloudflare Pages free tier).
- Superficie de ataque: de PHP + MySQL + 12 plugins + WordPress core a cero componentes dinámicos.
- Mantenimiento: de actualizaciones semanales a prácticamente cero.
- PageSpeed score: de 72 a 98 (móvil).
El SEO se mantuvo intacto gracias a las redirecciones 301 para las URLs que cambiaron y al mantenimiento de la estructura /YYYY/MM/slug/ para el resto.
Lecciones aprendidas
Tras migrar un blog de más de una década con cientos de posts, estas son las lecciones más importantes:
- No confíes en los plugins de exportación para sitios grandes. Fallan con timeouts, dejan assets sin descargar y generan HTML sucio.
- Las redirecciones son imprescindibles. Cualquier URL que cambie sin un 301 es tráfico y posicionamiento que se pierde para siempre.
- Valida antes de desplegar. Un solo enlace roto a una imagen o un post es invisible hasta que un usuario lo reporta (o no lo reporta nunca).
- Haz el crawling desde una IP limpia. Si tu WordPress tiene plugins de seguridad (Wordfence, etc.), pueden bloquear el crawler por exceso de peticiones.
- Guarda el WordPress original hasta que el sitio estático lleve al menos un mes funcionando sin problemas. Necesitarás volver a consultarlo para corregir transformaciones incorrectas.
- Automatiza todo lo posible. Cada fase manual es una fuente de errores. El script debería poder ejecutarse de principio a fin sin intervención humana.
Conclusión
Migrar un blog WordPress maduro a un sitio estático es un proyecto que requiere planificación y herramientas adecuadas, pero el resultado merece absolutamente la pena. Un sitio más rápido, más seguro, más barato y más fácil de mantener. Si tu blog es principalmente contenido (posts, documentación, writeups), no hay ninguna razón técnica para seguir ejecutando un stack LAMP completo.
El código de wp2static está disponible como proyecto open source en GitHub para quien quiera utilizarlo o adaptarlo a sus necesidades. Happy hacking!
:wq!