Algunas lecciones aprendidas con wp-discourse + cloudflare

Algunas lecciones específicas de mi configuración personal que he aprendido en los últimos meses con Discourse, wp-discourse y Cloudflare. Las comparto por si alguna resulta útil a alguien.

Entorno:

  • Discourse y WordPress alojados en instancias AWS EC2 separadas dentro del mismo VPC
  • La pila de WordPress es nginx con caché fastCGI + php8.3-fpm + redis + mariadb
  • Uso el plugin wp-discourse para vincular WP y Discourse e incrustar comentarios de Discourse debajo de las entradas del blog
  • Cloudflare actúa como proxy tanto para Discourse como para WP
  • WP utiliza APO

1) Hacer un pequeño truco con el VPC

Cuando desplegué todo por primera vez, me topé rápidamente con problemas porque Cloudflare intentaba limitar la velocidad o bloquear el tráfico de API que circulaba entre el servidor web y el servidor de Discourse. Pensé en intentar configurar reglas de exclusión, pero como ambos servidores están en el mismo VPC de AWS, fue mucho más sencillo simplemente añadir entradas en el archivo de hosts de cada uno para que el nombre de host de cada servidor apuntara a su dirección VPC en lugar de a su DNS público. Así ya no hay que preocuparse de que Cloudflare se meta entre medio de WP y Discourse. Un cambio pequeño, pero que reduce mucho los dolores de cabeza.

2) Gestionar una carrera entre wp-discourse y Apple News

Durante bastante tiempo, tras configurar Discourse con WordPress, tuve un problema intermitente y extraño: a veces —no a menudo, pero sí algunas veces— una entrada recién publicada en WordPress parecía mostrar a los visitantes el antiguo bloque de comentarios nativo de WordPress en lugar de los de Discourse. La entrada real estaba bien, con los comentarios de Discourse adecuados, pero la caché de borde de Cloudflare capturaba la entrada en el instante o dos antes de que los comentarios de Discourse se cargaran y retenía la página obsoleta.

No logré, por más que lo intenté, identificar un cómo o un por qué detrás de este comportamiento. Parecía aleatorio, sin ningún desencadenante aparente. Revisé los registros minuciosamente pero no encontré nada obvio.

Así que, atribuí el problema a la interacción entre las distintas capas de caché y usé chatGPT para escribir un pequeño mu-plugin que evitara el problema obligando a WP a gritar “NO CACHES ESTO” en las nuevas entradas antes de que se estableciera el vínculo con Discourse, y luego forzando una ronda adicional de limpieza de caché en el lado de CF una vez que se adjuntara el hilo de comentarios, para asegurarme absolutamente de que nada obsoleto permaneciera en la caché de CF.

discourse-cloudflare-purge.php
<?php
/**
 * Plugin Name: Discourse → Cloudflare Safe Cache for Comments
 * Description: Prevents caching of single-post HTML until Discourse linkage exists; also purges Cloudflare when linkage lands.
 * Author: Lee Hutchinson + ChatGPT
 * Version: 1.6.1
 *
 * Changelog:
 * 1.6.1 - Added timeout failsafe so that we're not emitting no-cache headers forever
 * 1.6.0 — ADD pre-linkage no-cache gate (template_redirect) so edge/origin never cache the WP-native-comments HTML.
 * 1.4.0 — Purge on added/updated postmeta + status transition; purge slash/no-slash variants.
 * 1.3.0 — Credentials via wp-config.php constants only.
 */

if (!defined('ABSPATH')) exit;

/** Resolve Cloudflare credentials from wp-config.php */
function scw_dcfp_get_creds(): array {
    $zone  = defined('SCW_CF_ZONE_ID')   ? trim((string) SCW_CF_ZONE_ID)   : '';
    $token = defined('SCW_CF_API_TOKEN') ? trim((string) SCW_CF_API_TOKEN) : '';
    return ['zone_id' => $zone, 'api_token' => $token];
}

/** Discourse linkage meta keys (filterable) */
function scw_dcfp_meta_keys(): array {
    $keys = ['discourse_topic_id', 'discourse_post_id', 'discourse_permalink'];
    return apply_filters('scw_dcfp_meta_keys', $keys);
}

/** Build URLs to purge for a post (slash + no-slash, plus home + RSS) */
function scw_dcfp_build_urls(int $post_id): array {
    $urls = [];
    $permalink = get_permalink($post_id);
    if ($permalink) {
        $urls[] = $permalink;
        $urls[] = (substr($permalink, -1) === '/') ? rtrim($permalink, '/') : trailingslashit($permalink);
    }
    $urls[] = home_url('/');
    $urls[] = get_bloginfo('rss2_url');
    $urls = array_unique(array_filter($urls));
    return apply_filters('scw_dcfp_urls', $urls, $post_id);
}

/** Minutes since publish (UTC) or null if unknown */
function scw_dcfp_minutes_since_publish(int $post_id): ?int {
    $post_time = get_post_time('U', true, $post_id);
    if (!$post_time) return null;
    return (int) floor((time() - (int)$post_time) / 60);
}

/** Low-level Cloudflare purge-by-URL */
function scw_dcfp_purge_urls(array $urls): void {
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '' || empty($urls)) return;

    $endpoint = sprintf('https://api.cloudflare.com/client/v4/zones/%s/purge_cache', $creds['zone_id']);
    $response = wp_remote_post($endpoint, [
        'headers' => [
            'Authorization' => 'Bearer ' . $creds['api_token'],
            'Content-Type'  => 'application/json',
        ],
        'body'    => wp_json_encode(['files' => array_values($urls)]),
        'timeout' => 10,
    ]);

    if (is_wp_error($response)) {
        error_log('[SCW CF Purge] WP_Error: ' . $response->get_error_message());
        return;
    }
    $code = (int) wp_remote_retrieve_response_code($response);
    if ($code < 200 || $code >= 300) {
        $body = wp_remote_retrieve_body($response);
        error_log(sprintf('[SCW CF Purge] HTTP %d. Response: %s', $code, $body));
    }
}

/** Common gate: only act when linkage meta key written & post is public */
function scw_dcfp_should_trigger($post_id, $meta_key, $meta_value): bool {
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '') return false;
    if (empty($meta_value)) return false;
    if (!in_array($meta_key, scw_dcfp_meta_keys(), true)) return false;
    return (get_post_status($post_id) === 'publish');
}

/** Purge on added/updated Discourse linkage meta */
function scw_dcfp_on_post_meta_change($meta_id, $post_id, $meta_key, $meta_value): void {
    if (!scw_dcfp_should_trigger($post_id, $meta_key, $meta_value)) return;
    $urls = scw_dcfp_build_urls((int) $post_id);
    scw_dcfp_purge_urls($urls);
}
add_action('added_post_meta',   'scw_dcfp_on_post_meta_change', 10, 4);
add_action('updated_post_meta', 'scw_dcfp_on_post_meta_change', 10, 4);

/** Purge on publish transition, if linkage meta already present */
function scw_dcfp_on_transition_post_status($new_status, $old_status, $post): void {
    if ($new_status !== 'publish' || !($post instanceof WP_Post)) return;
    foreach (scw_dcfp_meta_keys() as $k) {
        if (get_post_meta($post->ID, $k, true)) {
            $urls = scw_dcfp_build_urls((int) $post->ID);
            scw_dcfp_purge_urls($urls);
            break;
        }
    }
}
add_action('transition_post_status', 'scw_dcfp_on_transition_post_status', 10, 3);

/**
 * NEW: Pre-linkage no-cache gate.
 * Until any Discourse linkage meta exists on a published single post,
 * emit strong no-cache headers so neither origin (FastCGI) nor Cloudflare
 * can store the pre-linkage HTML that shows WP's native comment form.
 */
function scw_dcfp_prelinkage_nocache(): void {
    if (!is_singular('post')) return;

    $post = get_queried_object();
    if (!($post instanceof WP_Post) || get_post_status($post) !== 'publish') return;

    foreach (scw_dcfp_meta_keys() as $k) {
        if (get_post_meta($post->ID, $k, true)) return; // linkage exists; cache normally
    }

    // In wp-config.php you can set: define('SCW_CF_LINKAGE_GRACE_MIN', 10);
    $grace = defined('SCW_CF_LINKAGE_GRACE_MIN') ? (int) SCW_CF_LINKAGE_GRACE_MIN : 0;
    if ($grace > 0) {
        $mins = scw_dcfp_minutes_since_publish($post->ID);
        if ($mins !== null && $mins >= $grace) {
            // We’ve waited long enough; stop gating and let caching resume.
            // (Optionally, trigger one purge to refresh edge with whatever is current.)
            // scw_dcfp_purge_urls(scw_dcfp_build_urls((int)$post->ID));
            return;
        }
    }

    // Block caching for this response (origin + edge + browsers)
    if (!headers_sent()) {
        nocache_headers();                    // Cache-Control: no-store, no-cache, must-revalidate, etc.
        header('cf-edge-cache: no-cache');    // Extra hint for Cloudflare/APO
    }
    if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);

    // Optional debug: uncomment for a few publishes
    // error_log('[SCW CF Gate] Pre-linkage no-cache for post ' . $post->ID);
}
add_action('template_redirect', 'scw_dcfp_prelinkage_nocache', 0);

