Validate DKIM in your CI/CD: catch broken records before production
By CaptainDNS
Published on May 19, 2026

- A broken DKIM record rarely fails functional tests but breaks deliverability on the first email sent after deploy
- Local linters catch syntax; the CaptainDNS API
/v1/dkim/validatechecks what is actually published in DNS - GitHub Actions, GitLab CI, and Terraform hooks can block a merge or an
applyon an insufficient DKIM score - Pre-commit validation saves CI minutes but does not replace a check against published DNS state
- Combining SPF, DKIM, and DMARC validation in a single stage prevents cascading regressions
You version your DNS in Git, your DKIM key rotations go through a pull request, and everything is fine until the day Gmail starts dumping your mail into spam. The cause: a p= tag truncated by a DNS provider, or a signature that no longer matches the published selector. The bug does not surface in terraform plan, and it does not show up in application integration tests either.
The answer is one word: shift-left. Rather than discovering the problem in production, validate every DKIM change before merge, then before apply, then after deploy. This article shows how to wire those checks into GitHub Actions, GitLab CI, Terraform, and a pre-commit hook, with copy-pastable examples.
For a quick syntax check on a record, the DKIM Syntax Check exposes a public API reusable from any pipeline.
Why a broken DKIM record ends up in production
Four root causes explain how an invalid DKIM record bypasses the standard defenses of a DNS deployment.
Manual DNS edits. An operator pastes a public key from a transactional provider, drops a character, or swaps two base64 blocks. Without syntax validation, the record gets published as-is.
Key rotation without validation. The new key pair is generated, the public key is encoded, but the rotation pipeline does not re-check that the base64 string stays consistent after export. A 254-character truncation in some admin panels is enough to break the signature.
A DNS provider that splits into two chunks. A TXT string longer than 255 characters must be fragmented by the DNS server, which then concatenates it with quotes. Some providers (or some UIs) split the string without quotes: the resolver receives two distinct strings, and the reconstructed key is invalid.
No post-deploy canary. The bug only shows up on the first emails sent after the change. By the time the signal arrives (DMARC report, support ticket), thousands of messages have already landed in spam.
Shift-left: validate before merge
The principle is simple: every change to a mail-related DNS record goes through automatic validation in pull request. You have two options to validate.
Open-source local linter. Tools like dkim-checker (a Go CLI based on github.com/emersion/go-msgauth/dkim) or homegrown scripts check the syntax of a record, the consistency of its tags (v=DKIM1, k=rsa, p=...), and the key length. Upside: zero network dependency, fast. Downside: it cannot tell whether the key is actually published and reachable.
Cloud linter via API. The CaptainDNS endpoint /v1/dkim/validate accepts a domain and a selector, queries published DNS, parses the record, and returns a normalized score, a state (valid, invalid, warning), and recommendations. Upside: it validates the real state observed from the outside, exactly as an MX receiving the mail would. Downside: it depends on the network, the API quota, and DNS.
| Criterion | Local linter | CaptainDNS API |
|---|---|---|
Syntax (v=, k=, p=) | Yes | Yes |
| Key published in DNS | No | Yes |
| Effective key size | Partial | Yes |
| Normalized score | No | Yes (computed on the backend) |
| Network dependency | No | Yes |
| Use case | pre-commit, syntax lint | PR validation, post-deploy |
Important note: the score, the thresholds, and the recommendations are computed by the CaptainDNS API on the backend. The CI client consumes an already-normalized value, it does no calculation of its own.
Combine both: a local linter in pre-commit for quick iterations, the API in CI for the final check before merge.
GitHub Actions pipeline for DNS pull request validation
Here is a workflow triggered on every pull request that modifies DNS files. It extracts DKIM records, calls the CaptainDNS API, and blocks the merge if the state is invalid or the score is below 50.
name: Validate DKIM records
on:
pull_request:
paths:
- 'dns/**.tf'
- 'dns/**.yaml'
jobs:
validate-dkim:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Detect changed DNS files
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
dns:
- 'dns/**'
- name: Validate DKIM via CaptainDNS API
if: steps.changes.outputs.dns == 'true'
env:
CDNS_API_KEY: ${{ secrets.CAPTAINDNS_API_KEY }}
run: |
set -euo pipefail
for entry in $(yq '.dkim_selectors[]' dns/email.yaml); do
domain=$(echo "$entry" | yq '.domain')
selector=$(echo "$entry" | yq '.selector')
response=$(curl -sS -X POST \
-H "Authorization: Bearer ${CDNS_API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"domain\":\"${domain}\",\"selector\":\"${selector}\"}" \
https://api.captaindns.com/public/v1/dkim/validate)
state=$(echo "$response" | jq -r '.state')
score=$(echo "$response" | jq -r '.score')
echo "Selector ${selector} for ${domain}: state=${state}, score=${score}"
if [ "$state" = "invalid" ] || [ "$score" -lt 50 ]; then
echo "::error::DKIM validation failed for ${selector}._domainkey.${domain}"
exit 1
fi
done
You provision the API key from the CaptainDNS console, then store it as a GitHub secret (Settings > Secrets and variables > Actions). Use a key scoped to dkim:read to limit blast radius in case of a leak.

GitLab CI: pipeline with a validate stage
On GitLab the logic is the same. The .gitlab-ci.yml below adds a cache to avoid re-validating unchanged records and sends a Slack notification on failure.
stages:
- validate
- apply
validate-dkim:
stage: validate
image: alpine:3.20
before_script:
- apk add --no-cache curl jq yq bash
cache:
key: dkim-validation-$CI_COMMIT_REF_SLUG
paths:
- .dkim-cache/
script:
- mkdir -p .dkim-cache
- |
bash -c '
set -euo pipefail
for entry in $(yq ".dkim_selectors[]" dns/email.yaml); do
domain=$(echo "$entry" | yq ".domain")
selector=$(echo "$entry" | yq ".selector")
cache_key=".dkim-cache/${domain}_${selector}.json"
response=$(curl -sS -X POST \
-H "Authorization: Bearer ${CAPTAINDNS_API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"domain\":\"${domain}\",\"selector\":\"${selector}\"}" \
https://api.captaindns.com/public/v1/dkim/validate)
echo "$response" > "$cache_key"
state=$(echo "$response" | jq -r ".state")
score=$(echo "$response" | jq -r ".score")
if [ "$state" = "invalid" ] || [ "$score" -lt 50 ]; then
curl -X POST -H "Content-Type: application/json" \
-d "{\"text\":\"DKIM validation failed: ${selector}._domainkey.${domain} (score=${score})\"}" \
"$SLACK_WEBHOOK_URL"
exit 1
fi
done
'
only:
changes:
- dns/**/*
The GitLab cache stores responses across runs on the same branch. The API is still called for every selector (DNS state can drift independently of code), but the history makes it possible to trace regressions.
Terraform pre-apply: validate before DNS publication
When your DNS is managed by Terraform, Pulumi, or OpenTofu, validation happens before apply. The hook below extracts modified TXT records from the JSON plan, validates each one through the CaptainDNS API, and blocks the apply on failure.
#!/usr/bin/env bash
# scripts/dkim-preapply.sh
set -euo pipefail
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# Extract modified DKIM TXT records
jq -r '
.resource_changes[]
| select(.type == "cloudflare_record" or .type == "aws_route53_record")
| select(.change.after.type == "TXT")
| select(.change.after.name | test("_domainkey"))
| "\(.change.after.name)|\(.change.after.value // (.change.after.records | join("")))"
' tfplan.json > dkim-changes.txt
while IFS='|' read -r fqdn value; do
# fqdn: selector._domainkey.captaindns.com
selector="${fqdn%%._domainkey.*}"
domain="${fqdn#*._domainkey.}"
response=$(curl -sS -X POST \
-H "Authorization: Bearer ${CDNS_API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"domain\":\"${domain}\",\"selector\":\"${selector}\",\"record\":\"${value}\"}" \
https://api.captaindns.com/public/v1/dkim/validate)
state=$(echo "$response" | jq -r '.state')
if [ "$state" = "invalid" ]; then
echo "Pre-apply DKIM check failed: ${fqdn}"
echo "$response" | jq '.recommendations'
exit 1
fi
done < dkim-changes.txt
terraform apply tfplan.binary
Wire that script into a make plan && make validate && make apply target, or into an Atlantis stage if you run that GitOps pattern. The same logic applies to Pulumi (via pulumi preview --json) and OpenTofu, since the JSON plan format is compatible.
Local lint at pre-commit
To save CI minutes, add a pre-commit hook that validates DKIM record syntax before the push even happens. The pre-commit.com framework lets you orchestrate several Python or bash hooks.
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: dkim-syntax-lint
name: DKIM syntax lint
entry: scripts/dkim-lint.sh
language: script
files: ^dns/.*\.(tf|yaml)$
pass_filenames: true
The dkim-lint.sh script can rely on a local Go or Python parser that verifies the required tags (v=DKIM1, p=...) and the base64 validity of the public key. That is enough to catch 80% of copy-paste mistakes.
A limitation worth knowing: a pre-commit lint cannot check the actual state published in DNS, nor conflicts with an already-active selector. The API check in CI remains mandatory for those cases.
Post-deploy integration tests
Once DNS is applied, verify that the end-to-end chain works. A smoke test sends a real email and inspects the Authentication-Results header.
#!/usr/bin/env bash
# scripts/dkim-smoke-test.sh
set -euo pipefail
# Send a test mail via swaks
swaks --to test-canary@captaindns.com \
--from noreply@captaindns.com \
--server smtp.captaindns.com \
--header "Subject: DKIM canary $(date +%s)" \
--body "Canary message"
# Wait 60 seconds for the mail to land
sleep 60
# Check the Authentication-Results header via IMAP or a homegrown endpoint
result=$(curl -sS "https://canary.captaindns.com/last-mail/auth-results")
if ! echo "$result" | grep -q "dkim=pass"; then
echo "Canary DKIM check FAILED: $result"
# Automatic rollback
terraform apply -auto-approve -var "dkim_selector=previous"
exit 1
fi
You can also rely on synthetic services like Mail-Tester or GlockApps, which expose an API to fetch the authentication results of a mail sent to a throwaway inbox. The feedback loop becomes: send the mail, read the result, then roll back DNS if DKIM fails.
For a one-off check without a pipeline, the DKIM Record Check queries DNS and returns the full state of a selector in seconds.
Combine with SPF / DMARC
Validating DKIM alone is not enough. A valid DKIM with a broken SPF yields dmarc=fail on every message not also signed by DKIM. A misaligned DMARC degrades deliverability even with a perfect DKIM.
A full pipeline must validate the three protocols in the same stage:
validate-email-auth:
stage: validate
script:
- bash scripts/validate-spf.sh dns/email.yaml
- bash scripts/validate-dkim.sh dns/email.yaml
- bash scripts/validate-dmarc.sh dns/email.yaml
- bash scripts/validate-bimi.sh dns/email.yaml # optional
Each script hits the matching endpoint: /v1/spf/validate, /v1/dkim/validate, /v1/dmarc/validate. Sharing the HTTP client, response parsing, and thresholds in a common module avoids duplication.

Recommended rollout plan
- Inventory: list your active DKIM selectors per domain and subdomain
- Provision a CaptainDNS API key scoped to
dkim:read+spf:read+dmarc:read - Add the pre-commit hook for the local syntax lint
- Set up a CI workflow that validates modified DNS files in PR
- Wire Terraform into a pre-apply hook that blocks invalid records
- Enable a post-deploy canary: one test email after every
apply - Extend to other protocols: SPF, DMARC, BIMI in the same validation stage
FAQ
Why validate DKIM in CI/CD?
A broken DKIM record does not raise a visible DNS error. It only breaks email deliverability, which surfaces hours later through DMARC reports or user complaints. Validating in CI lets you block the merge before thousands of messages go out with an invalid signature.
What is the difference between local validation and API validation?
A local linter only checks the syntax of a record (tags, base64 format). The CaptainDNS API queries published DNS, parses the record exactly as a receiver would, and returns a score computed on the backend. Local is fast but blind to actual published state.
How do I integrate DKIM validation into GitHub Actions?
Create a workflow triggered on pull requests that modify the DNS folder. Read the domain/selector pairs, call /v1/dkim/validate with curl, parse the response with jq, and fail the job if the state is invalid or the score is below your threshold (50 is a reasonable starting point).
Can I block a terraform apply on an invalid DKIM record?
Yes. Run terraform plan -out=tfplan then terraform show -json to extract the modified TXT records. Validate each one against the CaptainDNS API before running terraform apply. If validation fails, exit the script with a non-zero status code to block the apply.
Should I also validate SPF and DMARC in the same pipeline?
Yes. DKIM, SPF, and DMARC are interdependent. A valid DKIM on its own does not guarantee deliverability if SPF fails or if DMARC is not aligned. Validate all three in the same CI stage, with a fail-fast on the first failure or a consolidated report.
What secrets do I need for the CaptainDNS API in CI?
Only one secret: CAPTAINDNS_API_KEY. Provision it from the console, scope it to the endpoints you need (read DKIM/SPF/DMARC), and store it in your CI secret manager. Avoid admin keys in a CI pipeline, which only needs read access.
What if the record passes locally but fails after deploy?
Three common causes: incomplete DNS propagation (wait 30 minutes), a DNS provider that split the TXT without quotes (check with dig +short TXT selector._domainkey.domain), or a negative cache on an intermediate resolver. The post-deploy smoke test should retry with exponential backoff before triggering a rollback.
Glossary
- Shift-left: the practice of moving quality controls into the early phases of the development cycle (PR, pre-commit), instead of leaving them for production.
- Pre-commit hook: a script executed locally before each Git commit, which can block the commit on error.
- Pre-apply hook: a script executed before
terraform apply(or its Pulumi/OpenTofu equivalent) that validates planned changes. - Canary: a post-deploy test using reduced traffic that catches regressions before they affect the full user base.
- DKIM score: a normalized value (0 to 100) computed by the CaptainDNS API from syntax, key size, algorithm, and RFC recommendations.
- ARC (Authenticated Received Chain): RFC 8617 protocol that preserves DKIM/SPF authentication results across email forwarding.
Test your integration with the DKIM Syntax Check: the tool exposes the same API used in CI/CD, perfect for validating a record before adding it to your pipeline.
Related DKIM guides to read next
- Complete DKIM guide: how it works, DNS configuration, RSA vs Ed25519, and SPF/DMARC integration.
- DKIM fail: causes and fixes: diagnosis of 7 main causes of
dkim=failwith step-by-step fixes.


