So there I was, late on a Thursday, staring at a perfectly innocent TXT record that had somehow turned our client’s emails into ghosts. No bounces. No delivery. Just silence. Their SPF record looked clean, but buried inside were more “include”s than a family tree. And that’s when it hit me—yep, we’d smacked into the SPF 10 DNS lookup limit. Again. If you’ve ever watched a marketing blast vanish into the ether, you know the feeling. The fix? Flattening. But here’s the thing: doing it manually is like mowing a lawn with scissors. The grass grows back—and often when you’re off for the weekend. That’s why I started automating SPF flattening with CI/CD and, later, with Workers. It kept us clear of the limit, avoided stale IPs, and most importantly, let the team sleep.
In this guide, I’ll walk you through what the 10 lookup limit actually means, why flattening helps (and when it can hurt), and two practical automation paths—CI/CD pipelines or scheduled Workers—that keep your SPF fast, tidy, and self-healing. I’ll share the little gotchas, how to handle providers like Google Workspace, Microsoft 365, Mailgun, and SendGrid, and how to test changes safely. The goal isn’t to “hack around” SPF. It’s to respect it—and make it work for you without a weekly fire drill.
İçindekiler
- 1 The SPF 10 Lookup Limit, Explained Like a Coffee Chat
- 2 Why Flattening Helps (and Why Manual Flattening Can Bite)
- 3 Path One: Automated SPF Flattening with CI/CD
- 4 Path Two: Scheduled Workers That Keep SPF Fresh on Their Own
- 5 A Calm Strategy That Holds Up Over Time
- 6 Testing, Guardrails, and Real-World Gotchas
- 7 CI/CD vs Workers: Pick What Fits Your Brain
- 8 A Quick Word on Security and Secrets
- 9 When Providers Publish Complex SPF
- 10 What About Forwarding, SRS, and Alignment?
- 11 Practical Walkthrough: A Small, Safe Rollout Plan
- 12 A Note on Documentation and Team Habits
- 13 Related Homework if You’re in the Mood
- 14 Wrap-Up: The Calm Path Past the 10 Lookup Limit
The SPF 10 Lookup Limit, Explained Like a Coffee Chat
Let’s demystify the wall you keep hitting. SPF (Sender Policy Framework) is a DNS-based way to say “these IPs are allowed to send mail for my domain.” The record lives in DNS (as a TXT record) and can include mechanisms like ip4/ip6, a, mx, include, and a few others. The catch? Some of those mechanisms trigger DNS lookups during the receiver’s SPF evaluation. And SPF puts a hard cap on that. Ten. Not eleven. Ten total DNS-querying mechanisms and modifiers per evaluation.
Which ones count? include definitely. a and mx do too because they resolve to IPs. exists and ptr also trigger lookups (and ptr should really be avoided in modern setups). The redirect modifier counts as well, because it points evaluation to another domain. What doesn’t count? ip4 and ip6 entries—these are literal addresses and cost zero lookups. The spec has a few more nuances, like the idea of “void lookups,” but here’s the plain version: once the receiving server hits that tenth query while checking your SPF, many implementations mark your record as a “PermError,” and that can snowball into delivery issues. If you want the official bedtime reading, the SPF RFC 7208 lays it all out.
The real-world problem is modern email stacks. One business domain can touch three or four senders easily: Google or Microsoft for regular mail, a marketing platform, a transactional provider, and maybe a backup relay for edge cases. Each one publishes its own SPF via include, and—surprise—those includes pull in more includes. Ten comes fast.
Why Flattening Helps (and Why Manual Flattening Can Bite)
Flattening means converting all those indirect lookups into direct IP ranges. Instead of saying “include:_spf.provider.com,” your SPF says “ip4:1.2.3.4 ip4:5.6.7.0/24 …” and so on. Receivers don’t need to make DNS detours; they just match an IP. Zero lookups there, which means you can dodge the 10-lookup wall and keep your SPF evaluation snappy.
There’s a catch though, and I learned it the hard way. One of my clients ran a clean marketing calendar. Every Tuesday, a big newsletter. One day, they flattened manually, pasted in the IPs from their providers, and moved on. Weeks later, the provider rotated IP ranges (not unusual), and half the newsletter went to spam or disappeared. Why? The flattened SPF was stale. The include would have kept up, but the hard-coded IPs did not.
That’s the life lesson. Flattening is great only if it’s kept fresh automatically. Providers change backends. Your stack evolves. You don’t want to be the person who has “update SPF IPs” as a recurring Friday task. Automate it or skip flattening entirely. And if you skip it, you’ll probably fight the 10-lookup limit again soon enough.
Path One: Automated SPF Flattening with CI/CD
In my experience, the CI/CD route feels natural if you’re already using GitHub, GitLab, or similar. The idea is simple. You keep a tiny configuration file that defines which include domains or senders your domain relies on. A small script resolves those includes, recursively collects the IPs, and writes out a flattened SPF string. A scheduled pipeline then updates your DNS via an API. Nobody touches it day-to-day, and you get a clean commit history for every change.
How I Structure the Repo
I usually keep it minimal. A spf.yml that lists your senders or includes, a flatten_spf.py script, and a CI workflow file. The config can be incredibly simple—just your domain and the include domains you trust. The script does the heavy lifting: resolve TXT, follow “include” and “redirect,” expand “a” and “mx” to IPs, and ignore the problematic stuff like “ptr.” Then it outputs a single SPF record with ip4/ip6 ranges. Finally, the workflow updates your DNS record using your provider’s API token.
A Small Python Script That Gets It Done
Here’s a simplified script that has served me well for flattening. It uses dnspython and tries to be polite about recursion. It won’t be perfect for every edge case, but it’s a solid starting point:
#!/usr/bin/env python3
import sys
import re
import json
from collections import deque
try:
import dns.resolver
import dns.exception
except ImportError:
print("Please pip install dnspython", file=sys.stderr)
sys.exit(1)
RE_SPF = re.compile(r's+')
def fetch_txt(name):
try:
answers = dns.resolver.resolve(name, 'TXT')
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers, dns.exception.DNSException):
return []
out = []
for rdata in answers:
# Join multi-string TXT chunks
txt = ''.join([part.decode('utf-8') if isinstance(part, bytes) else part for part in rdata.strings])
out.append(txt)
return out
def parse_spf(txt):
# returns list of tokens like ['v=spf1', 'ip4:1.2.3.4', 'include:_spf.example.com', ...]
return RE_SPF.split(txt.strip())
def is_spf(txt):
return txt.lower().startswith('v=spf1')
def resolve_a(domain):
ips = set()
for rrtype in ['A', 'AAAA']:
try:
answers = dns.resolver.resolve(domain, rrtype)
for r in answers:
ips.add(r.address)
except Exception:
pass
return ips
def resolve_mx(domain):
mx_hosts = []
try:
answers = dns.resolver.resolve(domain, 'MX')
for r in answers:
mx_hosts.append(str(r.exchange).rstrip('.'))
except Exception:
return set()
ips = set()
for host in mx_hosts:
ips |= resolve_a(host)
return ips
def flatten(domain):
seen_domains = set()
ip4s, ip6s = set(), set()
queue = deque([domain])
while queue:
d = queue.popleft()
if d in seen_domains:
continue
seen_domains.add(d)
txts = fetch_txt(d)
spf_records = [t for t in txts if is_spf(t)]
if not spf_records:
continue
# Use the first SPF record found
tokens = parse_spf(spf_records[0])
for tok in tokens:
t = tok.lower()
if t.startswith('ip4:'):
ip4s.add(tok.split(':', 1)[1])
elif t.startswith('ip6:'):
ip6s.add(tok.split(':', 1)[1])
elif t.startswith('include:'):
inc = tok.split(':', 1)[1]
queue.append(inc)
elif t.startswith('redirect='):
red = tok.split('=', 1)[1]
queue.append(red)
elif t == 'a' or t.startswith('a:'):
target = tok.split(':', 1)[1] if ':' in tok else d
ips = resolve_a(target)
for ip in ips:
if ':' in ip:
ip6s.add(ip)
else:
ip4s.add(ip)
elif t == 'mx' or t.startswith('mx:'):
target = tok.split(':', 1)[1] if ':' in tok else d
ips = resolve_mx(target)
for ip in ips:
if ':' in ip:
ip6s.add(ip)
else:
ip4s.add(ip)
# We deliberately skip: ptr, exists, exp, and modifiers we don't want to expand
return sorted(ip4s), sorted(ip6s)
def build_spf(ip4s, ip6s, policy='-all'):
parts = ['v=spf1']
parts += [f'ip4:{x}' for x in ip4s]
parts += [f'ip6:{x}' for x in ip6s]
parts.append(policy)
spf = ' '.join(parts)
# Split into <=255 byte chunks for TXT
chunks = []
cur = ''
for ch in spf:
if len(cur.encode('utf-8')) + len(ch.encode('utf-8')) >= 255:
chunks.append(cur)
cur = ch
else:
cur += ch
if cur:
chunks.append(cur)
return chunks
if __name__ == '__main__':
if len(sys.argv) < 2:
print('Usage: flatten_spf.py DOMAIN', file=sys.stderr)
sys.exit(2)
domain = sys.argv[1]
ip4s, ip6s = flatten(domain)
chunks = build_spf(ip4s, ip6s)
result = {
'domain': domain,
'ip4': ip4s,
'ip6': ip6s,
'txt_chunks': chunks
}
print(json.dumps(result, indent=2))
This script takes a domain that already publishes an SPF, resolves its includes, expands a/mx where possible, collects IPs, and emits a flattened SPF string split into 255-byte chunks. In the real world, I sometimes invert the flow: keep a list of known include domains (like “_spf.google.com” or “spf.protection.outlook.com”) and flatten those directly into a dedicated policy domain like “_spf.example.com.” Then, in my root policy, I use a tiny delegator like “v=spf1 redirect=_spf.example.com.” That way, I can keep the public-facing record tidy while the automation updates the backend record.
GitHub Actions: A Calm Scheduled Workflow
Here’s a compact GitHub Actions workflow that runs on a schedule, flattens, and updates the DNS record using the Cloudflare API. Adapt it for your provider if you’re on Route 53, Google Cloud DNS, or elsewhere. The idea is the same—build the string, push via API, and forget it until it saves your bacon.
name: Flatten SPF
on:
schedule:
- cron: '0 */6 * * *' # every 6 hours
workflow_dispatch: {}
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install deps
run: pip install dnspython
- name: Flatten SPF
id: flatten
run: |
python flatten_spf.py _spf.example.com > out.json
echo "RESULT=<$(cat out.json)>" >> $GITHUB_OUTPUT
- name: Update Cloudflare TXT
env:
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
RECORD_ID: ${{ secrets.SPF_RECORD_ID }}
run: |
JSON=$(cat out.json)
TXT=$(echo "$JSON" | jq -c '.txt_chunks')
NAME="_spf.example.com"
# Cloudflare expects strings array for TXT chunks
DATA=$(jq -n --arg name "$NAME" --argjson content $TXT '{type:"TXT", name:$name, content:$content}')
curl -sS -X PUT
-H "Authorization: Bearer $CF_API_TOKEN"
-H "Content-Type: application/json"
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records/$RECORD_ID"
--data "$DATA"
That’s the core. A few field notes: I like running it every 6 or 12 hours. You could go daily, but I’ve seen providers change IPs mid-day. Use secrets for API tokens and record IDs. And test on a staging domain first—point a subdomain like “spf-staging.example.com,” verify the TXT, then switch your redirect to it once you’re happy.
If you need a refresher on the broader email authentication picture, I’ve written a friendly deep dive on DMARC that pairs beautifully with this topic: Beyond p=none: a friendly playbook for advanced DMARC, RUA/RUF analysis, and BIMI. It helps you see how SPF fits into the whole deliverability story.
Path Two: Scheduled Workers That Keep SPF Fresh on Their Own
Maybe you’d rather keep it all inside your DNS provider’s ecosystem. That’s where scheduled Workers shine. With something like Cloudflare Workers and scheduled Cron triggers, you can write a tiny script that wakes up every few hours, rebuilds your flattened SPF from the canonical includes, and updates the DNS record via API. No CI server needed.
The architecture is the same at heart: resolve, flatten, publish. The difference is who’s driving. Workers are good for “set and forget,” and you can keep a small list of include domains in environment variables or KV storage. Some teams like that because it reduces moving parts; others prefer CI because they love the audit trail of commits. Either way works.
A Lightweight Worker With a Cron Trigger
Here’s a small example using the module syntax and a scheduled handler. Real-world code might use KV to store your include list and chunking logic shared across runs. But this shows the idea clearly:
export default {
async scheduled(event, env, ctx) {
const includes = (env.INCLUDES || '_spf.google.com, spf.protection.outlook.com').split(',').map(s => s.trim())
const name = env.SPF_RECORD_NAME || '_spf.example.com'
const zoneId = env.CF_ZONE_ID
const apiToken = env.CF_API_TOKEN
const { ip4, ip6 } = await flattenIncludes(includes)
const spf = buildSpf(ip4, ip6)
const ok = await updateTxtRecord(zoneId, apiToken, name, spf)
if (!ok) throw new Error('Failed to update SPF record')
}
}
async function dnsTxt(name) {
// Use DNS over HTTPS for TXT lookups
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=TXT`
const res = await fetch(url, { headers: { 'accept': 'application/dns-json' } })
if (!res.ok) return []
const data = await res.json()
const answers = data.Answer || []
return answers
.filter(a => a.type === 16)
.map(a => a.data.replace(/^"|"$/g, '')) // basic unquote
}
function parseSpf(txt) {
return txt.trim().split(/s+/)
}
async function resolveA(name) {
const ips = new Set()
for (const type of ['A', 'AAAA']) {
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`
const res = await fetch(url, { headers: { 'accept': 'application/dns-json' } })
if (!res.ok) continue
const data = await res.json()
const answers = data.Answer || []
for (const a of answers) {
if (type === 'A') ips.add(a.data)
if (type === 'AAAA') ips.add(a.data)
}
}
return [...ips]
}
async function resolveMx(name) {
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=MX`
const res = await fetch(url, { headers: { 'accept': 'application/dns-json' } })
if (!res.ok) return []
const data = await res.json()
const answers = data.Answer || []
const hosts = answers.map(a => a.data.split(' ').slice(1).join(' ').replace(/.$/, ''))
const ips = new Set()
for (const h of hosts) {
const arr = await resolveA(h)
for (const ip of arr) ips.add(ip)
}
return [...ips]
}
async function flattenIncludes(includeDomains) {
const seen = new Set()
const ip4 = new Set()
const ip6 = new Set()
const queue = [...includeDomains]
while (queue.length) {
const d = queue.shift()
if (seen.has(d)) continue
seen.add(d)
const txts = await dnsTxt(d)
const spf = txts.find(t => t.toLowerCase().startsWith('v=spf1'))
if (!spf) continue
for (const tok of parseSpf(spf)) {
const t = tok.toLowerCase()
if (t.startsWith('ip4:')) ip4.add(tok.split(':', 1)[1])
else if (t.startsWith('ip6:')) ip6.add(tok.split(':', 1)[1])
else if (t.startsWith('include:')) queue.push(tok.split(':', 1)[1])
else if (t.startsWith('redirect=')) queue.push(tok.split('=', 1)[1])
else if (t === 'a' || t.startsWith('a:')) {
const target = t.includes(':') ? tok.split(':', 1)[1] : d
const arr = await resolveA(target)
for (const ip of arr) (ip.includes(':') ? ip6 : ip4).add(ip)
} else if (t === 'mx' || t.startsWith('mx:')) {
const target = t.includes(':') ? tok.split(':', 1)[1] : d
const arr = await resolveMx(target)
for (const ip of arr) (ip.includes(':') ? ip6 : ip4).add(ip)
}
}
}
return { ip4: [...ip4].sort(), ip6: [...ip6].sort() }
}
function buildSpf(ip4, ip6, policy='-all') {
const parts = ['v=spf1']
for (const i of ip4) parts.push(`ip4:${i}`)
for (const i of ip6) parts.push(`ip6:${i}`)
parts.push(policy)
const spf = parts.join(' ')
// Return array of chunks under 255 bytes, as CF supports multi-string TXT
const out = []
let cur = ''
for (const ch of spf) {
if (new TextEncoder().encode(cur + ch).length >= 255) { out.push(cur); cur = ch } else cur += ch
}
if (cur) out.push(cur)
return out
}
async function updateTxtRecord(zoneId, apiToken, name, chunks) {
// You will want to discover RECORD_ID by listing records once, or store it in a secret.
const list = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}`, {
headers: { 'Authorization': `Bearer ${apiToken}` }
})
if (!list.ok) return false
const info = await list.json()
const record = (info.result || [])[0]
const body = JSON.stringify({ type: 'TXT', name, content: chunks })
const url = record ? `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${record.id}`
: `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`
const res = await fetch(url, {
method: record ? 'PUT' : 'POST',
headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
body
})
return res.ok
}
It’s not production-perfect, but it demonstrates the loop: gather, flatten, chunk, publish. The Cron trigger keeps it on schedule. And if you ever need to rotate providers, you just update your env list and let the Worker do the next run.
A Calm Strategy That Holds Up Over Time
Flattening is not just about beating a number. It’s about doing it safely so your marketing and transactional mail keeps flowing. A few patterns have saved me more than once:
First, use a dedicated policy domain. I like something like “_spf.example.com” as the canonical flattened record, and then “v=spf1 redirect=_spf.example.com” on the root (or wherever you need the policy). It keeps your public record short, and swaps become a one-liner. Second, keep the policy strict. In stable environments, I default to “-all” rather than “~all.” If you’re still mapping your sender landscape, start softer and move to “-all” once you’re confident. Third, cap record size. TXT records must be split into 255-byte chunks at the DNS layer, and I try to keep the overall SPF under a few KB. If your flattened policy gets unwieldy, it’s a hint you might be authorizing more infrastructure than you really use.
Another trick is the “emergency switch.” Keep a backup redirect ready, like “_spf_safe.example.com,” that contains a minimal known-good set (your primary provider’s ranges). If something goes wrong in production—say a provider publishes a malformed SPF and your automation chokes—you can swap the redirect and stabilize within minutes.
Finally, make SPF one piece of a complete picture. SPF is about the envelope sender and connects nicely with DKIM and DMARC. If you haven’t tuned DMARC reporting yet, it’s worth your time; it’s where you see the reality of which IPs are sending on your behalf. I’ve walked through that in detail here: a friendly playbook for advanced DMARC, RUA/RUF analysis, and BIMI. That article pairs well with a flattened SPF because it helps you prune authorization over time.
Testing, Guardrails, and Real-World Gotchas
Here’s what I wish someone had told me on day one. Testing isn’t just “dig and done.” When you publish a new flattened record, dig it from different recursive resolvers and confirm you see the chunked strings intact. I like running a quick manually rendered SPF evaluation against known senders—just to confirm that the IPs we actually use are matched by the record. Then send a few test emails to mailboxes that show full headers and look for the “Received-SPF: pass” line. That tiny check has saved too many late nights.
Be mindful of “void lookups.” While we’re flattening, we stop relying on runtime DNS lookups during SPF evaluation. But your flattening process still performs those lookups at build-time. If you pull in an include that resolves to nowhere or returns NXDOMAIN, your script should skip gracefully. I also recommend a sanity limit on recursion depth and the number of tokens processed—just in case a provider publishes an SPF record that goes a bit wild. If your pipeline encounters weirdness, fall back to the last known-good record rather than publishing something broken.
TTL matters less than you think once you automate, but it still matters. I keep the TXT record TTL reasonably low at first, just while I validate the automation works as intended. Once I’m confident, I bump it to something sensible. The value you choose depends on your DNS provider and your appetite for churn, but the important part is that your automation runs reliably, so a short TTL doesn’t hurt.
There’s also a human side. If your marketing team occasionally spins up a new sender—say they trial a new platform—make sure they know the path. With CI/CD, you can treat “add platform” like a small pull request to your spf.yml or env list. With Workers, maybe it’s a dashboard or a simple variable change. The fewer side channels, the fewer surprises you’ll find in your DMARC reports later.
CI/CD vs Workers: Pick What Fits Your Brain
I’ve bounced between both approaches depending on the team. If a company already lives inside Git, with code reviews and a staging-first mentality, CI/CD feels like a warm blanket. You get diff history, approvals, and a paper trail. On the other hand, some teams want fewer moving parts. For them, a scheduled Worker that manages SPF inside the DNS provider’s house feels simpler. Both paths let you bypass the 10-lookup limit without painting yourself into a corner.
One detail I always emphasize: keep your original includes documented. Even if your flattened record is pristine, you’ll thank past-you when you need to explain why a certain IP range is authorized. I usually keep a comment block in the repo or a small JSON mapping from “provider → canonical include.” That way, if something changes upstream, you know who to ask and where to look.
A Quick Word on Security and Secrets
Automating DNS changes means you’re holding tokens that can alter your zone. Treat them like production credentials—because they are. Scope the token to the smallest set of permissions, tie the token to only the record(s) you update if your provider supports granular permissions, and rotate periodically. In CI/CD, keep tokens in your secrets store. In Workers, use secrets and environment bindings. And don’t forget access logs. It’s comforting to have a trail that says “this system updated the SPF at 03:00” when you’re debugging a weird deliverability blip.
When Providers Publish Complex SPF
Some providers publish layered SPF that uses macros or chains multiple levels of includes. That’s okay—flattening can still work—but test slowly. If you see mechanisms you don’t want to expand (like exists or ptr), skip them at build-time. Most reputable senders stick to includes, ip4/ip6, a, and mx. If your script encounters something exotic, don’t guess. Keep the include for that provider as-is, or add a temporary whitelist, and come back to it when you have certainty. Deliverability is the north star; neatness comes second.
What About Forwarding, SRS, and Alignment?
SPF is evaluated on the envelope sender (MAIL FROM). Forwarding breaks SPF because the forwarding host isn’t in your domain’s SPF policy. That’s why SRS (Sender Rewriting Scheme) exists. If you control forwarding, consider SRS to keep SPF meaningful. Separate but related: DMARC alignment. You might deploy a dedicated envelope domain for your bulk mail—like “mail.example.com”—which gives you a clean, independent SPF policy. That’s a nifty way to avoid spraying your root policy with every SaaS on earth. And it plays nicely with alignment if you DKIM-sign correctly and scope your DMARC policy with intention. Again, if you want to connect the dots, that DMARC guide I mentioned earlier makes alignment feel less mysterious.
Practical Walkthrough: A Small, Safe Rollout Plan
Here’s a calm rollout flow I’ve used for clients. First, inventory your senders. List the canonical SPF includes for each: your primary mailbox provider, your marketing platform, and your transactional service. Second, decide on the architecture—CI/CD or Workers. Pick one and keep the other in your pocket as plan B. Third, build a staging policy on a subdomain and flatten that. Fourth, send a small batch through each sender and watch the headers. Fifth, switch your production policy to a simple redirect to that staging domain. Sixth, schedule the automation and walk away.
Every time I’ve skipped a step—especially the small batch test—I’ve regretted it. A tiny mis-typed include, a stale record from a new provider, or a rogue IP shows up in your DMARC reports faster than you think. Test it like you mean it, then enjoy the quiet when your Tuesday newsletter just… works.
A Note on Documentation and Team Habits
I like leaving one short readme in the repo: what runs the pipeline, how to add a new include, and how to fail back to the safe policy. That readme has saved new teammates hours. I also keep a short list of “known good” test mailboxes where we can see full headers easily. The simple ritual of sending a test to those mailboxes before a big campaign makes everyone feel better. And yes, I’ve absolutely had a marketing manager send me a screenshot of a “Received-SPF: pass” header like it’s a postcard from vacation. That’s how you know you’ve built something that doesn’t just work—it reassures.
Related Homework if You’re in the Mood
If you enjoy learning by tinkering, pairing this with a small CI habit pays off elsewhere too. The same approach—small scripts, scheduled jobs, tidy DNS changes—helps with everything from zero-downtime cutovers to keeping protocol security tidy. I’ve shared step-by-step plays in other posts as well, like tuning TLS for mail transport or organizing your infra-as-code. If you’re making your email stack more resilient, spending an hour understanding DMARC reports will deliver returns for months. And if you’re the kind of person who enjoys seeing moving pieces fit neatly together, you’ll find that SPF flattening is the gateway habit that makes bigger automation feel easy.
Wrap-Up: The Calm Path Past the 10 Lookup Limit
Let’s bring it home. The SPF 10 lookup limit isn’t a quirk—it’s the rule. And it’s one that modern stacks bump into often. Manual flattening works until it doesn’t. The way out is automation: let a CI/CD job or a scheduled Worker rebuild your flattened record on a predictable cadence and publish it safely. Keep the public policy small, use redirect to a dedicated policy domain, and test on a subdomain before you flip the switch. When in doubt, fall back to a safe minimal policy and investigate without pressure.
Once you’ve set it up, you’ll wonder why you lived any other way. Emails pass SPF without drama, your DMARC reports get cleaner, and your Tuesdays stay boring. The good kind of boring. If this saved you one 11 p.m. SPF fire drill, it was worth writing. Hope it helps—and if you’ve got a gnarly SPF tangle you want to untie, you know where to find me. See you in the next post.
Helpful References You Can Keep Open
When you need official docs or a workflow refresher, these are the links I keep bookmarked:
• the SPF RFC 7208
• Cloudflare Workers and scheduled Cron triggers
• GitHub Actions workflow docs
