Skip to header Skip to main navigation Skip to main content Skip to footer

Main navigation

  • Home
  • Drupal Theming
  • Web Designer In Austin
  • Drupal Development Portfolio (opens in new tab)
  • Blog
  • Videos
  • Contact
Web Designer In Austin
Designing Drupal, Defining Distinction

Drupal and Cloudflare Caching: Long Edge TTLs with Reliable Purging

Alaa Haddad, professional Drupal developer based in Austin, TX   Drupal Care
  10:38 PM CDT, Mon September 14, 2026
Share

Cache max-age and cache purging are two different instructions, and a fast, accurate Drupal site needs both: long lifetimes at the Cloudflare edge for speed, and a reliable purge on content change so nobody sees a stale page.

Almost every "why is my Drupal site still showing the old text?" question comes from treating those two things as one setting. They are not. Max-age answers how long may this response stay fresh. A purge answers delete this copy now, because it changed. Get the split right and you can cache anonymous pages at the edge for a month without ever serving stale content.

The three cache layers in a Drupal + Cloudflare stack

For an anonymous visitor, a request can be answered at any of three points before Drupal is asked to build anything:

Visitor browser
  |
Cloudflare edge cache
  |
Drupal origin (internal page cache / dynamic page cache / render cache)
  • Browser cache — private to one visitor. You cannot purge it. Whatever Cache-Control: max-age you sent is what that browser will honour.
  • Cloudflare edge cache — shared, and you can purge it via the API.
  • Drupal's own caches — page cache for anonymous users, dynamic page cache for authenticated, plus the render cache, all keyed by cache tags and contexts.

The one that catches people out is the first. A Cloudflare purge does nothing to a page already sitting in a visitor's browser. That single fact drives the whole configuration below.

What max-age actually sets

In Drupal, the browser and proxy cache maximum age is a real config key with an integer value. Core ships it at 0 — verified in core/modules/system/config/install/system.performance.yml on Drupal 11.4.6. The Performance UI exposes a fixed list of values; anything else you set in settings.php:

$config['system.performance']['cache']['page']['max_age'] = 3600;

That header goes to both browsers and shared caches. Which is exactly why a one-year value there is a trap for HTML. Long browser caching is right for versioned CSS, JavaScript, fonts and images. It is wrong for a page an editor will change on Tuesday.

The pattern that works: short at the browser, long at the edge

Split the two audiences. Send a modest max-age from the origin, then let a Cloudflare Cache Rule hold the response at the edge for far longer, and purge the edge when Drupal says the content changed.

Recommended starting configuration for an anonymous-heavy Drupal content site
LayerSettingWhy
Drupal max_age3600 (1 hour) or 86400 (1 day)Safe if a purge ever fails; short enough that a browser self-corrects.
Cloudflare Edge Cache TTL1 month to 1 yearPurgeable, so a long value costs nothing.
Cloudflare Browser Cache TTLRespect originDo not let the CDN extend HTML caching in browsers behind your back.
Purge triggerDrupal cache-tag invalidationFires exactly when a node, block or menu changes.

The shorthand is cache long, purge on change. The long TTL buys the performance; the purge buys the accuracy. Neither works well alone.

Using s-maxage to say it in one header

If you would rather express the split at the origin than in a Cloudflare rule, s-maxage applies only to shared caches:

Cache-Control: public, max-age=300, s-maxage=31536000

Browsers treat it as fresh for five minutes; Cloudflare treats it as fresh for a year. Apply it narrowly — to known-public anonymous routes — never globally.

If your site is slow for reasons that have nothing to do with the CDN, the groundwork is elsewhere: see the five changes that make any site faster before you tune TTLs. And if you would rather someone else owned the whole caching layer, that is exactly what Drupal services covers.

Per-route control from Drupal

When only some routes should get the aggressive header, do it in a response event subscriber, not in hook_page_attachments_alter() — that hook is for attachments, not HTTP headers.

<?php

declare(strict_types=1);

namespace Drupal\my_module\EventSubscriber;

use Drupal\Core\Session\AccountProxyInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

final class ResponseCacheControlSubscriber implements EventSubscriberInterface {

  public function __construct(
    private readonly AccountProxyInterface $currentUser,
  ) {}

  public static function getSubscribedEvents(): array {
    return [KernelEvents::RESPONSE => ['onResponse', -10]];
  }

