— Article — № 081

081 —Drupal

Drupal 7 behind Cloudflare: don't break admin or images

Flip the orange cloud on a Drupal 7 site and two things break first: the editor login loops, and image styles 404. Here's the settings.php and cache-rule playbook that holds.

Overhead photo: handwritten Cloudflare cache-rule sheet, settings.php printout, Drupal 7 folder tab, wax seal on envelope.
Hero · staged still№ 081

The 22:55 flip

The orange cloud went on at 22:55. By 23:41, the lead developer at a small Dutch agency we work with was in our DMs with three screenshots: a /user login that redirected straight back to /user, an editor's homepage carousel where every image style returned 404, and a Slack message from her client asking whether the demo at 09:00 the next morning was still on.

The legacy site was a twelve-year-old Drupal 7 install. Roughly 4,200 nodes, a Views-heavy editorial layout, a fleet of image styles for the carousel and article cards, and an admin team of nine. The migration goal was modest: put Cloudflare in front of it for DDoS protection and bandwidth before a press cycle. Nobody planned to touch code. Twenty minutes after the DNS proxy flipped, two specific things were broken, and they are almost always the same two things on every Drupal 7 site that gets fronted by a CDN for the first time.

This is the playbook we walked them through that night, written down so you can do it in the calm part of an afternoon instead.

Settings.php: trust the proxy first

Drupal 7 sees Cloudflare's edge as the client unless you tell it otherwise. That breaks more than IP logging. Anything that consults ip_address(), which includes Flood, login throttling, and most spam modules, will start treating every visitor as the same machine. The first edit goes into sites/default/settings.php:

// Trust Cloudflare's edge as the reverse proxy.
$conf['reverse_proxy'] = TRUE;
$conf['reverse_proxy_header'] = 'HTTP_CF_CONNECTING_IP';
$conf['reverse_proxy_addresses'] = array(
  // IPv4 ranges, current as of writing.
  '173.245.48.0/20',
  '103.21.244.0/22',
  '103.22.200.0/22',
  '103.31.4.0/22',
  '141.101.64.0/18',
  '108.162.192.0/18',
  '190.93.240.0/20',
  '188.114.96.0/20',
  '197.234.240.0/22',
  '198.41.128.0/17',
  '162.158.0.0/15',
  '104.16.0.0/13',
  '104.24.0.0/14',
  '172.64.0.0/13',
  '131.0.72.0/22',
);

The list comes straight from cloudflare.com/ips. Pull it from there at deploy time rather than copy-pasting it once and forgetting; the ranges do change. We keep a Drush command in our deploy hook that rewrites this array from the official endpoint.

HTTP_CF_CONNECTING_IP is the cleaner header to read on Cloudflare specifically; HTTP_X_FORWARDED_FOR works too but you have to parse the chain. Reading CF-Connecting-IP means ip_address() returns a single value, every time.

HTTPS and the redirect loop

The login redirect loop the agency hit at 23:41 is almost always the same root cause: Cloudflare terminates TLS at the edge and proxies HTTP to the origin. Drupal sees HTTP, decides the current request is insecure, and on any path that requires HTTPS (which, with the Secure Login module or a custom login redirect, includes /user), it sends a 302 to https://.... Cloudflare receives that, serves it, the browser follows it, Cloudflare proxies HTTP to the origin again. Loop.

Two more lines in settings.php:

// Detect HTTPS via Cloudflare's forwarded protocol.
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])
    && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
  $_SERVER['HTTPS'] = 'on';
}

// Pin the canonical base URL.
$base_url = 'https://www.example.com';

The X-Forwarded-Proto header is the standard way to surface the original scheme through a reverse proxy. Setting $_SERVER['HTTPS'] early means drupal_is_https() returns the truth, and the redirect chain ends.

Pin $base_url explicitly. Drupal will otherwise infer it from $_SERVER['HTTP_HOST'], which Cloudflare sets correctly but which a misbehaving Worker, a Page Rule that rewrites Host, or a poorly-configured Argo Tunnel can quietly mangle. Pinning it removes one whole class of bug from your future.

Cache rules that keep editors logged in

Cloudflare, by default, caches static assets and respects Cache-Control on dynamic responses. Drupal sends Cache-Control: no-cache, must-revalidate, post-check=0, pre-check=0 on anonymous-cacheable pages and a stricter private on authenticated ones, which is fine, but you should not rely on the origin headers alone. You want the edge to know that anything carrying a Drupal session cookie is uncacheable.

Drupal session cookies are prefixed SESS over HTTP and SSESS over HTTPS. The cache rule you want, in the Cloudflare Cache Rules UI:

When incoming requests match:
  (http.cookie contains "SESS") or (http.cookie contains "SSESS")
Then:
  Cache eligibility: Bypass cache

Add a second rule for the paths that should never be cached even for an anonymous visitor walking in cold:

When incoming requests match:
  (starts_with(http.request.uri.path, "/user"))
  or (starts_with(http.request.uri.path, "/admin"))
  or (http.request.uri.path contains "/edit")
  or (starts_with(http.request.uri.path, "/node/add"))
Then:
  Cache eligibility: Bypass cache

The /edit contains-match catches /node/123/edit, /taxonomy/term/4/edit, and anything custom modules expose. It's blunt, but on a Drupal admin surface, blunt is correct.