/** Optional: admin notice if not configured */
add_action('admin_notices', function () {
    if (!current_user_can('manage_options')) return;
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '') {
        echo '<div class="notice notice-error"><p><strong>SCW Discourse → Cloudflare:</strong> Missing credentials. Define <code>SCW_CF_ZONE_ID</code> and <code>SCW_CF_API_TOKEN</code> in <code>wp-config.php</code>.</p></div>';
    }
});

Ten en cuenta que este mu-plugin realiza operaciones de API con Cloudflare y, por lo tanto, requiere que se definan algunas constantes en wp-config con tu zona de CF (SCW_CF_ZONE_ID) y tu clave de API (SCW_CF_API_TOKEN). También podrías querer renombrar estas constantes.

Esto eliminó por completo el comportamiento problemático: cesó en el momento en que desplegué discourse-cloudflare-purge.php. Pero aún así me molestaba muchísimo. ¿Por qué estaba haciendo esto? Podía hacer que el comportamiento problemático reapareciera en unos pocos días si desactivaba el mu-plugin, por lo que la causa raíz seguía ahí.

Finalmente, tras analizar minuciosamente los registros, encontré el problema:

El sitio de WP que gestiono publica su contenido en Apple News usando este plugin, que se actualiza regularmente y ha funcionado genial durante años. Sin embargo, el proceso de publicación en Apple News se dispara inmediatamente al publicar una entrada y, a veces, ocurre el comportamiento que se muestra en el fragmento de registro de nginx a continuación. Este fragmento comienza con el evento “POST” desencadenado por el autor al pulsar el botón “Publicar” en WordPress:

Fragmento desordenado del registro de acceso de nginx
[22/Dec/2025:06:15:40 -0600] spacecityweather.com [-] [Poster IP] | POST /wp-admin/admin-ajax.php HTTP/2.0 200 Ref: "https://spacecityweather.com/wp-admin/post.php?post=53600&action=edit" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:40 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET / HTTP/2.0 200 Ref: "-" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /wp-admin/admin.php?page=stats&noheader&proxy&chart=admin-bar-hours-scale HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /wp-json/jetpack/v4/scan HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.197 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.183 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [-] [Discourse server IP] | POST /wp-json/wp-discourse/v1/update-topic-content HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.175 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 HTTP/2.0 301 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [MISS] [Discourse server IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/1.1 200 Ref: "-" UA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [-] [Discourse server IP] | GET /wp-content/uploads/2025/12/image-29.png HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [-] [Discourse server IP] | GET /wp-content/uploads/2025/12/change.jpg HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] [Discourse server IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/1.1 200 Ref: "-" UA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [MISS] 2a01:b747:3003:206::32 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] 57.103.65.182 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 HTTP/2.0 301 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] 57.103.65.181 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[Poster IP] es la dirección IP censurada del autor de la entrada de WP, y [Discourse server IP] es la dirección VPC del servidor de Discourse.

[HIT], [MISS] o [BYPASS] es el estado de acierto de la caché local fastcgi de nginx para esa solicitud y es un buen indicador auxiliar.

La URL de la nueva entrada es /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2.

En resumen, lo que muestran los registros es la creación de la entrada y, luego, antes de que el plugin de Discourse pueda hacer su trabajo, AppleNewsBot se lanza (desencadenado por la publicación de la entrada) y accede al enlace permanente recién publicado. (Puedes ver a Discourse construyendo la entrada y obteniendo las imágenes justo debajo, mientras que otros servidores de Apple News también obtienen copias.)

Esta carrera fue casi con toda seguridad la fuente de mis páginas obsoletas que aparecían en la caché de borde de CF, y la inconsistencia se debe a que a veces Discourse gana la carrera y la primera vez que la entrada llega a la caché ya tiene los comentarios adecuados, y a veces Apple News gana la carrera y la entrada no los tiene. El plugin de CF para WP tiene su propia lógica de limpieza de caché, pero parece que se activa antes que Apple News.

En cualquier caso, me siento mucho mejor sabiendo el por qué del problema. Estoy considerando enviar una solicitud de problema a los mantenedores del plugin de Apple News, pero no sé si este problema es lo suficientemente generalizado como para merecer la atención de los desarrolladores. Además, el mu-plugin (junto con asegurarse de que nginx sea inteligente con su propia caché) elimina por completo el problema para mí de todos modos.

