Home Linux & Systems Cybersecurity Cloud & DevOps Networks & Infrastructure SIEM & Monitoring DFIR & Threat Intel Development & Other All categories Projects About Tools

How to Migrate WordPress to Static Site with Python

Leer en espanol
How to Migrate WordPress to Static Site with Python

Table of contents

After more than a decade maintaining Red Orbita on WordPress, the decision to migrate to a 100% Static Site was not impulsive. WordPress is an extraordinary platform, ===

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:

text
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.txt

Each 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:

yaml
# 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).

python
# 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 pages

Key aspects of crawling:

  • Persistent session with retries: we use requests.Session with a HTTPAdapter configured with Retry to 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:

python
# 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:

python
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:

python
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 slug

Every 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:

python
# 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:

text
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.

python
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:

text
# _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                     301

In 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:

text
# _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; preload

Phase 9: Validation

Before deploying, the validation module automatically verifies that the export is correct:

python
# 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 errors

Deployment to Cloudflare Pages

With the static site generated and validated, deployment to Cloudflare Pages is trivial:

Bash
# 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 main

In 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=123 cannot 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/*:

TOML
# 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:

javascript
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:

javascript
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:

javascript
// ?feed=* → página principal
// ?paged=* → /categorias/
// /tag/* → /categorias/

Request flow

text
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

Bash
cd workers/
npx wrangler deploy

The 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_redirectsCloudflare Worker
Query strings (?p=123)Not supportedSupported
Rules limit2000 (free)No practical limit
Conditional logicNoYes (JavaScript)
Added latency0ms~1-2 ms (edge)
CostFree100k 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:

Bash
# 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 --resume

The 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