Drupal Caching Layers Explained: Internal Cache, Redis, and Varnish

Drupal's performance story depends almost entirely on caching. A Drupal site without caching tuned correctly will struggle to serve a few dozen concurrent users. The same site with all three cache layers configured properly can handle hundreds. The three layers — Drupal's internal cache, Redis as a cache backend, and Varnish as a reverse proxy cache — serve different purposes and work together. This article explains what each layer does, how to configure it, and how to diagnose problems when the layers interact unexpectedly.


Layer 1: Drupal's Internal Cache System

Drupal's cache system is an abstraction over any number of storage backends. Out of the box it uses the database, but the architecture is designed to be replaced. Every cache operation goes through a CacheBackendInterface implementation.

Cache Bins

Drupal organises cached data into bins — named namespaces that can be backed by different storage mechanisms. The most important bins:

  • bootstrap — minimal data needed to bootstrap Drupal (config, module list)
  • config — compiled configuration objects
  • discovery — plugin discovery results (block definitions, entity types, field formatters)
  • render — rendered HTML fragments with their cache metadata
  • page — full cached HTML pages for anonymous users
  • data — arbitrary module data
  • menu — compiled menu tree structures

Cache Tags and Invalidation

Drupal's render cache is tag-based. Every cached item carries tags identifying what data it depends on. When that data changes, Drupal invalidates all cached items with matching tags — regardless of which bin they are in.

// Reading from the cache with tags
$cid = 'my_module:user_list';
if ($cache = \Drupal::cache('data')->get($cid)) {
    $data = $cache->data;
} else {
    $data = $this->computeExpensiveUserList();

    \Drupal::cache('data')->set(
        $cid,
        $data,
        Cache::PERMANENT,                        // No time-based expiry
        ['user_list', 'node_list', 'config:my_module.settings']  // Cache tags
    );
}

When any user entity is saved, Drupal invalidates the user_list tag. The cached data is gone on the next request. This is safer than TTL-based caching, where stale data can persist for minutes after a change.

Cache Contexts

Cache contexts tell Drupal that a cached item varies along some dimension. Common contexts:

// Vary per user role — one cached version per role
'#cache' => ['contexts' => ['user.roles']]

// Vary per URL
'#cache' => ['contexts' => ['url']]

// Vary per language
'#cache' => ['contexts' => ['languages:language_interface']]

// Multiple contexts — multiplicative
'#cache' => ['contexts' => ['user.roles', 'url.query_args']]

Every context you add multiplies the number of cache entries. user context (varies per individual user) generates one cache entry per user — which is usually not what you want. Use user.roles (varies per role combination) for most cases.

The Internal Page Cache and Dynamic Page Cache

Drupal core ships two page-level caches:

  • Internal Page Cache — caches full pages for anonymous users only. Does not understand cache contexts. Simple and fast for public pages.
  • Dynamic Page Cache — caches page fragments for authenticated users, respecting cache contexts. More sophisticated but generates more cache entries.

Both are enabled by default in Drupal 11. Disable the Internal Page Cache only if you have a full-page cache layer (Varnish/CDN) in front of Drupal — running both is redundant.


Layer 2: Redis as a Cache Backend

The default database cache works fine for small sites, but it puts cache reads on the same server as content queries. On high-traffic sites this causes contention. Redis is an in-memory key-value store that is orders of magnitude faster for cache reads than a relational database, and it keeps cache operations off the primary database.

Installing the Redis Module

composer require drupal/redis
drush en redis -y

You also need the PHP Redis extension (PhpRedis) or the Predis library. PhpRedis is strongly preferred for performance:

# Debian/Ubuntu
apt install php8.3-redis

# Alpine (Docker)
pecl install redis && docker-php-ext-enable redis

Configuring Redis as the Cache Backend

Add to web/sites/default/settings.php:

// Redis connection settings
$settings['redis.connection']['interface'] = 'PhpRedis';
$settings['redis.connection']['host']      = '127.0.0.1'; // or 'redis' in Docker
$settings['redis.connection']['port']      = 6379;
// $settings['redis.connection']['password'] = 'your-password';
// $settings['redis.connection']['base']     = 1; // Redis database number

// Set Redis as the default cache backend for all bins
$settings['cache']['default'] = 'cache.backend.redis';

// Keep bootstrap, discovery, and config in a fast local backend
// These bins are read on every request — keep them in-process
$settings['cache']['bins']['bootstrap'] = 'cache.backend.chainedfast';
$settings['cache']['bins']['discovery'] = 'cache.backend.chainedfast';
$settings['cache']['bins']['config']    = 'cache.backend.chainedfast';

// Enable the module before this configuration takes effect
$settings['container_yamls'][] = 'modules/contrib/redis/example.services.yml';

The chainedfast backend is a two-tier cache: it stores values in APCu (in-process memory, per-worker) first, falling back to the configured backend. For bootstrap/discovery/config this is a significant speedup — these bins are read on every request and their values change rarely.

Redis Cache Stampede Protection

When a cached item expires under load, multiple workers may simultaneously find a cache miss and all race to rebuild the same value. The Redis module handles this with an optional lock-based stampede protection. Configure it in settings.php:

// Use the database for locks, not Redis (Redis locks can fail silently on disconnect)
$settings['lock_backend'] = 'lock.backend.database';

Monitoring Redis

# Connect to Redis CLI
redis-cli

# Monitor hit/miss ratio
127.0.0.1:6379> INFO stats
# Look for: keyspace_hits, keyspace_misses
# Healthy ratio: > 90% hits

# List all Drupal cache keys (careful on large Redis instances)
127.0.0.1:6379> KEYS drupal:*

# Memory usage
127.0.0.1:6379> INFO memory

# Current operations per second
127.0.0.1:6379> INFO commandstats

Layer 3: Varnish as a Full-Page Cache

Redis speeds up Drupal's internal cache. Varnish sits in front of Nginx and caches complete HTTP responses — it serves cached pages without PHP running at all. For anonymous traffic (blog posts, landing pages, news articles), Varnish reduces server load dramatically.

How Varnish Interacts with Drupal

Drupal's Purge module listens for cache tag invalidations and sends purge requests to Varnish via HTTP. When a node is saved, Drupal tells Varnish to drop all cached responses that carried the node:42 cache tag. This keeps Varnish consistent with Drupal's content without simply flushing everything.

Required Modules

composer require drupal/varnish_purge drupal/purge
drush en varnish_purge purge purge_queuer_coretags purge_processor_cron -y

Varnish Configuration (VCL)

The critical parts of a Varnish 7 configuration for Drupal:

vcl 4.1;

import std;
import purge;

backend default {
    .host = "127.0.0.1";  # Nginx
    .port = "8080";
    .first_byte_timeout = 60s;
    .connect_timeout = 5s;
}

# Allow purge requests only from trusted IPs
acl purge_allowed {
    "127.0.0.1";
    "::1";
    # Add your Drupal server IP if running Varnish on a separate host
}

sub vcl_recv {
    # Handle PURGE requests from Drupal
    if (req.method == "PURGE") {
        if (!client.ip ~ purge_allowed) {
            return (synth(405, "Purge not allowed from this IP"));
        }
        return (purge);
    }

    # Handle BAN requests (used by Drupal for cache tag invalidation)
    if (req.method == "BAN") {
        if (!client.ip ~ purge_allowed) {
            return (synth(405, "Ban not allowed from this IP"));
        }
        # Drupal sends the cache tag in the Cache-Tags header
        ban("obj.http.Cache-Tags ~ " + req.http.Cache-Tags);
        return (synth(200, "Ban added"));
    }

    # Do not cache POST requests or authenticated requests
    if (req.method == "POST") {
        return (pass);
    }

    # Do not cache requests with a session cookie
    if (req.http.Cookie ~ "SESS|NO_CACHE") {
        return (pass);
    }

    # Strip unnecessary cookies to improve cache hit rate
    unset req.http.Cookie;

    return (hash);
}

sub vcl_backend_response {
    # Store Cache-Tags header from Drupal in the cached object
    # Needed for ban-based purging
    set beresp.http.Cache-Tags = beresp.http.Cache-Tags;

    # Cache 200 responses for 1 hour by default
    # Drupal's Cache-Control header overrides this per route
    if (beresp.status == 200) {
        set beresp.ttl = 1h;
        set beresp.grace = 6h;  # Serve stale content if backend is down
    }

    # Do not cache error responses
    if (beresp.status >= 400) {
        set beresp.uncacheable = true;
    }
}