3) Intentando tener mi pastel y comérmelo también al cachear respuestas ajax durante 60 segundos

El plugin wp-discourse tiene varias formas de gestionar la caché de comentarios en entradas/páginas de WP, pero para ajustarme a los requisitos del propietario del sitio opté por el método ajax para mostrar los comentarios. Es exactamente lo que quiero y funciona genial, mostrando nuevos comentarios en las entradas de WP casi tan pronto como se publican en el lado de Discourse.

Pero siempre tengo el espectro de un evento de alto tráfico acechando: este es un sitio de pronóstico del tiempo y, a veces, los pronósticos se vuelven virales. ¿Qué pasa si de repente me bombardean usuarios que ven comentarios incrustados y todas las solicitudes ajax que eso genera?

Resulta que podemos abusar un poco de Cloudflare y hacer un caché de corta duración de las respuestas ajax en el borde de Cloudflare, para ayudar a aliviar parte de esa carga potencial. Esto requiere otro mu-plugin para decirle a WP qué hacer y luego una regla de caché de Cloudflare para decirle a CF qué hacer.

discourse-rest-edge-cache.php
<?php
/**
 * Plugin Name: Discourse REST Edge Cache
 * Description: Sets cache headers on WP-Discourse REST responses so Cloudflare can cache them (~60s at edge) while browsers revalidate.
 * Version: 1.0.3
 * Author: Lee Hutchinson + ChatGPT
 */

if (!defined('ABSPATH')) exit;

add_filter('rest_post_dispatch', function($result, $server, $request){
    if (!($request instanceof WP_REST_Request)) return $result;

    // Match the WP-Discourse namespace at the start of the route
    $route = $request->get_route(); // e.g., /wp-discourse/v1/...
    $attrs = method_exists($request, 'get_attributes') ? (array) $request->get_attributes() : [];
    $ns    = $attrs['namespace'] ?? '';

    $is_discourse = ($ns === 'wp-discourse') || (is_string($route) && preg_match('#^/wp-discourse(?:/|$)#', $route));
    if (!$is_discourse) return $result;

    // Safe methods only
    $method = strtoupper($request->get_method() ?: 'GET');
    if ($method !== 'GET' && $method !== 'HEAD') return $result;

    // Don’t cache personalized/authenticated requests
    if (is_user_logged_in()
        || $request->get_header('authorization')
        || $request->get_header('x-wp-nonce')
        || $request->get_header('cookie')) {
        return $result;
    }

    // Cache only successful responses
    if (is_wp_error($result)) return $result;
    $status = ($result instanceof WP_HTTP_Response) ? (int) $result->get_status() : 200;
    if ($status < 200 || $status >= 400) return $result;

    // Edge TTL ~60s, browsers revalidate; SWR smooths refresh
    $server->send_header('Cache-Control', 'public, s-maxage=60, max-age=0, stale-while-revalidate=30');
    $server->send_header('Vary', 'Accept-Encoding');

    return $result;
}, 10, 3);

Esto hace que WP adjunte una cabecera Cache-Control a esas llamadas /wp-json/wp-discourse/v1/discourse-comments?post_id= que está realizando. El s-maxage=60 es donde establezco la duración que quiero que la respuesta permanezca en caché.

Cloudflare normalmente no cacheará este tipo de solicitudes, por lo que necesitamos animarlo suavemente a que lo haga con una regla de caché:

Algunas capturas de pantalla de reglas de caché de Cloudflare

Esta regla marca las rutas con /wp-json/wp-discourse/ como aptas para la caché y luego le dice a la caché de borde de CF que las retenga durante el tiempo que solicite el origen. En nuestro caso, estamos solicitando 60 segundos. (Tenemos que hacer esto con el plugin en lugar de usar ese bonito cuadro de “input time-to-live” en la tercera captura de pantalla porque el TTL mínimo que Cloudflare permite establecer para cuentas gratuitas es de 2 horas y para cuentas Pro es de 1 hora, y yo quiero algo mucho menor que eso.)

Pienso que esta caché de corta duración me ayudará un poco si una entrada de WP se vuelve viral y 100.000 personas empiezan a mirarla simultáneamente: CF se hará cargo de parte de la carga, mientras que aún me permitirá mantener la apariencia de tener comentarios de actualización casi en tiempo real.

Aparte de esos puntos —ninguno de los cuales es culpa de wp-discourse!—, CF y wp-discourse han sido muy fáciles de integrar. Soy un cliente feliz. (Y si soy honesto, en cierto modo he disfrutado de la excusa para hacer experimentos.)

2 Me gusta