Reconciler TTL / Propagation
Proxied record silently overrides configured TTL
Your DNS-as-code file says ttl: 300. Your script or the dashboard form sends that value to Cloudflare. The apply step succeeds with no error. Then you check the live zone and the record shows ttl=1, not 300. This is not a bug in your tool and it is not a caching artifact. Cloudflare only honors a custom TTL on records that are not proxied, and it never tells you it ignored the number you sent. Here is why that happens and a script that catches the mismatch before it confuses your next deploy.
Cloudflare only honors a custom TTL on DNS-only (unproxied) A, AAAA, and CNAME records. The moment a record's proxied field is true, Cloudflare silently coerces the stored ttl to 1, which means Automatic and behaves like a fixed 300 seconds, no matter what value your API call, script, or dashboard form actually sent. Your DNS-as-code source can say ttl: 300 forever and the apply will keep "succeeding" while the live zone keeps showing ttl=1. There is no per-record fix that keeps a custom TTL on a proxied record. Either update your source config to expect ttl: 1 when a record is proxied, or set proxied: false if you truly need a custom TTL. Full code, tests, and a dry run guard are below.
The problem in plain words
Most DNS records let you pick a TTL, the number of seconds a resolver is allowed to cache the answer before asking again. A high TTL means fewer lookups and slightly slower propagation. A low TTL means faster changes and more queries. That tradeoff is yours to make, in theory.
Cloudflare breaks that assumption for one specific case. When a record is proxied, meaning traffic for that hostname routes through Cloudflare's network instead of straight to your origin, Cloudflare needs the freedom to move that traffic anywhere at any time. So it does not let a custom TTL exist on a proxied record at all. It quietly stores ttl=1, its code for Automatic, and answers with a TTL around 300 seconds no matter what number you asked for. Nothing in the API response calls this out as a rejection or a warning. Your write looks like it worked.
Why it happens
- A proxied (orange-clouded) record does not answer with your origin's IP address at all. It answers with one of Cloudflare's anycast addresses, which can change at short notice, so Cloudflare keeps the effective cache time low and fixed rather than letting you pin a long TTL that would slow down a future IP change.
- Cloudflare's API and dashboard both accept any TTL value you submit for a proxied record without rejecting the request, they just do not store it. The write looks successful because, from the API's point of view, it is: the record was created or updated, just not with the TTL field you expected.
- Infrastructure-as-code tools, Terraform providers, and hand-rolled scripts that manage DNS often assert a fixed
ttlvalue for every record type, including proxied ones, so every plan or apply keeps reporting a diff that instantly reappears because the true, allowed state is alwaysttl=1for that record. - Teams that migrate a record from unproxied to proxied (or the reverse) often forget that the TTL behavior changes with it, so a record that used to hold a custom TTL quietly loses it the moment someone turns the proxy on.
This has caused real automation loops: tools like external-dns have hit an infinite reconciliation loop precisely because they kept trying to apply a TTL greater than 1 to a proxied record, and Cloudflare kept reporting the record as different from the desired state on every pass. See the citations at the end for the exact writeup.
A proxied record and a custom TTL are mutually exclusive on Cloudflare. This is not a transient error or an edge case, it is a documented, permanent rule. The moment you see proxied: true paired with any ttl other than 1 in your intended config, treat that as an invalid combination to reconcile, not as drift to silently overwrite.
The fix, as a flow
Do not try to force a custom TTL onto a proxied record, since Cloudflare will not store it. Instead, decide up front which of the two outcomes you actually want for each record, and make your source of truth match Cloudflare's rule rather than fight it. If the record should stay proxied, your config should expect ttl: 1. If the record truly needs a custom TTL, it cannot stay proxied, so flip proxied to false and let the custom TTL apply for real.
How to fix it
Read the intended config for the record
Open your DNS-as-code source, whatever it is: a YAML file, a Terraform resource, a JSON manifest read by a deploy script. Find the record in question and note the exact ttl and proxied values it declares.
app.example.com:
type: A
content: 203.0.113.10
ttl: 300
proxied: true
Query the live state from Cloudflare
Ask the Cloudflare API what the record actually looks like right now. Compare its ttl and proxied fields against what your config declared in step 1.
curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=app.example.com" \
-H "Authorization: Bearer $CF_API_TOKEN" \
| jq '.result[] | {name,type,content,ttl,proxied}'
# bad result (expected/by-design, not a transient glitch):
# {"name":"app.example.com","type":"A","content":"203.0.113.10","ttl":1,"proxied":true}
# your intended config said ttl: 300, but the live zone shows ttl: 1
Cross-check with dig, from outside your own config
Confirm the same story shows up externally. For a proxied record, the answer's TTL will hover around 300 seconds no matter what your source config says, and it cannot be pinned any longer while the record stays proxied.
dig +short app.example.com
dig app.example.com
# look at the TTL column in the ANSWER SECTION
# for a proxied record it sits near 300s, not your configured 300 by
# coincidence, but because Automatic behaves like a fixed 300s
# repeat a few times to watch it count down from ~300 each time
dig +noall +answer app.example.com @1.1.1.1
Reconcile: accept Automatic, or turn off proxying
There is no record-level setting that keeps a custom TTL on a proxied record. Pick one of two real fixes. If the record must stay proxied, change your source config to declare ttl: 1 so it stops disagreeing with reality. If the record genuinely needs a custom TTL, set proxied to false so Cloudflare stores the value you actually want.
# option b: keep the custom TTL, turn proxying off (DNS-only, grey cloud)
curl -X PATCH \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"A","name":"app.example.com","content":"203.0.113.10","proxied":false,"ttl":300}'
# option a: keep it proxied, update your source config instead
# app.example.com:
# type: A
# content: 203.0.113.10
# ttl: 1 # Automatic, matches what Cloudflare will always store
# proxied: true
How to check it worked
Re-fetch the record after applying either fix and confirm the fields now agree with your (corrected) intent, then confirm the change externally with dig.
curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '{ttl,proxied}'
# if you kept it proxied and updated config to ttl: 1
# good result: {"ttl":1,"proxied":true}
# matches your corrected intended config, no more drift to report
# if you switched to unproxied for a custom ttl
# good result: {"ttl":300,"proxied":false}
dig +noall +answer app.example.com
# TTL near 300 (or your chosen value) and counting down normally
dig +short app.example.com
# now returns your origin's real address, not a Cloudflare edge IP
# (not a 104.x/172.x style anycast address)
The full code
Here is the complete reconciler in one file for each language. It fetches live records from Cloudflare, compares each one's ttl and proxied fields against an intended-config source, flags any record where proxied is true but the intended TTL is not 1 as a reconciliation mismatch rather than blind drift, and, only when you turn dry run off, repairs the record by either accepting ttl: 1 or setting proxied: false with the desired TTL.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a proxied Cloudflare record whose intended config expects a
custom TTL, which Cloudflare will always silently coerce to 1
(Automatic), and optionally repair the zone by either accepting
ttl: 1 in policy, or unproxying the record to keep a custom TTL.
Safe by default. Set DRY_RUN=false to let it write.
Env vars:
DNS_DOMAIN the domain to check, e.g. "example.com"
CLOUDFLARE_API_TOKEN Cloudflare API token (only needed for repair)
CLOUDFLARE_ZONE_ID Cloudflare zone id (only needed for repair)
DRY_RUN default "true"; set to "false" to actually write
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("proxied_record_forces_ttl")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CF_API = "https://api.cloudflare.com/client/v4"
def diagnose_ttl_proxy_mismatch(intended, live):
"""Pure decision function. No I/O.
intended, live: {"ttl": int, "proxied": bool}
Returns a mismatch reason string, or None if consistent.
- If live.proxied is True and live.ttl != 1: impossible/stale-cache state.
- If intended.proxied is True and intended.ttl not in (1, None):
config is invalid per Cloudflare rules (would be silently coerced to 1).
- If intended.proxied != live.proxied: proxy status itself drifted.
- If intended.proxied is False and intended.ttl != live.ttl:
real TTL drift, not the proxied-TTL quirk.
"""
if live.get("proxied") is True and live.get("ttl") != 1:
return "impossible state: live record is proxied but ttl is not 1 (stale read or cache)"
if intended.get("proxied") is True and intended.get("ttl") not in (1, None):
return "invalid config: proxied records are always coerced to ttl 1 by Cloudflare"
if intended.get("proxied") != live.get("proxied"):
return "proxy status drifted between intended config and live zone"
if intended.get("proxied") is False and intended.get("ttl") != live.get("ttl"):
return "real ttl drift on an unproxied record, not the proxied-ttl quirk"
return None
def fetch_live_records(domain):
"""Return the live A/AAAA/CNAME records for domain from Cloudflare."""
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
params = {"name": domain, "per_page": 100}
r = requests.get(
f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
headers=headers, params=params, timeout=30,
)
r.raise_for_status()
return r.json()["result"]
def repair_record(record_id, domain, record_type, content, policy, desired_ttl=300):
"""Repair a mismatched record.
policy: "accept_automatic" sets ttl to 1 and keeps proxied true.
"unproxy" sets proxied to false and keeps desired_ttl.
"""
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
if policy == "accept_automatic":
body = {"type": record_type, "name": domain, "content": content, "proxied": True, "ttl": 1}
elif policy == "unproxy":
body = {"type": record_type, "name": domain, "content": content, "proxied": False, "ttl": desired_ttl}
else:
raise ValueError(f"unknown policy: {policy}")
if DRY_RUN:
log.info("[dry run] would PATCH record %s with %s", record_id, body)
return
r = requests.patch(
f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}",
headers=headers, json=body, timeout=30,
)
r.raise_for_status()
log.info("Repaired record %s with policy %s", record_id, policy)
def run(intended_by_name, policy="accept_automatic"):
"""intended_by_name: dict mapping record name -> {"ttl": int, "proxied": bool}."""
live_records = fetch_live_records(DNS_DOMAIN)
mismatches = 0
for rec in live_records:
intended = intended_by_name.get(rec["name"])
if intended is None:
continue
live = {"ttl": rec["ttl"], "proxied": rec["proxied"]}
reason = diagnose_ttl_proxy_mismatch(intended, live)
if reason is None:
log.info("%s: consistent (ttl=%s, proxied=%s)", rec["name"], live["ttl"], live["proxied"])
continue
mismatches += 1
log.warning("%s: %s", rec["name"], reason)
if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
log.warning("No Cloudflare credentials set. Not repairing, only reporting.")
continue
repair_record(
rec["id"], rec["name"], rec["type"], rec["content"],
policy=policy, desired_ttl=intended.get("ttl") or 300,
)
log.info("Done. %d mismatch(es) found.", mismatches)
if __name__ == "__main__":
example_intended = {
f"app.{DNS_DOMAIN}": {"ttl": 300, "proxied": True},
}
run(example_intended, policy=os.environ.get("REPAIR_POLICY", "accept_automatic"))
/**
* Detect a proxied Cloudflare record whose intended config expects a
* custom TTL, which Cloudflare will always silently coerce to 1
* (Automatic), and optionally repair the zone by either accepting
* ttl: 1 in policy, or unproxying the record to keep a custom TTL.
*
* Safe by default. Set DRY_RUN=false to let it write.
*
* Env vars:
* DNS_DOMAIN the domain to check, e.g. "example.com"
* CLOUDFLARE_API_TOKEN Cloudflare API token (only needed for repair)
* CLOUDFLARE_ZONE_ID Cloudflare zone id (only needed for repair)
* DRY_RUN default "true"; set to "false" to actually write
* REPAIR_POLICY "accept_automatic" or "unproxy", default "accept_automatic"
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REPAIR_POLICY = process.env.REPAIR_POLICY || "accept_automatic";
const CF_API = "https://api.cloudflare.com/client/v4";
export function diagnoseTtlProxyMismatch(intended, live) {
// Pure decision function. No I/O.
//
// intended, live: { ttl: number, proxied: boolean }
//
// Returns a mismatch reason string, or null if consistent.
// - If live.proxied is true and live.ttl !== 1: impossible/stale-cache state.
// - If intended.proxied is true and intended.ttl not in (1, null/undefined):
// config is invalid per Cloudflare rules (would be silently coerced to 1).
// - If intended.proxied !== live.proxied: proxy status itself drifted.
// - If intended.proxied is false and intended.ttl !== live.ttl:
// real TTL drift, not the proxied-TTL quirk.
if (live.proxied === true && live.ttl !== 1) {
return "impossible state: live record is proxied but ttl is not 1 (stale read or cache)";
}
const intendedTtl = intended.ttl;
if (intended.proxied === true && intendedTtl !== 1 && intendedTtl != null) {
return "invalid config: proxied records are always coerced to ttl 1 by Cloudflare";
}
if (intended.proxied !== live.proxied) {
return "proxy status drifted between intended config and live zone";
}
if (intended.proxied === false && intendedTtl !== live.ttl) {
return "real ttl drift on an unproxied record, not the proxied-ttl quirk";
}
return null;
}
async function fetchLiveRecords(domain) {
const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
const params = new URLSearchParams({ name: domain, per_page: "100" });
const res = await fetch(
`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?${params.toString()}`,
{ headers },
);
if (!res.ok) throw new Error(`Cloudflare list returned ${res.status}`);
const body = await res.json();
return body.result;
}
async function repairRecord(recordId, domain, recordType, content, policy, desiredTtl = 300) {
const headers = {
Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
"Content-Type": "application/json",
};
let payload;
if (policy === "accept_automatic") {
payload = { type: recordType, name: domain, content, proxied: true, ttl: 1 };
} else if (policy === "unproxy") {
payload = { type: recordType, name: domain, content, proxied: false, ttl: desiredTtl };
} else {
throw new Error(`unknown policy: ${policy}`);
}
if (DRY_RUN) {
console.log(`[dry run] would PATCH record ${recordId} with`, payload);
return;
}
const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, {
method: "PATCH",
headers,
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Cloudflare patch returned ${res.status}`);
console.log(`Repaired record ${recordId} with policy ${policy}`);
}
async function run(intendedByName, policy = REPAIR_POLICY) {
// intendedByName: object mapping record name -> { ttl, proxied }
const liveRecords = await fetchLiveRecords(DNS_DOMAIN);
let mismatches = 0;
for (const rec of liveRecords) {
const intended = intendedByName[rec.name];
if (!intended) continue;
const live = { ttl: rec.ttl, proxied: rec.proxied };
const reason = diagnoseTtlProxyMismatch(intended, live);
if (!reason) {
console.log(`${rec.name}: consistent (ttl=${live.ttl}, proxied=${live.proxied})`);
continue;
}
mismatches++;
console.warn(`${rec.name}: ${reason}`);
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
console.warn("No Cloudflare credentials set. Not repairing, only reporting.");
continue;
}
await repairRecord(
rec.id, rec.name, rec.type, rec.content,
policy, intended.ttl || 300,
);
}
console.log(`Done. ${mismatches} mismatch(es) found.`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const exampleIntended = {
[`app.${DNS_DOMAIN}`]: { ttl: 300, proxied: true },
};
run(exampleIntended).catch((err) => {
console.error(err);
process.exit(1);
});
}
Add a test
The decision rule is the part most worth testing, because it decides whether a mismatch gets reported and how it gets repaired. Because diagnose_ttl_proxy_mismatch is pure, the test needs no network and no live zone. It just feeds in plain objects and checks the reason string.
from proxied_record_forces_ttl import diagnose_ttl_proxy_mismatch
def test_consistent_proxied_record_returns_none():
intended = {"ttl": 1, "proxied": True}
live = {"ttl": 1, "proxied": True}
assert diagnose_ttl_proxy_mismatch(intended, live) is None
def test_consistent_unproxied_custom_ttl_returns_none():
intended = {"ttl": 300, "proxied": False}
live = {"ttl": 300, "proxied": False}
assert diagnose_ttl_proxy_mismatch(intended, live) is None
def test_invalid_config_ttl_300_with_proxied_true():
intended = {"ttl": 300, "proxied": True}
live = {"ttl": 1, "proxied": True}
reason = diagnose_ttl_proxy_mismatch(intended, live)
assert reason is not None
assert "invalid config" in reason
def test_impossible_state_live_proxied_but_ttl_not_one():
intended = {"ttl": 1, "proxied": True}
live = {"ttl": 300, "proxied": True}
reason = diagnose_ttl_proxy_mismatch(intended, live)
assert reason is not None
assert "impossible state" in reason
def test_proxy_status_drifted():
intended = {"ttl": 1, "proxied": True}
live = {"ttl": 300, "proxied": False}
reason = diagnose_ttl_proxy_mismatch(intended, live)
assert reason is not None
assert "proxy status drifted" in reason
def test_real_ttl_drift_on_unproxied_record():
intended = {"ttl": 600, "proxied": False}
live = {"ttl": 300, "proxied": False}
reason = diagnose_ttl_proxy_mismatch(intended, live)
assert reason is not None
assert "real ttl drift" in reason
def test_intended_ttl_none_with_proxied_true_is_ok():
intended = {"ttl": None, "proxied": True}
live = {"ttl": 1, "proxied": True}
assert diagnose_ttl_proxy_mismatch(intended, live) is None
import { test } from "node:test";
import assert from "node:assert/strict";
import { diagnoseTtlProxyMismatch } from "./proxied-record-forces-ttl.js";
test("consistent proxied record returns null", () => {
const intended = { ttl: 1, proxied: true };
const live = { ttl: 1, proxied: true };
assert.equal(diagnoseTtlProxyMismatch(intended, live), null);
});
test("consistent unproxied custom ttl returns null", () => {
const intended = { ttl: 300, proxied: false };
const live = { ttl: 300, proxied: false };
assert.equal(diagnoseTtlProxyMismatch(intended, live), null);
});
test("invalid config ttl 300 with proxied true", () => {
const intended = { ttl: 300, proxied: true };
const live = { ttl: 1, proxied: true };
const reason = diagnoseTtlProxyMismatch(intended, live);
assert.ok(reason);
assert.match(reason, /invalid config/);
});
test("impossible state live proxied but ttl not one", () => {
const intended = { ttl: 1, proxied: true };
const live = { ttl: 300, proxied: true };
const reason = diagnoseTtlProxyMismatch(intended, live);
assert.ok(reason);
assert.match(reason, /impossible state/);
});
test("proxy status drifted", () => {
const intended = { ttl: 1, proxied: true };
const live = { ttl: 300, proxied: false };
const reason = diagnoseTtlProxyMismatch(intended, live);
assert.ok(reason);
assert.match(reason, /proxy status drifted/);
});
test("real ttl drift on unproxied record", () => {
const intended = { ttl: 600, proxied: false };
const live = { ttl: 300, proxied: false };
const reason = diagnoseTtlProxyMismatch(intended, live);
assert.ok(reason);
assert.match(reason, /real ttl drift/);
});
test("intended ttl null with proxied true is ok", () => {
const intended = { ttl: null, proxied: true };
const live = { ttl: 1, proxied: true };
assert.equal(diagnoseTtlProxyMismatch(intended, live), null);
});
Case studies
The plan that never went clean
A team managed their zone with the Cloudflare Terraform provider and set ttl = 300 on every A record for consistency, including several proxied ones. Every single terraform plan after that showed the same one-line diff on those records, forever, because the applied state could never match the declared state.
Once someone traced it to Cloudflare's proxied-TTL rule, the fix was one line per record: set ttl = 1 in the Terraform resource wherever proxied = true. The plan went clean immediately and stayed clean.
The controller that kept reapplying the same TTL
An automation tool responsible for keeping DNS in sync with running services kept sending a fixed TTL on every reconciliation pass, even for proxied records. Because Cloudflare kept reporting the record back as different from the desired state, the tool kept issuing patch requests every cycle, burning API rate limit for no real change.
The team added a check equivalent to diagnose_ttl_proxy_mismatch before issuing any write: if a record is proxied, only compare against ttl: 1, never against the tool's default TTL. The reconciliation loop stopped repeating itself, and the API call volume dropped by most of its previous load.
After the fix, your reconciler reports zero mismatches on every run, because your intended config now matches what Cloudflare is actually willing to store. Proxied records show ttl: 1 everywhere, on both sides. Any record that truly needs a custom TTL is unproxied on purpose, with that tradeoff written down, not discovered by surprise during an incident.
FAQ
Why does my Cloudflare record show ttl=1 when I set ttl to 300?
Cloudflare only honors a custom TTL on DNS-only records. The moment proxied is true, Cloudflare forces the stored ttl to 1, which means Automatic, no matter what number your API call or dashboard form sent. This is expected behavior, not a bug in your script.
Can I keep a proxied record and still get a custom TTL?
No. There is no setting that lets a proxied (orange-clouded) record keep a custom TTL on Cloudflare. If you need a specific TTL value, you must set proxied to false, which also means the record stops going through Cloudflare's proxy and points at your origin's real IP address.
What TTL does Automatic actually resolve to on a proxied record?
Automatic TTL on a proxied record behaves like a fixed 300 seconds at the edge. You cannot pin it lower or higher while the record stays proxied, since Cloudflare needs to move traffic quickly if it ever changes its anycast IP addresses.
Related field notes
Citations
On the problem:
- Cloudflare DNS docs: Time to Live (TTL), custom TTL only applies to DNS-only records. developers.cloudflare.com/dns
- Cloudflare DNS docs: Proxy status, what changes when a record is proxied. developers.cloudflare.com/dns
- external-dns issue: infinite loop when ttl greater than 1 and the proxy parameter is enabled. github.com/kubernetes-sigs/external-dns/issues/1820
On the solution:
- Cloudflare API docs: DNS Records, Edit (PATCH). developers.cloudflare.com/api
- Cloudflare Community: setting a custom TTL with proxy on. community.cloudflare.com
- Cloudflare Help Center: what does the Automatic TTL value mean. support.cloudflare.com
Stuck on a tricky one?
If you have a DNS, domain, or email routing problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear up your TTL drift?
If this stopped a confusing config diff or explained a mysterious ttl=1, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi