We recently crossed a threshold that felt impossible two years ago: a Drupal 10 platform serving 4.2 million monthly sessions at a consistent sub-200ms Time to First Byte — on government infrastructure, with bilingual content, full WCAG compliance, and no edge-side caching.
This post breaks down the exact architecture decisions, Redis configuration, and Nginx tuning that got us there. No theory — just the specific settings, in the order we implemented them.
Baseline: Where we started and why it was slow
The existing platform was a Drupal 9 monolith. Every page request hit PHP, which hit MySQL, which ran 40–80 queries per page load. The internal page cache was enabled but misconfigured — it cached authenticated users, invalidating constantly. Average TTFB was sitting at 1,200ms.
The path from 1,240ms to 148ms wasn't a single change — it was eight layers of incremental improvements, each measured before and after. Here's each layer in sequence.
Layer 1: Redis as Drupal's primary cache backend
Drupal's default cache backend writes to MySQL. That means every cache read is a database query. For a high-traffic platform, you're trading slow uncached pages for slightly-less-slow cached pages.
Redis changes that completely. A Redis cache read is a memory operation — typically 0.1–0.3ms versus MySQL's 2–8ms for a cached row. At 74 cache lookups per page load, that's meaningful.
// Redis cache backend — replaces MySQL for all cache bins
$settings['cache']['default'] = 'cache.backend.redis';
$settings['cache']['bins']['render'] = 'cache.backend.redis';
$settings['cache']['bins']['discovery'] = 'cache.backend.redis';
$settings['cache']['bins']['bootstrap'] = 'cache.backend.redis';
// Keep config in APCu — survives Redis flushes during deploys
$settings['cache']['bins']['config'] = 'cache.backend.apcu';
$settings['redis.connection']['host'] = '127.0.0.1';
$settings['redis.connection']['port'] = 6379;
$settings['redis.connection']['database'] = 0;
$settings['redis.connection']['password'] = getenv('REDIS_PASSWORD');
cache.bins.config in APCu, not Redis. Config cache is read before Redis connectivity is established during bootstrapping. Routing both through Redis causes a bootstrap deadlock during Redis restarts — a lesson we learned the hard way at 2am on a deployment.Layer 2: Redis configuration — the settings that matter
Installing the Redis module and pointing Drupal at it gets you 60% of the gain. The other 40% is in Redis's own configuration. These are the settings we tune on every production deployment:
# Memory limit — set to 75% of available RAM reserved for Redis
maxmemory 6gb
maxmemory-policy allkeys-lru
# Persistence — disable AOF for cache-only workloads
# RDB snapshots are fine; cache loss on restart is acceptable
appendonly no
save 900 1
save 300 10
# Connection tuning
tcp-backlog 511
timeout 0
tcp-keepalive 300
# Lazy freeing — non-blocking key expiry, critical at high volume
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
"The allkeys-lru eviction policy is non-negotiable at government scale. You will exceed your memory limit during traffic spikes — and when you do, you need Redis to gracefully evict the least-recently-used keys, not throw OOM errors and take the whole site down."
Layer 3: Nginx micro-caching + FastCGI tuning
Even with Redis, Drupal still executes PHP for every request that isn't in the internal page cache. For anonymous traffic at scale, Nginx's FastCGI cache with a 1-second micro-cache absorbs traffic spikes without serving stale content to logged-in users.
# FastCGI cache zone — 1GB shared memory, 7d content TTL
fastcgi_cache_path /var/cache/nginx/drupal
levels=1:2
keys_zone=DRUPAL:100m
max_size=1g
inactive=60m
use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
# Skip cache for authenticated users and POST requests
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($http_cookie ~* "SESS") { set $skip_cache 1; }
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache DRUPAL;
fastcgi_cache_valid 200 1s; # 1-second micro-cache
add_header X-FastCGI-Cache $upstream_cache_status;
The 1-second TTL sounds trivial, but at 500 requests/second it means only one request per URL actually hits PHP each second. During a ministerial announcement driving 2,000 concurrent users to a single page, this prevented the site from going down completely.