Quelques leçons apprises avec wp-discourse + Cloudflare

Quelques leçons spécifiques à ma configuration personnelle que j’ai apprises ces derniers mois avec Discourse, wp-discourse et Cloudflare. Je les partage au cas où cela pourrait être utile à quelqu’un.

Environnement :

  • Discourse et WordPress hébergés sur des instances AWS EC2 distinctes dans le même VPC
  • La pile WordPress utilise nginx avec mise en cache fastCGI + php8.3-fpm + redis + mariadb
  • Utilisation du plugin wp-discourse pour lier WP et Discourse et intégrer les commentaires Discourse sous les articles de blog
  • Cloudflare fait office de proxy pour Discourse et WP
  • WP utilise APO

1) Tricher un peu avec le VPC

Lors du déploiement initial, j’ai rapidement rencontré des problèmes avec Cloudflare tentant de limiter le débit ou bloquer le trafic API entre le serveur web et le serveur Discourse. J’ai envisagé de définir des règles d’exclusion, mais comme les deux serveurs sont dans le même VPC AWS, il était beaucoup plus simple de créer des entrées dans le fichier hosts sur chacun d’eux pour pointer le nom d’hôte de chaque serveur vers son adresse VPC plutôt que vers son adresse DNS publique. Fini les soucis avec Cloudflare qui s’immisce entre WP et Discourse. Petit changement, gros gain de tranquillité d’esprit.

2) Gérer une course entre wp-discourse et Apple News

Depuis un certain temps après avoir configuré Discourse avec WordPress, je rencontrais un problème intermittent étrange : parfois — pas souvent, mais parfois — un nouvel article WordPress apparaissait aux visiteurs avec l’ancien bloc de commentaires natif de WordPress en bas au lieu de ceux de Discourse. L’article réel était correct, avec les bons commentaires Discourse, mais le cache edge de Cloudflare capturait l’article une ou deux secondes avant que les commentaires Discourse ne soient intégrés, et conservait cette page obsolète.

Je n’ai pas pu, pour l’amour de Dieu, identifier le comment ou le pourquoi de ce comportement. Cela semblait aléatoire, sans déclencheur apparent. J’ai parcouru les journaux mais n’ai rien trouvé d’évident.

Alors, j’ai attribué cela à l’interaction entre les différentes couches de cache et j’ai demandé à ChatGPT d’écrire un petit mu-plugin pour contourner le problème en forçant WP à crier « NE MISEZ PAS EN CACHE CELA » sur les nouveaux articles avant que le lien Discourse ne soit établi, puis en forçant une nouvelle purge du cache côté CF une fois le fil de commentaires attaché, pour être absolument certain qu’aucune donnée obsolète ne subsiste dans le cache de CF.

discourse-cloudflare-purge.php
<?php
/**
 * Nom du plugin : Discourse → Cloudflare Safe Cache for Comments
 * Description : Empêche la mise en cache du HTML des articles uniques tant que le lien Discourse n'existe pas ; purge également Cloudflare lorsque le lien est établi.
 * Auteur : Lee Hutchinson + ChatGPT
 * Version : 1.6.1
 *
 * Journal des modifications :
 * 1.6.1 - Ajout d'un mécanisme de sécurité par délai d'attente pour éviter d'émettre des en-têtes no-cache indéfiniment
 * 1.6.0 — AJOUT d'une porte de sécurité no-cache pré-lien (template_redirect) pour que l'edge/origin ne mettent jamais en cache le HTML des commentaires natifs de WP.
 * 1.4.0 — Purge lors de l'ajout/mise à jour de postmeta et de la transition de statut ; purge des variantes avec/sans barre oblique.
 * 1.3.0 — Identifiants uniquement via les constantes wp-config.php.
 */

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

/** Résoudre les identifiants Cloudflare depuis 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];
}

/** Clés de métadonnées de liaison Discourse (filtrables) */
function scw_dcfp_meta_keys(): array {
    $keys = ['discourse_topic_id', 'discourse_post_id', 'discourse_permalink'];
    return apply_filters('scw_dcfp_meta_keys', $keys);
}

/** Construire les URLs à purger pour un article (avec/sans barre oblique, plus accueil + 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 depuis la publication (UTC) ou null si inconnu */
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);
}

/** Purge Cloudflare bas niveau par 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. Réponse: %s', $code, $body));
    }
}

/** Porte de sécurité commune : agir uniquement lorsque la clé de métadonnées de liaison est écrite et que l'article est 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 lors de l'ajout/mise à jour des métadonnées de liaison Discourse */
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 lors de la transition vers la publication, si les métadonnées de liaison sont déjà présentes */
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);

