A Docker container that automatically flattens SPF records, resolving all include, redirect, a, and mx mechanisms into raw ip4/ip6 entries. This eliminates the 10 DNS lookup limit problem.
Inspired by cfspflat and r53spflat, but redesigned as a fully stateless container with all configuration via environment variables.
┌─────────────────────────────────────────────────────────────────┐
│ Your apex record (manually created, never touched by spfflat): │
│ example.com TXT "v=spf1 redirect=qazwsx3.example.com" │
└──────────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────────┐
│ Source record (you maintain this with your real SPF): │
│ qazwsx3._source.example.com TXT "v=spf1 include:_spf.goo..." │
│ (can exceed 10 lookups — this is your unflattened truth) │
└──────────────────────────────┬──────────────────────────────────┘
│ spfflat reads & resolves
▼
┌─────────────────────────────────────────────────────────────────┐
│ Flattened records (written automatically by spfflat): │
│ qazwsx3.example.com TXT "v=spf1 ip4:... include:qazwsx3_1 │
│ qazwsx3_1.example.com TXT "v=spf1 ip4:... ip6:... ~all" │
│ qazwsx3._state.example.com TXT "spfflat_hash=abc123..." │
└─────────────────────────────────────────────────────────────────┘
- You create a source SPF record at
{SOURCE_ID}._source.{domain}containing your real SPF with all includes - You create an apex redirect:
v=spf1 redirect={SOURCE_ID}.{domain} - spfflat reads the source, recursively resolves everything to IPs, and publishes flat chained records at
{SOURCE_ID}.{domain},{SOURCE_ID}_1.{domain}, etc. - State tracking via DNS at
{SOURCE_ID}._state.{domain}— no local files needed - Orphan cleanup — if the record shrinks (e.g.,
qazwsx3_3.example.comis no longer needed), it's deleted automatically
At your DNS provider, create:
qazwsx3._source.example.com TXT "v=spf1 include:_spf.google.com include:spf.protection.outlook.com include:amazonses.com ~all"
example.com TXT "v=spf1 redirect=qazwsx3.example.com"
Cloudflare:
docker run -d --name spfflat \
-e SOURCE_ID=qazwsx3 \
-e MY_DOMAINS="example1.com example2.com" \
-e DNS_PROVIDER=cloudflare \
-e CF_API_TOKEN=your-cloudflare-api-token \
-e SCHEDULE=60 \
spfflatRoute53:
docker run -d --name spfflat \
-e SOURCE_ID=qazwsx3 \
-e MY_DOMAINS="example.com sub.example.com" \
-e DNS_PROVIDER=route53 \
-e AWS_ACCESS_KEY_ID=AKIA... \
-e AWS_SECRET_ACCESS_KEY=... \
-e SCHEDULE=60 \
spfflatBunny.net:
docker run -d --name spfflat \
-e SOURCE_ID=qazwsx3 \
-e MY_DOMAINS="example.com" \
-e DNS_PROVIDER=bunny \
-e BUNNY_API_KEY=your-bunny-api-key \
-e SCHEDULE=60 \
spfflatCopy docker-compose.yml, edit the environment variables, then:
docker compose up -d| Variable | Description | Example |
|---|---|---|
MY_DOMAINS |
Space-separated list of domains | "example.com sub.example.com" |
DNS_PROVIDER |
DNS provider to write records | cloudflare, route53, or bunny |
| Variable | Default | Description |
|---|---|---|
SOURCE_ID |
qazwsx3 |
Prefix for all managed records |
SCHEDULE |
60 |
Minutes between checks |
DNS_TTL |
300 |
TTL for created TXT records |
RESOLVERS |
1.1.1.1,8.8.8.8 |
Comma-separated DNS resolvers |
MAX_TXT_LEN |
450 |
Max characters per TXT value (safe under 512) |
SPF_ALL_QUALIFIER |
(from source) | Override the all qualifier (e.g. ~all, -all) |
DRY_RUN |
false |
Log changes without writing DNS |
RUN_ONCE |
false |
Run one cycle then exit |
LOG_LEVEL |
INFO |
DEBUG, INFO, WARNING, ERROR |
| Variable | Description |
|---|---|
CF_API_TOKEN |
API token (recommended) |
CF_API_KEY |
Global API key (legacy, requires CF_API_EMAIL) |
CF_API_EMAIL |
Account email (used with CF_API_KEY) |
| Variable | Description |
|---|---|
AWS_ACCESS_KEY_ID |
AWS access key (or use instance role) |
AWS_SECRET_ACCESS_KEY |
AWS secret key |
AWS_REGION |
AWS region (default: us-east-1) |
| Variable | Description |
|---|---|
BUNNY_API_KEY |
Bunny.net API key from account settings |
All optional. If SMTP_HOST and SMTP_TO are set, email alerts are sent on changes.
| Variable | Default | Description |
|---|---|---|
SMTP_HOST |
SMTP server hostname | |
SMTP_PORT |
587 |
SMTP port |
SMTP_USER |
SMTP username | |
SMTP_PASS |
SMTP password | |
SMTP_FROM |
Sender address | |
SMTP_TO |
Comma-separated recipients | |
SMTP_TLS |
true |
Use STARTTLS |
| Variable | Description |
|---|---|
SLACK_WEBHOOK_URL |
Slack incoming webhook URL |
| Variable | Description |
|---|---|
TELEGRAM_BOT_TOKEN |
Telegram bot token |
TELEGRAM_CHAT_ID |
Chat/group ID for messages |
| Variable | Description |
|---|---|
TEAMS_WEBHOOK_URL |
Teams incoming webhook URL |
For SOURCE_ID=qazwsx3 and domain example.com:
| Record | Purpose |
|---|---|
qazwsx3._source.example.com |
You maintain — your real SPF with all includes |
qazwsx3.example.com |
spfflat writes — first flattened record (chained) |
qazwsx3_1.example.com |
spfflat writes — overflow record 1 |
qazwsx3_2.example.com |
spfflat writes — overflow record 2 (if needed) |
qazwsx3._state.example.com |
spfflat writes — hash for change detection |
Your apex record should contain: v=spf1 redirect=qazwsx3.example.com
A single container handles multiple domains. Each domain must have its own _source record:
-e MY_DOMAINS="example.com marketing.example.com partner.co"If qazwsx3._source.marketing.example.com doesn't exist, spfflat logs a warning and moves to the next domain.
docker build -t spfflat .Test without writing any DNS changes:
docker run --rm \
-e MY_DOMAINS="example.com" \
-e DNS_PROVIDER=cloudflare \
-e CF_API_TOKEN=... \
-e DRY_RUN=true \
-e RUN_ONCE=true \
spfflatMIT