  public function onResponse(ResponseEvent $event): void {
    if (!$event->isMainRequest() || $this->currentUser->isAuthenticated()) {
      return;
    }
    $request = $event->getRequest();
    if ($request->getMethod() !== 'GET') {
      return;
    }
    if (!in_array((string) $request->attributes->get('_route'), ['<front>', 'entity.node.canonical'], TRUE)) {
      return;
    }
    $response = $event->getResponse();
    if (!$response->isSuccessful() || $response->headers->has('Set-Cookie')) {
      return;
    }
    $response->headers->set('Cache-Control', 'public, max-age=300, s-maxage=31536000');
  }

}

Note the guard clauses. Anonymous only, GET only, named routes only, successful responses only, and never when a Set-Cookie header is present. Every one of those exists to stop a personalised response reaching a shared cache. On a Drupal 12 target, remember getSubscribedEvents() must declare its : array return type or Symfony 7 fatals at container build.

What must never be cached at the edge

A Cloudflare Cache Rule that ignores origin headers too broadly will happily cache something Drupal marked private. Exclude, at minimum:

  • /admin/* and /user/*, including /user/login and /user/logout
  • Cart and checkout routes on Commerce sites
  • Webform confirmation pages that echo submitted values
  • Anything rendered per-user, per-role or per-session

Scope the rule to the paths you know are public rather than caching everything and carving out exceptions. Fail closed on data access — the cost of a wrong guess here is one visitor seeing another's page.

Choosing the purge integration

I maintain the Cloudflare Purge module, so I will be plain about where it fits. Read from its cloudflare_purge.info.yml: core_version_requirement: ^10 || ^11 || ^12, php: ^8.1, and its only dependency is drupal:system.

That single line is the whole design decision. It does not require the purge module stack, and it uses the Guzzle client already in core rather than raw cURL. It handles purge-everything, purge by URL (Cloudflare caps a request at 100 URLs), by cache tag, by prefix and by hostname; it can purge automatically when Drupal invalidates a cache tag; it queues requests for cron-time batching; and it ships Drush 13 commands. Credentials can live in the Key module or be overridden from settings.php so they never enter config or git.

It is the right choice when you are on a Cloudflare-partner host without dashboard access, or when you want the invalidation without adopting the full purge ecosystem. If you already run purge with several queue backends, stay there.

How to verify it, not assume it

curl -sI https://example.com/about-us | grep -iE 'cache-control|cf-cache-status|age|set-cookie'
curl -sI https://example.com/user/login | grep -i cf-cache-status

CF-Cache-Status: HIT means Cloudflare served it; MISS means it went to origin. Immediately after a purge, a MISS is correct and expected. The second command is the one people skip: confirm your sensitive routes are not being cached before you widen the rule.

Common questions

Does purging Cloudflare change my max-age?

No. Purge deletes copies that already exist. Max-age governs how long the next copy is allowed to stay fresh. Changing one never changes the other.

Why do I still see the old page after a successful purge?

Almost always your own browser cache. Purge cleared the edge; your browser is still honouring the max-age it was given earlier. Test in a private window or with curl, which has no cache.

Can I set a one-year max-age in Drupal and be done?

For static assets, yes. For HTML, no — you would be handing every visitor a copy you have no way to recall. Keep HTML short at the browser and long at the edge.

Do I need cache tags configured for this to work?

Drupal emits cache tags automatically for nodes, blocks, menus, views and config. The purge integration listens for those invalidations. You only need custom tags when you render something Drupal cannot see is dependent on it.

What if the purge API call fails?

This is why the origin max-age stays modest. A failed purge with a one-hour max-age means an hour of staleness at worst. A failed purge with a one-year max-age means a year. Design the failure path first.

Where to go next

Caching sits on top of everything else, so it is worth getting the layer underneath right too — how Drupal renders blocks and why they duplicate is a common source of pages that cache badly because they render twice.

If you want this configured and proven on your site rather than described, two routes: Drupal development services covers the module and event-subscriber work, and get in touch if you would rather start with a look at what your headers are doing today.

Cloudflare Purge
Drupal Caching
cache invalidation
Edge Cache TTL

Footer menu

  • About
  • Privacy Policy
  • Terms & Conditions
  • Flash Web Center, LLC (opens in new tab)
  • Drupal Care (opens in new tab)
  • Log in
  • Contact

Copyright © 2026 Flash Web Center, LLC | All rights reserved

Developed & Designed by Alaa Haddad