Skip to main content

HTTP monitors, white-label, custom domain. Live in 3 minutes.

Monitors & groups

  • Unlimited HTTP monitors
  • Group by service or region

100% customizable

  • Logo & color palette
  • Title & SEO meta
  • Free-form content
New New feature

White-label

  • No CaptainDNS mention
  • Your domain via CNAME
  • Automatic TLS

Real-time & history

  • In sync with your monitors
  • 30-day history
  • Incidents & maintenance

Validate DKIM in your CI/CD: catch broken records before production

By CaptainDNS
Published on May 19, 2026

CI/CD pipeline with DKIM validation: pre-commit hooks, GitHub Actions, GitLab CI, Terraform, and a post-deploy smoke test
TL;DR
  • 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/validate checks what is actually published in DNS
  • GitHub Actions, GitLab CI, and Terraform hooks can block a merge or an apply on 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.

CriterionLocal linterCaptainDNS API
Syntax (v=, k=, p=)YesYes
Key published in DNSNoYes
Effective key sizePartialYes
Normalized scoreNoYes (computed on the backend)
Network dependencyNoYes
Use casepre-commit, syntax lintPR 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.

GitHub Actions pipeline validating a DKIM record via the CaptainDNS API

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.

A single CI stage validating SPF, DKIM, DMARC, and BIMI in parallel via the CaptainDNS API

  1. Inventory: list your active DKIM selectors per domain and subdomain
  2. Provision a CaptainDNS API key scoped to dkim:read + spf:read + dmarc:read
  3. Add the pre-commit hook for the local syntax lint
  4. Set up a CI workflow that validates modified DNS files in PR
  5. Wire Terraform into a pre-apply hook that blocks invalid records
  6. Enable a post-deploy canary: one test email after every apply
  7. 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.


Sources

Similar articles