sub vcl_deliver {
    # Remove internal headers before sending to client
    unset resp.http.X-Drupal-Cache-Tags;
    unset resp.http.X-Drupal-Cache-Contexts;

    # Debug header — remove in production
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }
}

Configuring Drupal to Send Cache Tags to Varnish

In Drupal's admin UI: Configuration → Development → Performance → Varnish Purge. Set the Varnish host to your Varnish server IP and port.

In settings.php, tell Drupal to include cache tags in response headers (required for ban-based purging):

// Include cache tags in HTTP response headers
// Varnish uses these to know what to ban on invalidation
$config['system.performance']['cache']['page']['max_age'] = 3600;

How the Three Layers Work Together

A request for a Drupal page goes through these layers in order:

  1. Varnish — checks if it has a cached response. If yes, returns it immediately (no PHP, no database). If no, passes the request to Nginx.
  2. Nginx — routes the request to PHP-FPM.
  3. PHP-FPM / Drupal — the Internal Page Cache checks for a cached full page in Redis. If found, returns it (no render processing). If not, runs the full render pipeline.
  4. Drupal's render cache — assembles the page from render cached fragments stored in Redis. A cache miss on a fragment triggers the code to generate it and store it back in Redis.
  5. The database — only consulted for data not covered by any cache layer.

On a well-warmed site serving anonymous traffic, 95%+ of requests should be served entirely by Varnish without touching PHP at all.


Diagnosing Cache Problems

Finding Cache Miss Reasons in Drupal

# Enable debug output for cache contexts and tags
# Add to settings.php or settings.local.php:
$config['system.performance']['cache']['page']['max_age'] = 0;  # Temporarily disable page cache

# Install the Drupal devel module for cache inspection
composer require --dev drupal/devel
drush en devel -y

Enable "Display cache metadata" in the Devel settings to see cache tags and contexts for every render element on a page.

Checking Varnish Cache Status

# Check if a URL is being served from Varnish cache
curl -I https://example.com/ | grep X-Cache
# X-Cache: HIT or MISS

# Check what cache tags Drupal is sending
curl -I https://example.com/ | grep Cache-Tags

# Check Varnish statistics
varnishstat -1 -f MAIN.cache_hit -f MAIN.cache_miss

Common Cache Problems

  • Session cookie preventing Varnish caching: A SESS* cookie is being set for anonymous users. Often caused by a module that initialises the session for anonymous requests. Check with curl -I and look for Set-Cookie headers on anonymous requests.
  • Cache max-age: 0 on every page: A render element somewhere on the page is setting 'max-age' => 0. Use Devel's cache metadata display to find which element is responsible.
  • Redis connection failures causing fallback to database: If Redis goes down, Drupal falls back to the database cache gracefully, but performance degrades. Monitor /admin/reports/dblog for Redis connection errors.
  • Varnish not receiving purge requests: Check that the Purge module's queue processor is running (either via cron or the real-time processor). View the queue status at /admin/config/development/performance/purge.

Summary Checklist

  • Drupal's internal cache uses tags for granular invalidation — always attach cache tags to cached render elements.
  • Use user.roles as a cache context rather than user to avoid per-user cache explosion.
  • Install the Redis module and configure it as the default cache backend; keep bootstrap/discovery/config on chainedfast.
  • Use PhpRedis (the PHP C extension), not Predis, for best performance.
  • Monitor Redis hit rate — below 80% suggests cache tags are being over-invalidated or TTLs are too short.
  • Run Varnish in front of Nginx for full-page caching of anonymous requests; configure it to receive ban-based purge requests from Drupal's Purge module.
  • Strip session cookies in Varnish for anonymous requests (unset req.http.Cookie) to maximise the cache hit rate.
  • Use the X-Cache: HIT/MISS debug header during configuration; remove it before going to production.
  • Use Drupal Devel's cache metadata display to find the element setting max-age: 0 when a page refuses to cache.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.