/**
 * NOUVEAU : Porte de sécurité no-cache pré-lien.
 * Tant qu'aucune métadonnée de liaison Discourse n'existe sur un article unique publié,
 * émettre des en-têtes no-cache stricts pour que ni l'origin (FastCGI) ni Cloudflare
 * ne puissent stocker le HTML pré-lien affichant le formulaire de commentaires natif de WP.
 */
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; // liaison existe ; mise en cache normale
    }

    // Dans wp-config.php, vous pouvez définir : 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) {
            // Nous avons attendu assez longtemps ; arrêter la porte de sécurité et permettre la reprise de la mise en cache.
            // (Optionnellement, déclencher une purge pour rafraîchir l'edge avec ce qui est actuel.)
            // scw_dcfp_purge_urls(scw_dcfp_build_urls((int)$post->ID));
            return;
        }
    }

    // Bloquer la mise en cache pour cette réponse (origin + edge + navigateurs)
    if (!headers_sent()) {
        nocache_headers();                    // Cache-Control: no-store, no-cache, must-revalidate, etc.
        header('cf-edge-cache: no-cache');    // Indice supplémentaire pour Cloudflare/APO
    }
    if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);

    // Debug optionnel : décommenter pour quelques publications
    // error_log('[SCW CF Gate] No-cache pré-lien pour l'article ' . $post->ID);
}
add_action('template_redirect', 'scw_dcfp_prelinkage_nocache', 0);

/** Optionnel : notification d'administration si non configuré */
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> Identifiants manquants. Définissez <code>SCW_CF_ZONE_ID</code> et <code>SCW_CF_API_TOKEN</code> dans <code>wp-config.php</code>.</p></div>';
    }
});

Notez que ce mu-plugin effectue des appels API vers Cloudflare et nécessite donc la définition de certaines constantes dans wp-config avec votre zone CF (SCW_CF_ZONE_ID) et votre clé API (SCW_CF_API_TOKEN). Vous voudrez peut-être également renommer ces constantes.

Cela a complètement éliminé le comportement problématique — il a cessé dès que j’ai déployé discourse-cloudflare-purge.php. Mais cela m’a toujours beaucoup agacé. Pourquoi cela se produisait-il ? Je pouvais faire réapparaître le comportement problématique en quelques jours si je désactivais le mu-plugin, donc la cause racine était toujours là.

Finalement, après avoir examiné attentivement les journaux, j’ai trouvé le problème :

Le site WP que j’exploite publie son contenu sur Apple News en utilisant ce plugin, régulièrement mis à jour et qui fonctionne parfaitement depuis des années. Cependant, le processus de publication Apple News se déclenche immédiatement lors de la publication d’un article, et parfois, le comportement montré dans l’extrait de journal nginx ci-dessous se produit. Cet extrait commence par l’événement « POST » déclenché par l’auteur en cliquant sur le bouton « Publier » dans WordPress :

