Algumas lições específicas da minha configuração que aprendi nos últimos meses com Discourse, wp-discourse e Cloudflare. Compartilhando caso alguma disso seja útil para alguém.
Ambiente:
- Discourse e Wordpress hospedados em instâncias AWS EC2 separadas na mesma VPC
- A pilha do Wordpress é nginx com cache via fastCGI + php8.3-fpm + redis + mariadb
- Uso o plugin wp-discourse para vincular WP e Discourse e incorporar comentários do Discourse abaixo dos posts do blog
- O Cloudflare atua como proxy tanto para o Discourse quanto para o WP
- O WP está usando APO
1) Burlar um pouco a VPC
Quando implementei as coisas inicialmente, rapidamente encontrei problemas com o Cloudflare tentando limitar a taxa ou bloquear o tráfego de API entre o servidor web e a caixa do Discourse. Pensei em tentar descobrir algumas regras de exclusão, mas como ambos os servidores estão na mesma VPC da AWS, foi muito mais fácil simplesmente criar entradas no arquivo de hosts em cada um para apontar o nome de host de cada servidor para seu endereço VPC em vez de seu endereço DNS público. Não há mais necessidade de se preocupar com o Cloudflare metendo o nariz entre o WP e o Discourse. Pequena mudança, grande redução de dores de cabeça.
2) Lidando com uma corrida entre wp-discourse e Apple News
Eu vinha tendo um problema intermitente estranho há bastante tempo após configurar o Discourse com o Wordpress, onde às vezes — não frequentemente, mas às vezes — um post recém-publicado no Wordpress aparecia para os visitantes com o bloco de comentários nativo do Wordpress no final, em vez do Discourse. O post real estava correto, com comentários apropriados do Discourse, mas o cache de borda do Cloudflare estava capturando o post no instante ou dois antes que os comentários do Discourse fossem carregados e mantinha a página desatualizada.
Não consegui, para minha vida, identificar um como ou um porquê por trás do comportamento. Parecia aleatório, sem nenhum gatilho aparente. Revirei os logs, mas não vi nada óbvio.
Então, atribuí isso à interação entre todas as diferentes camadas de cache e usei o chatGPT para escrever um pequeno mu-plugin para contornar o problema, forçando o WP a gritar “NÃO CACHE ISTO” em novos posts antes que o vínculo do Discourse seja estabelecido, e depois forçando uma rodada adicional de limpeza de cache no lado do CF após a thread de comentários ser anexada, para ter certeza absoluta de que nada desatualizado permanece no cache do 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>';
}
});
Observe que este mu-plugin faz chamadas de API ao Cloudflare e, portanto, requer que algumas constantes sejam definidas no wp-config com seu zone CF (SCW_CF_ZONE_ID) e chave de API (SCW_CF_API_TOKEN). Você também pode querer renomear essas constantes.
[/details]
Isso eliminou completamente o comportamento problemático — ele cessou no momento em que implementei discourse-cloudflare-purge.php. Mas ainda me incomodava muito. Por que isso estava acontecendo? Eu conseguia fazer o comportamento problemático voltar em poucos dias se desativasse o mu-plugin, então a causa raiz ainda estava por aí.
Eventualmente, após analisar os logs, encontrei o problema:
O site WP que eu executo repubblica seu conteúdo para o Apple News usando este plugin, que é atualizado regularmente e funcionou muito bem por anos. No entanto, o processo de publicação no Apple News é acionado imediatamente na publicação do post e, às vezes, ocorre o comportamento mostrado no trecho de log do nginx abaixo. Este trecho começa com o evento “POST” acionado pelo autor ao clicar no botão “publicar” no wordpress:
Trecho bagunçado do log de acesso do 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] é o endereço IP ofuscado do autor do post do WP, e [Discourse server IP] é o endereço VPC do servidor do Discourse.
[HIT], [MISS] ou [BYPASS] é o status de acerto do cache local do nginx fastcgi para aquela solicitação e é um bom indicador auxiliar.
A URL do novo post é /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2.
[/details]
Resumidamente, o que o log mostra é o post sendo criado e, em seguida, antes que o plugin do Discourse possa fazer sua parte, o AppleNewsBot entra em ação (acionado pela publicação do post) e acessa o permalink recém-publicado. (Você pode ver o Discourse construindo o post e buscando imagens logo abaixo, enquanto outros servidores do Apple News também fazem cópias.)
Essa corrida foi quase certamente a fonte das minhas páginas desatualizadas aparecendo no cache de borda do CF, e a inconsistência ocorre porque às vezes o Discourse vence a corrida e, na primeira vez que o post entra no cache, ele tem comentários apropriados, e às vezes o Apple News vence a corrida e o post não tem. O plugin CF do WP tem sua própria lógica de limpeza de cache, mas parece que ela está sendo acionada antes do Apple News.
De qualquer forma, me sinto muito melhor sabendo o porquê do problema. Estou considerando enviar uma solicitação de problema para os mantenedores do plugin do Apple News, mas não tenho certeza se esse problema é generalizado o suficiente para merecer atenção dos desenvolvedores. Além disso, o mu-plugin (juntamente com garantir que o nginx seja inteligente sobre seu próprio cache) elimina completamente o problema para mim de qualquer maneira.
3) Tentar ter o meu bolo e comê-lo também, cacheando respostas ajax por 60 segundos
O plugin wp-discourse tem várias maneiras de lidar com o cache de comentários em posts/páginas do WP, mas para atender aos requisitos do proprietário do site, optei pelo método ajax para exibir comentários. É exatamente o que eu quero e funciona muito bem, exibindo novos comentários em posts do WP quase tão rápido quanto são feitos no lado do Discourse.
Mas sempre tenho o espectro de um evento de alto tráfego pairando — este é um site de previsão do tempo e, às vezes, as previsões explodem. E se eu for de repente bombardeado por usuários visualizando comentários incorporados e todas as solicitações ajax que isso gera?
Acontece que podemos abusar um pouco do Cloudflare e fazer algum cache de curta duração de respostas ajax na borda do Cloudflare, para ajudar a aliviar parte dessa carga potencial. Isso requer outro mu-plugin para dizer ao WP o que fazer e, em seguida, uma regra de cache do Cloudflare para dizer ao CF o que fazer.
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);
Isso faz com que o WP anexe um cabeçalho Cache-Control a essas chamadas /wp-json/wp-discourse/v1/discourse-comments?post_id= que ele está fazendo. O s-maxage=60 é onde estou definindo a duração que quero que a resposta permaneça no cache.
O Cloudflare normalmente não cacheia esse tipo de solicitação, então precisamos encorajá-lo gentilmente a fazê-lo com uma regra de cache:
Essa regra marca os caminhos com /wp-json/wp-discourse/ como elegíveis para cache e, em seguida, diz ao cache de borda do CF para mantê-los pelo tempo que a origem solicitar. No nosso caso, estamos solicitando 60 segundos. (Temos que fazer isso com o plugin em vez de usar aquela boa caixa de “input time-to-live” na terceira captura de tela porque o TTL mínimo que o Cloudflare permite definir para contas gratuitas é de 2 horas e para contas Pro é de 1 hora, e eu quero muito menos que isso.)
Acho que esse cache de curta duração me ajudará um pouco se um post do WP explodir e 100.000 pessoas começarem a visualizá-lo simultaneamente — o CF assumirá um pouco da carga, enquanto ainda me permite manter a aparência de ter comentários atualizados quase em tempo real.
Além desses itens — nenhum dos quais é culpa do wp-discourse! — o CF e o wp-discourse foram muito fáceis de integrar. Sou um cliente feliz. (E, para ser honesto, eu meio que gostei da desculpa para mexer nas coisas!)