Image styles and the itok token

The second broken thing in the 23:41 screenshot, the 404 carousel, has a different cause and a different fix. Image styles are generated on demand. The URL looks like this:

/sites/default/files/styles/carousel_large/public/hero.jpg?itok=Hx3p_kQ8

The itok query parameter is an HMAC of the derivative path and your site's hash salt. The image_style_url() function generates it; image_style_deliver() validates it before generating the derivative. The token exists to stop someone from asking your server to generate a million derivatives by guessing URLs.

Two things go wrong behind Cloudflare. First, Cloudflare's default Cache Level is Standard, which includes the query string in the cache key. That part is fine. But some agencies, ours included once, have flipped Cache Level to Ignore Query String on a previous project and inherited the setting on a new zone. When the query string is ignored, every image style URL collapses to the same cache key, the first request that arrives wins, and every subsequent request with a different itok gets a cached 403.

Check this in the Cache Rules. The setting you want is:

Cache key includes:
  - Query string: All query string parameters
  - Host: example.com

Second, the derivative file doesn't exist on disk when the URL is first requested. Drupal's .htaccess rewrites the request to index.php, which calls image_style_deliver(), which generates the file, writes it to sites/default/files/styles/, and returns it with a 200. The next request reads the file directly from disk via Apache. The problem is that the very first generation response carries no Cache-Control: public, and on some configurations it carries Cache-Control: no-cache. Cloudflare won't cache it. Worse, if Apache returns a 500 because the file system isn't writable, or a 403 because the itok doesn't match (a salt mismatch between environments will do this), Cloudflare caches the error for the default TTL.

Two fixes. In settings.php, make absolutely sure the hash salt is identical across every environment that shares an image style URL:

$drupal_hash_salt = file_get_contents('/var/www/.drupal-salt');

And in the Cache Rules, set:

When incoming requests match:
  starts_with(http.request.uri.path, "/sites/default/files/styles/")
Then:
  Cache eligibility: Eligible for cache
  Edge TTL: Override origin, 1 month
  Browser TTL: 1 day
  Cache by status code: 200-299 cache, 400-499 bypass, 500-599 bypass

The status-code rule is the one that would have saved the agency that night. The first 403 from a salt mismatch is not allowed to stick at the edge. Once you fix the salt, the next request rebuilds correctly.

Pre-warm if you can

If the site has more than a few hundred derivatives, run a Drush task in deploy to walk the image fields and request every style URL once, server-side, before the first real visitor arrives. drush eval with a loop over field_get_items() and image_style_url() is a fifty-line script and saves you the cold-start tail.

Verify the chain before you walk away

Before you close the laptop, run four checks. From a machine that is not behind your office proxy:

curl -I https://www.example.com/
# expect: cf-cache-status: HIT (after the second call)

curl -I https://www.example.com/user/login
# expect: cf-cache-status: BYPASS

curl -I --cookie "SESS123=fake" https://www.example.com/
# expect: cf-cache-status: BYPASS

curl -I https://www.example.com/sites/default/files/styles/carousel_large/public/hero.jpg?itok=Hx3p_kQ8
# expect: HTTP/2 200 and, on the second call, cf-cache-status: HIT

Then log in as a real editor in an incognito window. Save a node. Upload an image. Watch the styles render. The whole loop takes four minutes and it's the difference between sleeping and being woken at 06:00 by the client's CEO.

What we learned

Drupal 7 is twelve years past its first release and a year past official end-of-life, and the sites still running on it are almost always running on it because rewriting them is a six-figure project that nobody has signed off on. Putting a CDN in front is one of the highest-leverage moves you can make to extend the runway: it absorbs traffic spikes, hides the origin's IP, and gives you a place to enforce a WAF without touching code. The cost is the half-day of fiddling described above, done once.

When we built Pier we ran into this exact thing on a client's Drupal 7 install we were auditing. The way we handled it: one-click settings.php snapshots in the version history so reverting a botched reverse-proxy change is a single keystroke, plus a MySQL editor built around the assumption you'll be inspecting the sessions and cache_form tables while debugging an auth loop.

The smallest thing you can do today: open your production settings.php and grep it for reverse_proxy. If the array isn't there, you have an exposed origin IP and a broken ip_address() waiting to bite. Fix that one line tonight; the rest can wait until Tuesday.

— Questions —

Does Drupal 7 still get security support?

Community support ended in January 2025. The D7 Security Team is gone. You're on your own for patches, which is precisely why putting a WAF in front matters more than it used to.

Do I need Cloudflare's Full (Strict) SSL with Drupal 7?

Yes if you can manage it. Install a free origin certificate from Cloudflare, configure Apache to require it, and set the encryption mode to Full (Strict). Flexible mode is the redirect-loop trap.

Should I disable Rocket Loader?

On most Drupal 7 sites, yes. It defers inline scripts in a way that breaks the admin Overlay, CKEditor, and some Views AJAX. Leave Auto Minify on for CSS, off for JS, until you've tested every editorial workflow.

What if my image styles use the private file system?

Private files route through PHP, which means Cloudflare cannot cache them without a custom cache key that includes the session. Easier path: keep editorial images public, keep genuinely private files (invoices, contracts) on a separate hostname that isn't proxied.