Extrait désordonné du journal d'accès nginx
[22/Dec/2025:06:15:40 -0600] spacecityweather.com [-] [IP de l'auteur] | 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] [IP de l'auteur] | 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] [IP de l'auteur] | 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] [IP de l'auteur] | 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 [-] [IP du serveur Discourse] | 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] [IP du serveur Discourse] | 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 [-] [IP du serveur Discourse] | 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 [-] [IP du serveur Discourse] | 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] [IP du serveur Discourse] | 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] [IP de l'auteur] | 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"

[IP de l'auteur] est l’adresse IP masquée de l’auteur de l’article WP, et [IP du serveur Discourse] est l’adresse VPC du serveur Discourse.

[HIT], [MISS] ou [BYPASS] indique l’état de la mise en cache du cache fastcgi local nginx pour cette requête et constitue un bon indicateur d’aide.

La nouvelle URL de l’article est /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2.

En bref, ce que le journal montre, c’est la création de l’article, puis, avant que le plugin Discourse ne puisse faire son travail, AppleNewsBot surgit (déclenché par la publication de l’article) et accède au permalien nouvellement publié. (Vous pouvez voir Discourse construire l’article et récupérer les images juste en dessous, car d’autres serveurs Apple News récupèrent également des copies.)

Cette course était presque certainement la source de mes pages obsolètes apparaissant dans le cache edge de CF, et l’incohérence s’explique par le fait que parfois Discourse gagne la course et que la première fois que l’article est mis en cache, il contient les bons commentaires, et parfois Apple News gagne la course et l’article n’en contient pas. Le plugin CF de WP possède sa propre logique de vidage du cache, mais il semble qu’elle se déclenche avant Apple News.

Quoi qu’il en soit, je me sens beaucoup mieux en connaissant le pourquoi du problème. Je envisage de soumettre une demande de problème aux mainteneurs du plugin Apple News, mais je ne suis pas sûr que ce problème soit suffisamment répandu pour justifier l’attention des développeurs. De plus, le mu-plugin (couplé à une configuration intelligente de la mise en cache par nginx) élimine complètement le problème pour moi de toute façon.

3) Essayer d’avoir son gâteau et de le manger en mettant en cache les réponses ajax pendant 60 secondes

Le plugin wp-discourse propose plusieurs méthodes pour gérer la mise en cache des commentaires sur les articles/pages WP, mais pour répondre aux exigences du propriétaire du site, j’ai opté pour la méthode ajax d’affichage des commentaires. C’est exactement ce que je voulais et cela fonctionne très bien, affichant les nouveaux commentaires sur les articles WP presque aussitôt qu’ils sont publiés côté Discourse.

Mais j’ai toujours l’ombre d’un événement à fort trafic qui plane — il s’agit d’un site de prévisions météorologiques, et parfois les prévisions font des étincelles. Que se passerait-il si j’étais soudainement submergé par des utilisateurs consultant les commentaires intégrés et toutes les requêtes ajax générées ?

Il s’avère que nous pouvons un peu abuser de Cloudflare et mettre en cache brièvement les réponses ajax à la périphérie de Cloudflare, afin de soulager une partie de cette charge potentielle. Cela nécessite un autre mu-plugin pour dire à WP quoi faire, puis une règle de cache Cloudflare pour dire à CF quoi faire.

discourse-rest-edge-cache.php
<?php
/**
 * Nom du plugin : Discourse REST Edge Cache
 * Description : Définit les en-têtes de cache sur les réponses REST WP-Discourse afin que Cloudflare puisse les mettre en cache (~60s à la périphérie) tandis que les navigateurs revalident.
 * Version : 1.0.3
 * Auteur : Lee Hutchinson + ChatGPT
 */

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

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

    // Correspondre l'espace de noms WP-Discourse au début de la route
    $route = $request->get_route(); // ex. /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;

    // Méthodes sûres uniquement
    $method = strtoupper($request->get_method() ?: 'GET');
    if ($method !== 'GET' && $method !== 'HEAD') return $result;

    // Ne pas mettre en cache les requêtes personnalisées/authentifiées
    if (is_user_logged_in()
        || $request->get_header('authorization')
        || $request->get_header('x-wp-nonce')
        || $request->get_header('cookie')) {
        return $result;
    }

    // Mettre en cache uniquement les réponses réussies
    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;

    // TTL Edge ~60s, navigateurs revalident ; SWR lisse le rafraîchissement
    $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);

Cela amène WP à attacher un en-tête Cache-Control à ces appels /wp-json/wp-discourse/v1/discourse-comments?post_id=. Le s-maxage=60 est l’endroit où je définis la durée pendant laquelle je veux que la réponse reste en cache.

Cloudflare ne met généralement pas en cache ce type de requête, nous devons donc l’encourager doucement à le faire avec une règle de cache :

Quelques captures d'écran de règles de cache Cloudflare

Cette règle marque les chemins contenant /wp-json/wp-discourse/ comme éligibles à la mise en cache, puis indique au cache edge de CF de les conserver aussi longtemps que l’origin le demande. Dans notre cas, nous demandons 60 secondes. (Nous devons le faire avec le plugin plutôt que d’utiliser la jolie case « input time-to-live » dans la troisième capture d’écran, car le TTL minimum que Cloudflare permet de définir pour les comptes gratuits est de 2 heures et pour les comptes Pro de 1 heure, et je veux beaucoup moins que cela.)

Je suppose que ce cache à courte durée aidera un peu si un article WP fait des étincelles et que 100 000 personnes commencent à le consulter simultanément — CF prendra un peu le relais, tout en me permettant de maintenir l’apparence de commentaires se mettant à jour presque en temps réel.

Hormis ces éléments — dont aucun n’est la faute de wp-discourse ! — CF et wp-discourse ont été très faciles à intégrer. Je suis un client heureux. (Et si je suis honnête, j’ai plutôt apprécié l’excuse pour bricoler !)

2 « J'aime »