Diagnostic Cloudflare
only one Page Rule applies, and it is the one at the top
You have a broad rule near the top that caches everything, and a specific rule further down that adds a redirect. Both patterns match the URL, so you expect both actions. You get one. Only the highest-priority matching Page Rule takes effect on a request — every other match is discarded, with no log line, no warning, and no indication in the dashboard that a rule was skipped.
Page Rules are not a list of things that all happen. They are a first-match-wins lookup: Cloudflare finds the highest-priority rule whose pattern matches and applies only that one.
So a broad pattern like example.com/* sitting above a specific one shadows it permanently. Order most specific to least specific — or move the behaviour to the modern rule types, which are evaluated per phase and genuinely do stack.
The problem in plain words
The symptom is a rule that is present, enabled, correctly written and does nothing. Testing it in isolation works. Testing it on the live zone does not, because a broader rule above it is winning every request.
Two smaller traps compound it. A pattern with no scheme matches both http:// and https://, so it is broader than it looks. And a disabled rule still counts against the rule quota for your plan, so a zone can be full of rules that do nothing while refusing to let you add the one you need.
Why it happens
First-match-wins is a reasonable model that reads like a stack. The dashboard shows a vertical list with drag handles, which is exactly how an additive rule engine looks. Nothing on the screen says the rules below the first match will not run.
Patterns are broader than they appear. The five-segment form is <SCHEME>://<HOSTNAME>:<PORT>/<PATH>?<QUERY_STRING>, and both scheme and port are optional. Omitting them widens the match rather than narrowing it, so a rule written to be tidy ends up shadowing more than intended.
The modern rule types behave differently. Cache Rules, Redirect Rules, Configuration Rules and Origin Rules run in separate phases and do combine. Advice written for one model is wrong for the other, and both sets of advice are in circulation.
How to fix it
List the rules in priority order
The API returns them with an explicit priority, which is more reliable than reading the dashboard's visual order.
curl -s -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/pagerules" \
| python3 -c "import sys,json; [print(r['priority'], r['status'], r['targets'][0]['constraint']['value']) for r in sorted(json.load(sys.stdin)['result'], key=lambda x: -x['priority'])]"
Find which rule actually wins for a given URL
Walk the rules from highest priority down and stop at the first pattern that matches. That rule is the only one applied; everything after it is dead for that URL. This is what the script does, and it is the piece the dashboard will not tell you.
Reorder specific above general
The general rule belongs at the bottom. If two rules genuinely need to both apply, Page Rules cannot express that — you need either one combined rule or the modern rule types.
Delete disabled rules instead of leaving them
A disabled rule occupies a slot in your quota while doing nothing. If you are near the limit, this is usually where the space is.
Move the behaviour to the modern rule types
Redirect Rules, Cache Rules, Configuration Rules and Origin Rules are evaluated in separate phases, so a cache setting and a redirect can both apply to the same request. That is the fix for wanting two actions at once, not a cleverer pattern.
How to check it worked
Request a URL you expect the specific rule to affect and look at what actually happened:
curl -sI "https://example.com/promo?utm_source=x" | grep -i 'cf-cache-status\|location'
Cloudflare's Trace tool will also show which rule triggered for a specific URL, which settles the argument faster than reasoning about patterns.
The full code
The script fetches the Page Rules, sorts them by priority, and for each URL you give it reports the winning rule and every rule that matched but was skipped. That second list is the interesting one — it is the set of rules you believe are running and are not. It also flags disabled rules consuming quota.
"""Find Page Rules that match but never run.
Only the highest-priority matching rule takes effect. Every other match is
discarded silently, so a rule can be present, enabled, correct and dead.
"""
import argparse
import fnmatch
import logging
import os
import sys
from urllib.parse import urlsplit
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("cloudflare_page_rule_shadow")
API = "https://api.cloudflare.com/client/v4"
def normalise(pattern, url):
"""Strip the optional scheme from both sides before comparing.
A pattern with no scheme matches http and https. Comparing raw strings would
make such a pattern look narrower than it is -- the opposite of the truth.
"""
if "://" not in pattern:
url = urlsplit(url).netloc + urlsplit(url).path + (
"?" + urlsplit(url).query if urlsplit(url).query else "")
return pattern, url
def matches(pattern, url):
pat, target = normalise(pattern, url)
return fnmatch.fnmatch(target, pat)
def evaluate(rules, url):
"""Return (winner, shadowed) for one URL.
rules: list of dicts with 'priority', 'pattern', 'enabled', 'actions'.
Higher priority wins. Disabled rules never match at all.
"""
active = sorted((r for r in rules if r.get("enabled", True)),
key=lambda r: -r["priority"])
hits = [r for r in active if matches(r["pattern"], url)]
if not hits:
return None, []
return hits[0], hits[1:]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--zone-id", required=True)
ap.add_argument("--url", nargs="+", required=True)
args = ap.parse_args()
token = os.environ.get("CF_API_TOKEN")
if not token:
log.error("set CF_API_TOKEN")
return 2
r = requests.get(f"{API}/zones/{args.zone_id}/pagerules",
headers={"Authorization": f"Bearer {token}"}, timeout=30)
r.raise_for_status()
raw = r.json().get("result", [])
rules = [{
"priority": item.get("priority", 0),
"pattern": item["targets"][0]["constraint"]["value"],
"enabled": item.get("status") == "active",
"actions": [a.get("id") for a in item.get("actions", [])],
} for item in raw]
disabled = [x for x in rules if not x["enabled"]]
for d in disabled:
log.warning("DISABLED %s -- still counts against your rule quota", d["pattern"])
shadowed_any = False
for url in args.url:
winner, shadowed = evaluate(rules, url)
if not winner:
log.info("%s -- no Page Rule matches", url)
continue
log.info("%s -> %s actions=%s", url, winner["pattern"], winner["actions"])
for s in shadowed:
shadowed_any = True
log.error(" SHADOWED %s (actions=%s) matches but never runs -- only the "
"highest-priority match applies", s["pattern"], s["actions"])
return 1 if shadowed_any else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find Page Rules that match but never run.
*
* Only the highest-priority matching rule takes effect. Every other match is
* discarded silently, so a rule can be present, enabled, correct and dead.
*/
const API = 'https://api.cloudflare.com/client/v4';
const toRegExp = (pattern) => new RegExp(
`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
/**
* Strip the optional scheme from both sides before comparing. A pattern with no
* scheme matches http and https, so a raw string compare makes it look narrower
* than it is -- the opposite of the truth.
*/
export function matches(pattern, url) {
const target = pattern.includes('://') ? url : url.replace(/^https?:\/\//, '');
return toRegExp(pattern).test(target);
}
/** Return { winner, shadowed } for one URL. Disabled rules never match at all. */
export function evaluate(rules, url) {
const active = rules.filter((r) => r.enabled !== false).sort((a, b) => b.priority - a.priority);
const hits = active.filter((r) => matches(r.pattern, url));
return { winner: hits[0] ?? null, shadowed: hits.slice(1) };
}
async function main() {
const zone = process.argv[process.argv.indexOf('--zone-id') + 1];
const ui = process.argv.indexOf('--url');
const urls = process.argv.slice(ui + 1).filter((a) => !a.startsWith('--'));
const token = process.env.CF_API_TOKEN;
if (!token) { console.error('set CF_API_TOKEN'); process.exit(2); }
const res = await fetch(`${API}/zones/${zone}/pagerules`,
{ headers: { Authorization: `Bearer ${token}` } });
const { result: raw = [] } = await res.json();
const rules = raw.map((item) => ({
priority: item.priority ?? 0,
pattern: item.targets[0].constraint.value,
enabled: item.status === 'active',
actions: (item.actions ?? []).map((a) => a.id),
}));
for (const d of rules.filter((r) => !r.enabled)) {
console.warn(`DISABLED ${d.pattern} -- still counts against your rule quota`);
}
let shadowedAny = false;
for (const url of urls) {
const { winner, shadowed } = evaluate(rules, url);
if (!winner) { console.log(`${url} -- no Page Rule matches`); continue; }
console.log(`${url} -> ${winner.pattern} actions=${winner.actions}`);
for (const s of shadowed) {
shadowedAny = true;
console.error(` SHADOWED ${s.pattern} (actions=${s.actions}) matches but never runs`);
}
}
process.exit(shadowedAny ? 1 : 0);
}
if (import.meta.url === `file://${process.argv[1]}`) main();
Add a test
The tests pin the two things that make this counter-intuitive: a broad rule above a specific one shadows it rather than combining with it, and a pattern written without a scheme is wider than the same pattern with one.
from cloudflare_page_rule_shadow import evaluate, matches
def rule(priority, pattern, enabled=True):
return {"priority": priority, "pattern": pattern, "enabled": enabled, "actions": ["x"]}
def test_broad_rule_above_specific_shadows_it():
rules = [rule(2, "example.com/*"), rule(1, "example.com/promo*")]
winner, shadowed = evaluate(rules, "https://example.com/promo")
assert winner["pattern"] == "example.com/*"
assert [s["pattern"] for s in shadowed] == ["example.com/promo*"]
def test_specific_above_broad_is_the_fix():
rules = [rule(2, "example.com/promo*"), rule(1, "example.com/*")]
winner, _ = evaluate(rules, "https://example.com/promo")
assert winner["pattern"] == "example.com/promo*"
def test_a_pattern_without_a_scheme_matches_https():
"""Omitting the scheme widens the match rather than narrowing it."""
assert matches("example.com/*", "https://example.com/x")
def test_a_pattern_with_a_scheme_does_not_match_the_other_one():
assert not matches("http://example.com/*", "https://example.com/x")
def test_a_disabled_rule_never_wins():
rules = [rule(2, "example.com/*", enabled=False), rule(1, "example.com/promo*")]
winner, shadowed = evaluate(rules, "https://example.com/promo")
assert winner["pattern"] == "example.com/promo*"
assert shadowed == []
def test_no_match_is_not_an_error():
assert evaluate([rule(1, "other.com/*")], "https://example.com/") == (None, [])
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { evaluate, matches } from './cloudflare-page-rule-shadow.mjs';
const rule = (priority, pattern, enabled = true) => ({ priority, pattern, enabled, actions: ['x'] });
test('a broad rule above a specific one shadows it', () => {
const { winner, shadowed } = evaluate(
[rule(2, 'example.com/*'), rule(1, 'example.com/promo*')], 'https://example.com/promo');
assert.equal(winner.pattern, 'example.com/*');
assert.deepEqual(shadowed.map((s) => s.pattern), ['example.com/promo*']);
});
test('specific above broad is the fix', () => {
const { winner } = evaluate(
[rule(2, 'example.com/promo*'), rule(1, 'example.com/*')], 'https://example.com/promo');
assert.equal(winner.pattern, 'example.com/promo*');
});
test('a pattern without a scheme matches https', () => {
assert.ok(matches('example.com/*', 'https://example.com/x'));
});
test('a disabled rule never wins', () => {
const { winner } = evaluate(
[rule(2, 'example.com/*', false), rule(1, 'example.com/promo*')], 'https://example.com/promo');
assert.equal(winner.pattern, 'example.com/promo*');
});
FAQ
Do Cloudflare Page Rules stack?
No. Only the highest-priority matching Page Rule takes effect on a request; every other matching rule is skipped with no warning. If you need two actions on the same request, use one combined rule or move to the modern rule types.
Why does my Page Rule do nothing?
Usually a broader rule above it is winning. Order rules most specific to least specific — a pattern like example.com/* at the top shadows everything below it permanently.
Does a pattern without https:// only match http?
It matches both. Scheme and port are optional segments, and omitting them widens the match rather than narrowing it, which is how tidy-looking patterns end up shadowing more than intended.
Do disabled Page Rules count against my limit?
Yes. A disabled rule still appears in the dashboard, is still editable, and still occupies a slot in your plan's quota. If you cannot add a rule, that is usually where the space went.
What should I use instead of Page Rules?
Redirect Rules, Cache Rules, Configuration Rules and Origin Rules. They run in separate phases, so a cache setting and a redirect can both apply to one request — which is what people expect Page Rules to do.
Related field notes
- A rule that never applies because the record is grey-clouded
- A cache purge that reports success and clears nothing
- Cloudflare field notes
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.