Caching is one of the most important performance tools for a Drupal site, but it can also become confusing when several systems are involved. Drupal has its own cache system, browsers have their own cache behavior, and Cloudflare adds another caching layer between visitors and the origin server. On top of that, a module such as Cloudflare Purge handles invalidation, which is related to caching but not the same thing as cache duration.
The most important distinction is this: max-age controls how long content is allowed to stay fresh in cache, while Cloudflare Purge tells Cloudflare to delete cached content when it is no longer valid. They work together, but they solve different problems.
In a well-configured Drupal and Cloudflare setup, you usually want both. You want long cache lifetimes for performance, especially at the Cloudflare edge, and you want reliable cache purging so editors and visitors do not see outdated pages after content changes.
The Basic Request Flow
For an anonymous visitor, a typical Drupal request may pass through several layers before Drupal itself has to build the page.
Visitor browser
↓
Cloudflare edge cache
↓
Drupal origin serverEach layer can cache content differently. The visitor's browser may cache files or pages locally. Cloudflare may cache a copy at its edge network. Drupal may also cache anonymous page responses and rendered content internally.
This means there are usually three important caching layers to think about:
- Drupal internal cache: Drupal stores cached pages, render arrays, blocks, and other cacheable data.
- Cloudflare edge cache: Cloudflare stores cacheable responses closer to visitors.
- Browser cache: The visitor's browser stores cacheable responses locally.
The confusion often starts when these layers are treated as one system. They are connected, but they are not identical. A setting that affects browser cache may not affect Cloudflare in the same way. A Cloudflare purge may clear Cloudflare's cached copy, but it does not clear a page already cached inside a visitor's browser.
What Cache-Control max-age Means
The Cache-Control header tells browsers and shared caches how they may cache a response. A basic example looks like this:
Cache-Control: public, max-age=3600This means the response may be cached publicly and is considered fresh for 3,600 seconds, which is one hour.
Common values include:
3600seconds for one hour.86400seconds for one day.31536000seconds for one year.
In Drupal, the browser and proxy cache maximum age can be set from the Performance page, but the user interface only exposes certain values. If you need a custom value, you can override it in settings.php.
$config['system.performance']['cache']['page']['max_age'] = 31536000;That value tells Drupal to send a long max-age for cacheable page responses. In this example, the value is one year.
This may look attractive because it allows content to stay cached for a long time. However, it needs to be used carefully, especially for HTML pages. If a browser caches an HTML page for a very long time, Cloudflare Purge will not automatically remove that page from the visitor's local browser cache.
What Cloudflare Purge Does
Cloudflare Purge does not decide how long a page should be cached. It is not a TTL setting. It is an invalidation mechanism.
In plain language, Cloudflare Purge says:
This cached copy is no longer valid. Delete it from Cloudflare.After Cloudflare deletes the cached copy, the next visitor request goes back to Drupal. Drupal generates or serves a fresh response, and Cloudflare can cache the new version again.
This is why Cloudflare Purge pairs well with long edge cache lifetimes. The long cache lifetime gives you performance. The purge mechanism gives you freshness when content changes.
The pattern is often described as:
Cache long, purge on change.Why max-age and Purge Are Separate
A long max-age tells a cache:
You may keep this response fresh for a long time.A purge tells a cache:
Delete this response now because it changed.These are separate instructions. If you only use a long max-age without purging, visitors may see stale content until the cache expires. If you only purge but use very short cache lifetimes, the site may still work correctly, but you may not get the full performance benefit of Cloudflare's edge cache.
A strong Drupal and Cloudflare setup usually combines both:
- Reasonable Drupal cache headers.
- Long Cloudflare Edge Cache TTL for safe anonymous pages.
- Reliable Cloudflare purging when Drupal content changes.
Option 1: Set a Long max-age from Drupal
One approach is to set a long cache max-age directly in Drupal:
$config['system.performance']['cache']['page']['max_age'] = 31536000;This tells Drupal to send a long cache lifetime for cacheable page responses. It is simple, and it can work well in some cases.
However, this approach affects the headers Drupal sends from the origin. Depending on the rest of the configuration, browsers and shared caches may both use those headers. That is why I would be careful about setting a one-year max-age directly for HTML pages unless the site architecture is designed for it.
Long browser cache for CSS, JavaScript, images, fonts, and versioned assets is usually safer. Long browser cache for HTML pages requires more caution because HTML often changes when content editors update nodes, blocks, menus, or layout.
Option 2: Set a Long Edge Cache TTL in Cloudflare
A cleaner pattern is often to let Cloudflare keep public anonymous pages at the edge for a long time while keeping browser cache shorter.
In this approach, Drupal may send a moderate cache lifetime, while Cloudflare uses a longer Edge Cache TTL.
Drupal Cache-Control:
public, max-age=3600
Cloudflare Edge Cache TTL:
1 month or 1 year
Cloudflare Purge:
Clear the edge cache when Drupal content changesThis gives you strong performance at the CDN layer without forcing every visitor's browser to hold HTML pages for an extremely long time.
This is often the preferred setup for Drupal sites because Cloudflare is easier to purge centrally than individual visitor browsers. When Drupal content changes, the purge can clear the Cloudflare edge cache. The next request then gets fresh content from Drupal and repopulates Cloudflare.
A Practical Example
Imagine the page /about-us is cached by Cloudflare with a one-year Edge Cache TTL.
/about-us
Cloudflare Edge Cache TTL: 1 yearA visitor requests the page. Cloudflare has a cached copy and serves it quickly without asking Drupal to rebuild the page.
Later, an editor updates the About page in Drupal. Drupal invalidates the related cache tags. The Cloudflare Purge integration tells Cloudflare to delete the outdated cached version.
The next request works like this:
Visitor requests /about-us
↓
Cloudflare cache MISS
↓
Drupal serves fresh page
↓
Cloudflare stores the new version
↓
Visitor receives updated pageThis is the ideal behavior. Visitors get fast cached pages most of the time, but content updates still appear quickly after Drupal triggers the purge.
Be Careful Not to Cache Private or Personalized Pages
Long cache lifetimes are powerful, but they must be scoped carefully. You should not blindly cache every Drupal route at Cloudflare.
These paths usually should not be cached aggressively:
/admin/*/user/*/user/login/user/logout- Cart and checkout pages.
- Webform confirmation pages containing private data.
- Any page that displays user-specific or session-specific information.
For Drupal, this is especially important because anonymous pages and authenticated pages behave very differently. Public anonymous content can often be cached aggressively. Authenticated and personalized content must be handled much more carefully.
A dangerous Cloudflare rule can accidentally override protections from the origin. For example, if a Cloudflare Cache Rule ignores origin headers too broadly, it could cache something Drupal intended to keep private or uncacheable.
Cache Rules should be scoped to safe public pages and should exclude administrative, authenticated, preview, form, checkout, and other sensitive paths.
Recommended Drupal and Cloudflare Pattern
For many Drupal 11 content sites, I would start with a conservative and safe pattern.
In Drupal, use a reasonable page cache max-age:
$config['system.performance']['cache']['page']['max_age'] = 3600;That is one hour. For some sites, one day may also be reasonable:
$config['system.performance']['cache']['page']['max_age'] = 86400;Then use Cloudflare Cache Rules to give anonymous public pages a longer Edge Cache TTL.
Cloudflare Cache Rule:
Match safe public anonymous pages
Cache eligibility: Eligible for cache
Edge Cache TTL: 1 month or 1 year
Browser Cache TTL: Respect origin or keep shorterThen rely on Cloudflare Purge to invalidate changed content.
This keeps the setup flexible:
- Drupal controls normal cacheability and invalidation metadata.
- Cloudflare provides fast edge delivery.
- Browser cache does not need to hold HTML pages for too long.
- Cloudflare Purge clears changed content from the CDN layer.
Using s-maxage for Shared Caches
Another useful directive is s-maxage. This tells shared caches, such as CDNs and proxies, to use a different lifetime than browsers.
For example:
Cache-Control: public, max-age=300, s-maxage=31536000This means:
- Browsers may consider the response fresh for 300 seconds.
- Shared caches may consider the response fresh for 31,536,000 seconds.
This can be useful when you want short browser caching but long CDN caching.
The concept is strong, but in a Drupal site I would still be careful about applying this globally. You should only apply aggressive shared-cache headers to safe public responses.
Per-Route Control from Drupal
If different routes need different cache behavior, the clean Drupal approach is usually an event subscriber that modifies response headers under specific conditions.
I would not use hook_page_attachments_alter() as the main tool for HTTP cache headers. That hook is better suited for altering page attachments and render-related output. For response headers, use a Symfony response event subscriber.
Example service definition:
# my_module.services.yml
services:
my_module.response_cache_control_subscriber:
class: Drupal\my_module\EventSubscriber\ResponseCacheControlSubscriber
arguments: ['@current_user']
tags:
- { name: event_subscriber }Example event subscriber:
<?php
declare(strict_types=1);
namespace Drupal\my_module\EventSubscriber;
use Drupal\Core\Session\AccountProxyInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Sets custom Cache-Control headers for selected anonymous pages.
*/
final class ResponseCacheControlSubscriber implements EventSubscriberInterface {
public function __construct(
private readonly AccountProxyInterface $currentUser,
) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
KernelEvents::RESPONSE => ['onResponse', -10],
];
}
/**
* Sets cache headers for selected anonymous route responses.
*/
public function onResponse(ResponseEvent $event): void {
if (!$event->isMainRequest()) {
return;
}
if ($this->currentUser->isAuthenticated()) {
return;
}
$request = $event->getRequest();
if ($request->getMethod() !== 'GET') {
return;
}
$route_name = (string) $request->attributes->get('_route');
$cacheable_routes = [
'<front>',
'entity.node.canonical',
];
if (!in_array($route_name, $cacheable_routes, TRUE)) {
return;
}
$response = $event->getResponse();
if (!$response->isSuccessful()) {
return;
}
if ($response->headers->has('Set-Cookie')) {
return;
}
$response->headers->set(
'Cache-Control',
'public, max-age=300, s-maxage=31536000'
);
}
}This example only applies the custom header to anonymous main requests, only for GET requests, only for selected routes, only for successful responses, and only when there is no Set-Cookie header.
Those checks matter because cache headers should not be applied carelessly. Drupal's cacheability system is powerful, and custom response headers should respect the difference between public anonymous content and private or personalized content.
Where Cloudflare Cache Rules Fit
In many cases, Cloudflare Cache Rules are simpler than writing custom Drupal code. If the goal is to keep anonymous public pages cached longer at Cloudflare, a Cloudflare rule may be the cleaner solution.
For example, a Cloudflare rule could target public paths and exclude sensitive ones.
Include:
example.com/*
Exclude:
example.com/admin/*
example.com/user/*
example.com/user/login
example.com/user/logout
example.com/cart/*
example.com/checkout/*The exact rule depends on the site. A simple brochure site can usually be more aggressive than a membership site, ecommerce site, intranet, or application-style Drupal build.
The important point is that Cloudflare should cache the right things for the right amount of time. Cloudflare Purge should then clear those things when Drupal knows they changed.
What Happens When Drupal Content Changes
Drupal's caching system is built around cacheability metadata, including cache tags, cache contexts, and cache max-age. Cache tags are especially important for invalidation because they identify what cached content depends on.
When a node changes, Drupal can invalidate cache tags related to that node. A purge integration can use that invalidation to tell an external cache, such as Cloudflare, which cached items need to be cleared.
This is much better than waiting for a long TTL to expire naturally. Without purge, a one-year cache lifetime could mean stale content remains for a very long time. With purge, long TTLs become practical because changed content can be invalidated immediately.
That is the real value of pairing Cloudflare Purge with long Edge Cache TTLs.
A Good Mental Model
The easiest way to think about the pieces is:
Drupal cache metadata:
What is this response dependent on?
Drupal max-age:
How long should this response be considered fresh?
Cloudflare Edge Cache TTL:
How long may Cloudflare keep this response at the edge?
Browser Cache TTL:
How long may the visitor's browser keep this response?
Cloudflare Purge:
Delete this cached response because something changed.When these are configured well, the site can be both fast and accurate.
Common Mistakes to Avoid
One common mistake is assuming Cloudflare Purge changes max-age. It does not. Purge clears existing cached copies. TTL controls how long future cached copies may remain fresh.
Another mistake is setting a very long Drupal max-age and forgetting that browsers may cache the HTML page. If the visitor's browser keeps a stale page, purging Cloudflare will not necessarily fix what that visitor already has locally.
A third mistake is caching authenticated or personalized pages. This can create serious privacy and correctness problems. Admin pages, user pages, carts, checkout flows, and private responses should not be cached aggressively at the CDN layer.
A fourth mistake is creating a Cloudflare rule that is too broad. If Cloudflare is told to ignore origin cache headers for every path, it may cache content Drupal intentionally marked as private or uncacheable.
A safer strategy is to start conservative, confirm the behavior with headers, and then expand caching only for routes that are known to be safe.
How to Test the Setup
After configuring Drupal and Cloudflare, test with response headers.
You can use the browser's developer tools or a command-line request:
curl -I https://example.com/about-usLook for headers such as:
Cache-Control
CF-Cache-Status
Age
Set-CookieA Cloudflare cache HIT means Cloudflare served the response from its cache. A MISS means Cloudflare did not have a cached response and had to request it from the origin. After a purge, it is normal to see a MISS before Cloudflare stores the fresh response again.
Also check that sensitive pages are not cached. For example:
curl -I https://example.com/user/login
curl -I https://example.com/admin/contentThese should not be aggressively cached by Cloudflare.
Recommended Starting Point
For a typical Drupal 11 content site, I would usually start with this:
Drupal:
Page cache max-age: 3600 or 86400
Cloudflare:
Long Edge Cache TTL for anonymous public pages
Browser:
Respect origin or keep HTML browser cache shorter
Cloudflare Purge:
Enabled and tested for content changes
Exclusions:
Admin, user, login, logout, cart, checkout, forms, and private pathsThis gives you a balanced setup. It improves performance, avoids unnecessary origin requests, and still allows Drupal content updates to appear quickly after purge.
Final Summary
Cloudflare Purge and cache max-age are both important, but they do different jobs.
Max-age and Edge Cache TTL control how long content is allowed to remain fresh in cache. Cloudflare Purge deletes cached content when Drupal knows it has changed.
The strongest pattern for many Drupal sites is to keep browser caching reasonable, use a longer Cloudflare Edge Cache TTL for safe anonymous public pages, and rely on Cloudflare Purge to invalidate outdated content when editors update the site.
In short:
Drupal controls cacheability.
Cloudflare improves delivery speed.
Cloudflare Purge keeps the edge cache fresh.
Long TTLs provide performance.
Careful exclusions protect private and personalized pages.When these pieces are configured thoughtfully, Drupal and Cloudflare can work together very effectively. The result is a faster site, fewer origin requests, better scalability, and reliable content freshness when pages are updated.