Diagnostic Cloudflare
a Cloudflare rule that never applies because the record is grey-clouded
The redirect rule is right. The cache rule is right. You have re-read them four times and the syntax is fine. They never fire because the hostname they apply to is set to DNS only — the grey cloud — so requests go straight to your origin and never pass through Cloudflare at all. There is nothing wrong with the rule. Cloudflare is simply not in the path.
Cloudflare rules — redirects, cache rules, WAF, page rules — only apply to traffic that is proxied. A DNS record with proxy status off resolves straight to your origin IP and skips every rule you have written.
The dashboard shows this as a small grey cloud next to an orange one, which is easy to miss. The API states it plainly as proxied: false, and that is what to check first when a rule appears to do nothing.
The problem in plain words
Rules give no feedback about whether they matched. A rule that never fires looks exactly like a rule that fires and does nothing, so debugging starts with the rule expression and stays there. People rewrite the pattern, test it against the matcher, and conclude Cloudflare is broken.
It is common on hostnames that were deliberately unproxied at some point — a mail subdomain, an SSH host, something behind a VPN — and then reused for HTTP traffic later without anyone flipping the cloud back on.
Why it happens
Proxying is a per-record choice, and both settings are legitimate. Mail servers and SSH hosts should be grey-clouded; proxying them would break them. So Cloudflare cannot warn you that unproxied is wrong, because usually it is right.
The proxy status is also what exposes the origin. A grey-clouded record publishes your origin IP in public DNS, so this is not only a rules problem — it removes the DDoS protection people assume they have.
Rules are configured somewhere else entirely. The record lives in DNS, the rule lives in Rules. Nothing on either screen mentions the other, and the dependency between them is only in the documentation.
How to fix it
Check the proxy status of the exact hostname
Not the apex, the hostname in the rule. They are configured independently and often differ.
curl -s -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=app.example.com" \
| python3 -c "import sys,json; [print(r['name'], r['type'], 'proxied' if r['proxied'] else 'DNS ONLY') for r in json.load(sys.stdin)['result']]"
Confirm from outside whether traffic reaches Cloudflare
A proxied hostname resolves to a Cloudflare address and its responses carry cf-ray. If that header is absent, nothing you configure in Cloudflare will ever run.
curl -sI https://app.example.com | grep -i 'cf-ray\|server'
Only proxy what should be proxied
Turn the cloud orange for HTTP and HTTPS hostnames. Leave MX targets, SSH hosts and anything on a non-standard port grey — proxying those breaks them, and Cloudflare only proxies a specific set of ports.
Treat an exposed origin IP as its own problem
Once the record was grey-clouded, the origin IP was published. Proxying it now hides it from DNS but anyone who recorded it can still reach the origin directly. Firewall the origin to Cloudflare's ranges if that matters.
How to check it worked
The cf-ray header is the definitive answer — if it is present, traffic is going through Cloudflare and the rules will be evaluated:
curl -sI https://app.example.com | grep -i cf-ray
# cf-ray: 8a1b2c3d4e5f6789-LHR
Then exercise the rule itself and confirm it now does what it was always supposed to.
The full code
The script lists every DNS record in a zone, flags the ones serving HTTP that are not proxied, and cross-references them against the hostnames your rules target — so it can say which specific rule is dead rather than just listing grey clouds. It leaves mail and non-HTTP records alone, because those are correctly unproxied.
"""Find Cloudflare rules that can never fire because the record is not proxied.
Rules only apply to proxied traffic. A grey-clouded record resolves straight to the
origin, so the rule is never consulted -- which looks identical to a rule that
matches and does nothing.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("cloudflare_proxy_audit")
API = "https://api.cloudflare.com/client/v4"
# Records that SHOULD be unproxied. Proxying these breaks them, so they are not
# findings -- reporting them would train people to ignore the output.
NEVER_PROXY = {"MX", "TXT", "NS", "SRV", "CAA", "PTR"}
def unproxied_http_records(records):
"""Pure decision function.
Only A, AAAA and CNAME records can be proxied at all. Mail and metadata records
are correctly grey and must not be reported.
"""
return [r for r in records
if r.get("type") in {"A", "AAAA", "CNAME"}
and r.get("type") not in NEVER_PROXY
and not r.get("proxied", False)]
def dead_rules(rule_targets, unproxied_names):
"""Which configured hostnames point at something Cloudflare never sees?"""
grey = {r["name"] for r in unproxied_names}
return [t for t in rule_targets if t in grey]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--zone-id", required=True)
ap.add_argument("--rule-target", nargs="*", default=[],
help="hostnames your rules apply to")
args = ap.parse_args()
token = os.environ.get("CF_API_TOKEN")
if not token:
log.error("set CF_API_TOKEN")
return 2
s = requests.Session()
s.headers.update({"Authorization": f"Bearer {token}"})
r = s.get(f"{API}/zones/{args.zone_id}/dns_records",
params={"per_page": 500}, timeout=30)
r.raise_for_status()
records = r.json().get("result", [])
grey = unproxied_http_records(records)
log.info("%d record(s); %d HTTP record(s) not proxied", len(records), len(grey))
for rec in grey:
log.warning("DNS ONLY %-40s %s -> %s (origin IP is public; rules will not run)",
rec["name"], rec["type"], rec.get("content"))
dead = dead_rules(args.rule_target, grey)
for t in dead:
log.error("RULE DEAD a rule targeting %s can never fire -- that hostname "
"bypasses Cloudflare entirely", t)
return 1 if dead else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find Cloudflare rules that can never fire because the record is not proxied.
*
* Rules only apply to proxied traffic. A grey-clouded record resolves straight to
* the origin, so the rule is never consulted.
*/
const API = 'https://api.cloudflare.com/client/v4';
// Records that SHOULD be unproxied. Reporting them would train people to ignore output.
const NEVER_PROXY = new Set(['MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR']);
/**
* Pure decision function. Only A, AAAA and CNAME can be proxied at all; mail and
* metadata records are correctly grey.
*/
export function unproxiedHttpRecords(records) {
return records.filter((r) => ['A', 'AAAA', 'CNAME'].includes(r.type)
&& !NEVER_PROXY.has(r.type) && !r.proxied);
}
export function deadRules(ruleTargets, unproxied) {
const grey = new Set(unproxied.map((r) => r.name));
return ruleTargets.filter((t) => grey.has(t));
}
async function main() {
const zone = process.argv[process.argv.indexOf('--zone-id') + 1];
const at = process.argv.indexOf('--rule-target');
const targets = at === -1 ? [] : process.argv.slice(at + 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}/dns_records?per_page=500`,
{ headers: { Authorization: `Bearer ${token}` } });
const { result: records = [] } = await res.json();
const grey = unproxiedHttpRecords(records);
console.log(`${records.length} record(s); ${grey.length} HTTP record(s) not proxied`);
for (const rec of grey) {
console.warn(`DNS ONLY ${rec.name.padEnd(40)} ${rec.type} -> ${rec.content} (origin IP is public)`);
}
const dead = deadRules(targets, grey);
for (const t of dead) {
console.error(`RULE DEAD a rule targeting ${t} can never fire -- that hostname bypasses Cloudflare`);
}
process.exit(dead.length ? 1 : 0);
}
if (import.meta.url === `file://${process.argv[1]}`) main();
Add a test
The important behaviour is what the audit stays quiet about. An MX record is supposed to be grey, and reporting it as a finding is how a report becomes noise nobody reads.
from cloudflare_proxy_audit import unproxied_http_records, dead_rules
def rec(name, rtype="A", proxied=True):
return {"name": name, "type": rtype, "proxied": proxied, "content": "203.0.113.1"}
def test_a_proxied_record_is_not_reported():
assert unproxied_http_records([rec("app.example.com")]) == []
def test_a_grey_clouded_a_record_is_reported():
out = unproxied_http_records([rec("app.example.com", proxied=False)])
assert len(out) == 1
def test_mx_records_are_never_reported():
"""MX must be grey. Reporting it is how a report becomes noise."""
assert unproxied_http_records([rec("example.com", "MX", proxied=False)]) == []
def test_txt_records_are_never_reported():
assert unproxied_http_records([rec("example.com", "TXT", proxied=False)]) == []
def test_a_rule_on_a_grey_hostname_is_dead():
grey = unproxied_http_records([rec("app.example.com", proxied=False)])
assert dead_rules(["app.example.com"], grey) == ["app.example.com"]
def test_a_rule_on_a_proxied_hostname_is_live():
grey = unproxied_http_records([rec("app.example.com", proxied=True)])
assert dead_rules(["app.example.com"], grey) == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { unproxiedHttpRecords, deadRules } from './cloudflare-proxy-audit.mjs';
const rec = (name, type = 'A', proxied = true) => ({ name, type, proxied, content: '203.0.113.1' });
test('a proxied record is not reported', () => {
assert.deepEqual(unproxiedHttpRecords([rec('app.example.com')]), []);
});
test('a grey-clouded A record is reported', () => {
assert.equal(unproxiedHttpRecords([rec('app.example.com', 'A', false)]).length, 1);
});
test('MX records are never reported', () => {
assert.deepEqual(unproxiedHttpRecords([rec('example.com', 'MX', false)]), []);
});
test('a rule on a grey hostname is dead', () => {
const grey = unproxiedHttpRecords([rec('app.example.com', 'A', false)]);
assert.deepEqual(deadRules(['app.example.com'], grey), ['app.example.com']);
});
FAQ
Why does my Cloudflare rule do nothing?
Most often because the hostname it targets is set to DNS only. Rules apply to proxied traffic; a grey-clouded record resolves straight to your origin and never passes through Cloudflare, so the rule is never consulted.
How do I check without the dashboard?
The DNS records API states proxied: true or false plainly. From outside, a proxied hostname returns a cf-ray header — if that header is missing, nothing you configure in Cloudflare will run.
Should everything be proxied?
No. MX targets, SSH hosts and anything on a non-standard port must stay grey, because Cloudflare only proxies a specific set of ports and proxying those would break them. Proxy HTTP and HTTPS hostnames.
Does grey-clouding affect anything besides rules?
Yes, and it is the bigger problem. A grey-clouded record publishes your origin IP in public DNS, so the DDoS protection people assume they have is not there. Turning the cloud orange hides it going forward but does not un-publish it.
The record is proxied and the rule still does not fire. Now what?
Check rule ordering and any earlier rule that terminates evaluation, and confirm the expression matches the exact hostname and path. Once cf-ray is present the traffic is reaching Cloudflare, so the problem really is the rule.
Related field notes
- ERR_TOO_MANY_REDIRECTS is almost always Flexible SSL
- A proxied record overrides your configured TTL
- Only one Page Rule applies, and it is the one at the top
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.
- Proxy status — Cloudflare DNS docs
- Page Rules migration — Cloudflare docs
- Cloudflare API documentation
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.