Alcune lezioni specifiche sulla mia configurazione personale che ho imparato negli ultimi mesi con Discourse, wp-discourse e Cloudflare. Le condivido nel caso possano essere utili a qualcuno.
Ambiente:
- Discourse e WordPress ospitati su istanze AWS EC2 separate nella stessa VPC
- Stack WordPress: nginx con caching fastCGI + php8.3-fpm + redis + mariadb
- Utilizzo del plugin wp-discourse per collegare WP e Discourse e incorporare i commenti di Discourse sotto i post del blog
- Cloudflare fa da proxy sia per Discourse che per WP
- WP utilizza APO
1) Barare un po’ con la VPC
Quando ho inizialmente distribuito tutto, mi sono subito imbattuto in problemi con Cloudflare che tentava di limitare la velocità o bloccare il traffico API tra il server web e il server Discourse. Ho pensato di provare a configurare delle regole di esclusione, ma dato che entrambi i server si trovano nella stessa VPC AWS, è stato molto più semplice aggiungere voci nel file hosts su ciascuno, puntando il nome host di ogni server al suo indirizzo VPC invece che al suo indirizzo DNS pubblico. Non c’è più bisogno di preoccuparsi che Cloudflare metta il naso tra WP e Discourse. Un piccolo cambiamento, grande riduzione di mal di testa.
2) Gestire una corsa tra wp-discourse e Apple News
Da un po’ di tempo, dopo aver configurato Discourse con WordPress, avevo un problema intermittente strano: a volte — non spesso, ma a volte — un nuovo post di WordPress appariva ai visitatori con il vecchio blocco commenti nativo di WordPress in fondo invece di Discourse. Il post effettivo era corretto, con i commenti giusti di Discourse, ma la cache edge di Cloudflare catturava il post nell’istante o due prima che i commenti di Discourse venissero inseriti, e si atteneva alla pagina obsoleta.
Non sono riuscito, per tutta la vita, a identificare un come o un perché dietro questo comportamento. Sembrava casuale, senza alcun trigger apparente. Ho setacciato i log ma non ho visto nulla di ovvio.
Quindi, ho dato la colpa all’interazione tra tutti i diversi livelli di cache e ho usato ChatGPT per scrivere un piccolo mu-plugin per aggirare il problema, costringendo WP a urlare “NON CACHARE QUESTO” sui nuovi post prima che il collegamento a Discourse venisse stabilito, e poi forzando un’ulteriore pulizia della cache sul lato CF dopo che il thread dei commenti è stato allegato, per essere assolutamente sicuri che nulla di obsoleto rimanesse nella cache di 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>';
}
});
Nota che questo mu-plugin esegue operazioni API su Cloudflare e quindi richiede che alcune costanti siano definite in wp-config con il tuo ID zona CF (SCW_CF_ZONE_ID) e la tua chiave API (SCW_CF_API_TOKEN). Potresti anche voler rinominare queste costanti.
Questo mu-plugin ha completamente eliminato il comportamento problematico: è cessato nel momento in cui ho distribuito discourse-cloudflare-purge.php. Ma mi ha comunque dato molto fastidio. Perché stava facendo questo? Potevo far ricomparire il comportamento problematico entro pochi giorni se disattivavo il mu-plugin, quindi la causa principale era ancora lì.
Alla fine, dopo aver esaminato attentamente i log, ho trovato il problema:
Il sito WP che gestisco ripubblica i suoi contenuti su Apple News utilizzando questo plugin, che viene aggiornato regolarmente e ha funzionato benissimo per anni. Tuttavia, il processo di pubblicazione su Apple News si attiva immediatamente alla pubblicazione del post e, a volte, si verifica il comportamento mostrato nell’estratto del log nginx qui sotto. Questo estratto inizia con l’evento “POST” attivato dall’autore che preme il pulsante “Pubblica” in WordPress:
Estratto disordinato del log di accesso 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] è l’indirizzo IP oscurato dell’autore del post WP, e [Discourse server IP] è l’indirizzo VPC del server Discourse.
[HIT], [MISS] o [BYPASS] indica lo stato di hit della cache fastCGI locale di nginx per quella richiesta ed è un buon indicatore di aiuto.
L’URL del nuovo post è /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2.
In breve, il log mostra la creazione del post e poi, prima che il plugin Discourse possa fare la sua parte, AppleNewsBot si lancia (attivato dalla pubblicazione del post) e colpisce il permalink appena pubblicato. (Puoi vedere Discourse che costruisce il post e recupera le immagini subito sotto, mentre anche altri server Apple News ne recuperano copie.)
Questa corsa è quasi certamente la fonte delle mie pagine obsolete che apparivano nella cache edge di CF, e l’incoerenza è dovuta al fatto che a volte Discourse vince la corsa e la prima volta che il post finisce nella cache ha i commenti corretti, mentre a volte vince Apple News e il post non li ha. Il plugin CF di WP ha la sua logica di cancellazione della cache, ma sembra che si attivi prima di Apple News.
Comunque, mi sento molto meglio sapendo il perché del problema. Sto valutando di inviare una richiesta di issue ai manutentori del plugin Apple News, ma non sono sicuro che questo problema sia abbastanza diffuso da giustificare l’attenzione degli sviluppatori. Inoltre, il mu-plugin (unito all’assicurarsi che nginx sia intelligente riguardo alla propria cache) elimina completamente il problema per me.
3) Cercare di avere la torta e mangiarla anche caching le risposte ajax per 60 secondi
Il plugin wp-discourse ha diversi modi per gestire la cache dei commenti sui post/pagine di WP, ma per adattarmi ai requisiti del proprietario del sito ho optato per il metodo ajax per visualizzare i commenti. È esattamente quello che volevo e funziona benissimo, mostrando nuovi commenti sui post di WP quasi subito dopo che sono stati creati sul lato Discourse.
Ma ho sempre lo spettro di un evento ad alto traffico incombente: questo è un sito di previsioni meteorologiche e a volte le previsioni esplodono. Cosa succede se vengo improvvisamente sommerso da utenti che visualizzano commenti incorporati e tutte le richieste ajax che questo genera?
Si scopre che possiamo abusare un po’ di Cloudflare e fare una cache a breve termine delle risposte ajax al bordo di Cloudflare, per aiutare ad alleggerire parte di quel potenziale carico. Questo richiede un altro mu-plugin per dire a WP cosa fare, e poi una regola di cache Cloudflare per dire a CF cosa fare.
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);
Questo fa sì che WP alleghi un header Cache-Control a quelle chiamate /wp-json/wp-discourse/v1/discourse-comments?post_id= che sta effettuando. s-maxage=60 è dove imposto la durata che voglio che la risposta rimanga nella cache.
Cloudflare normalmente non cache questo tipo di richiesta, quindi dobbiamo incoraggiarlo gentilmente a farlo con una regola di cache:
Questa regola contrassegna i percorsi con /wp-json/wp-discourse/ come idonei alla cache e poi dice alla cache edge di CF di trattenerli per quanto tempo richiede l’origine. Nel nostro caso, chiediamo 60 secondi. (Dobbiamo farlo con il plugin invece di usare quella bella casella “input time-to-live” nella terza screenshot perché il TTL minimo che Cloudflare permette di impostare per gli account gratuiti è di 2 ore e per gli account Pro è di 1 ora, e io voglio molto meno di quello.)
Penso che questa cache a breve termine mi aiuterà un po’ se un post WP esplode e 100.000 persone iniziano a guardarlo simultaneamente: CF si farà carico di un po’ del carico, permettendomi comunque di mantenere l’aspetto di avere commenti in aggiornamento quasi in tempo reale.
Oltre a questi elementi — nessuno dei quali è colpa di wp-discourse! — CF e wp-discourse sono stati molto facili da integrare. Sono un cliente felice. (E se devo essere onesto, mi sono un po’ divertito a smanettare!)


