Skip to content
LiveStart building for free · CA$100 signup credit, up to CA$300 totalCA$1 verification payment credited back · Ends December 31, 2026
Get started for free →

Configure Email Authentication DNS

Email authentication DNS records help receiving systems distinguish mail sent by your approved systems from mail that only claims to use your domain. This tutorial configures dedicated DKIM and DMARC record sets on an existing ZCP DNS zone. It also explains how to handle SPF safely.

Choose one owner for each record set: the zcp CLI or Terraform/OpenTofu. Do not use both tools to manage the same name and type. The CLI accepts one content value per create and deletes records by their complete name and type. Terraform models records as name-and-type record sets.

By the end, you will have:

  • An inventory of the DNS zone and the systems permitted to send mail
  • A published DKIM record or CNAME at a provider-issued selector
  • A DMARC record in monitoring mode with reports sent to a mailbox you control
  • A verification routine for DNS and authenticated mail from every sender

This guide assumes ZCP already hosts the authoritative zone. To create a zone and delegate it at your registrar, follow Host a Domain on ZCP (CLI).

You need:

  • A ZCP DNS zone for a domain you control
  • The zcp CLI authenticated with a token, or Terraform or OpenTofu with ZCP_BEARER_TOKEN set in your shell
  • Access to every system that sends mail using the domain, including application delivery, notifications, support, billing, marketing, and device or service alerts
  • A mailbox dedicated to DMARC aggregate reports, such as dmarc-reports@example.ca
  • The exact DKIM and SPF instructions from each sending system

The example.ca names in this guide are illustrative placeholders. Replace them with a domain you control. Do not paste a private DKIM key into DNS. DNS publishes a public key or a provider-issued CNAME target only.

Start by recording the current state. Save the output in your change record. It gives you the zone slug, name servers, and the record sets you must preserve.

zcp dns list
zcp dns show <domain-slug> --output json

Ask both authoritative ZCP name servers for the records that matter. Replace example.ca with your domain.

for ns in ns1.zsoftly.ca ns2.zsoftly.ca; do
dig TXT example.ca "@$ns" +short
dig TXT _dmarc.example.ca "@$ns" +short
dig TXT selector._domainkey.example.ca "@$ns" +short
dig CNAME selector._domainkey.example.ca "@$ns" +short
done

List each legitimate sender, the domain it uses in the visible From address, the envelope sender domain, and its DKIM selector or CNAME instructions. Include systems that only send occasionally. Assign an owner who approves changes and reviews DMARC reports. Keep the inventory with the record values and the date each sender was last tested.

The current ZCP CLI creates or deletes an entire name-and-type record set. It has no in-place record update command. The current Terraform provider also replaces a zcp_dns_record when its content, name, type, or TTL changes. For an existing SPF record or another multi-value TXT record set, use the established DNS change process that can preserve the full intended set. Do not create a second root TXT resource or run a delete-and-recreate change during normal mail delivery.

SPF, DKIM, and DMARC use different DNS names and answer different questions.

  • SPF is a TXT policy at the envelope-sender domain. When that domain is the zone apex, the record name is @. It can also be a subdomain, such as bounce.example.ca. Keep a single, complete policy and account for DNS lookups in all authorized includes.
  • DKIM is a TXT record or CNAME at <selector>._domainkey.example.ca. It lets receivers retrieve the public material needed to validate a message signature.
  • DMARC is a TXT record at _dmarc.example.ca. It tells receivers what policy to apply when the visible From domain does not have aligned authenticated mail, and it requests reports.

The receiving system needs SPF or DKIM to authenticate and align with the visible From domain for a DMARC pass. DNS configuration alone cannot make that happen. Configure each sending system to use the correct domain, enable its DKIM signing, and send a test message after publication.

For a standards reference on SPF record selection and lookup limits, see RFC 7208.

Use the CLI for a small, reviewed change to an unused dedicated record name. Use Terraform or OpenTofu when your team already manages this zone as code and reviews plans. The examples below create only the dedicated DKIM and DMARC record sets after you confirm that each name is absent.

Do not apply the CLI commands if Terraform or OpenTofu owns the same record set. Do not apply the Terraform configuration if someone changes the same record set through the CLI or console. Record ownership alongside your sender inventory.

If the zone apex is the envelope-sender domain and it has no TXT record set at all, use one ownership path to create a single SPF policy with the complete value supplied by all authorized senders. Do this only after you have checked every sender and the value’s DNS lookups. An active sending domain with an existing root TXT set needs a separate reviewed change because this tutorial cannot safely merge or update that shared set.

Set variables for your zone and the instructions supplied by a sending system. The DKIM value below is read from your terminal to avoid putting it in shell history. Enter the complete DNS presentation: a quoted string for a short TXT value, or the provider’s complete quoted, chunked presentation for a long key. Enter the exact CNAME target when the sender asks for a CNAME.

export DOMAIN_SLUG="exampleca"
export DOMAIN_NAME="example.ca"
export DKIM_SELECTOR="mailer1"
read -r DKIM_CONTENT

Confirm that the selector name and _dmarc do not already have a record set. Query the actual authoritative name servers from zcp dns show <domain-slug>. Require a successful authoritative answer from both. A timeout, SERVFAIL, or REFUSED response is not permission to create a record.

for ns in ns1.zsoftly.ca ns2.zsoftly.ca; do
dig TXT "$DKIM_SELECTOR._domainkey.$DOMAIN_NAME" "@$ns" +norecurse +noall +comments +answer
dig CNAME "$DKIM_SELECTOR._domainkey.$DOMAIN_NAME" "@$ns" +norecurse +noall +comments +answer
dig TXT "_dmarc.$DOMAIN_NAME" "@$ns" +norecurse +noall +comments +answer
done

Replace the illustrative ns1.zsoftly.ca and ns2.zsoftly.ca values with the authoritative servers listed for your zone. If any answer section contains a record, stop and review its existing owner.

Create the DKIM TXT record only when the sender issued a TXT value. ZCP appends the zone to the relative name, so use mailer1._domainkey, not the complete domain name.

zcp dns record-create \
--domain "$DOMAIN_SLUG" \
--name "$DKIM_SELECTOR._domainkey" \
--type TXT \
--content "$DKIM_CONTENT" \
--ttl 3600

If the sending system issued a CNAME instead, create a CNAME with its exact target and do not also create the TXT record.

zcp dns record-create \
--domain "$DOMAIN_SLUG" \
--name "$DKIM_SELECTOR._domainkey" \
--type CNAME \
--content "<provider-issued-target>." \
--ttl 3600

Create DMARC in monitoring mode first. The reporting mailbox must exist and its owner must review the reports.

zcp dns record-create \
--domain "$DOMAIN_SLUG" \
--name _dmarc \
--type TXT \
--content '"v=DMARC1; p=none; rua=mailto:dmarc-reports@example.ca"' \
--ttl 3600

If, and only if, authoritative queries confirm there is no root TXT record set, you can publish the one complete SPF policy that all authorized senders provide. Enter its complete DNS presentation when prompted. Do not use v=spf1 -all for an active sending domain unless no system is authorized to send with it.

dig TXT "$DOMAIN_NAME" @ns1.zsoftly.ca +norecurse +noall +comments +answer
dig TXT "$DOMAIN_NAME" @ns2.zsoftly.ca +norecurse +noall +comments +answer
read -r SPF_CONTENT
zcp dns record-create \
--domain "$DOMAIN_SLUG" \
--name @ \
--type TXT \
--content "$SPF_CONTENT" \
--ttl 3600

TXT data must reach DNS as quoted character strings. A sender’s setup page can display an unquoted value for copy and paste. In that case, obtain its DNS presentation before you create the record. A single string needs surrounding quotes. A long value can need quoted chunks of no more than 255 bytes, which resolvers concatenate as one TXT value. Keep provider-issued chunks intact. Do not split a public key into separate DNS records, and do not invent a key. Verify the published answer against the sender’s instructions before enabling signing.

Publish DKIM and DMARC with Terraform or OpenTofu

Section titled “Publish DKIM and DMARC with Terraform or OpenTofu”

Use this path only when infrastructure as code owns the dedicated records. Set the token in your shell. Do not put it in main.tf or commit it.

export ZCP_BEARER_TOKEN="<your-token>"

Create main.tf. This configuration refers to an existing zone by its slug. It deliberately does not declare the zone itself, so an apply cannot replace it.

terraform {
required_providers {
zcp = {
source = "zsoftly/zcp"
version = "~> 0.2.0"
}
}
}
variable "domain_slug" {
type = string
}
variable "dkim_selector" {
type = string
}
variable "dkim_txt_content" {
type = string
}
provider "zcp" {}
resource "zcp_dns_record" "dkim" {
domain = var.domain_slug
name = "${var.dkim_selector}._domainkey"
type = "TXT"
content = var.dkim_txt_content
ttl = 3600
lifecycle {
prevent_destroy = true
}
}
resource "zcp_dns_record" "dmarc" {
domain = var.domain_slug
name = "_dmarc"
type = "TXT"
content = "\"v=DMARC1; p=none; rua=mailto:dmarc-reports@example.ca\""
ttl = 3600
lifecycle {
prevent_destroy = true
}
}

If the root TXT record set is absent, you can add this third resource after you have validated the complete policy from every authorized sender. Do not add it to a configuration for a root TXT set that already exists.

variable "spf_txt_content" {
type = string
}
resource "zcp_dns_record" "spf" {
domain = var.domain_slug
name = "@"
type = "TXT"
content = var.spf_txt_content
ttl = 3600
lifecycle {
prevent_destroy = true
}
}

If the sender issued a CNAME selector, replace both the dkim_txt_content variable and the zcp_dns_record.dkim resource above with the following variable and resource. Do not declare both DKIM resources at the same selector name.

variable "dkim_cname_target" {
type = string
}
resource "zcp_dns_record" "dkim_cname" {
domain = var.domain_slug
name = "${var.dkim_selector}._domainkey"
type = "CNAME"
content = var.dkim_cname_target
ttl = 3600
lifecycle {
prevent_destroy = true
}
}

Set dkim_cname_target to the complete provider-issued hostname with a trailing dot, for example selector.provider.example.ca.. A target without the final dot can be treated as relative to your zone.

Store the variable values with your DNS configuration. A DKIM TXT value contains public key material. A DKIM CNAME value contains a provider-issued DNS target. Keep the bearer token in the environment and out of source control.

terraform init
terraform plan -var='domain_slug=exampleca' -var='dkim_selector=mailer1'
terraform apply -var='domain_slug=exampleca' -var='dkim_selector=mailer1'

OpenTofu uses the same configuration and subcommands with tofu in place of terraform. Read the plan before approval. A zcp_dns_record represents the full record set identified by domain, name, and type. Its content is write-only, so the provider does not detect out-of-band content changes. A change in the configuration forces replacement. The prevent_destroy guard blocks a planned destroy while its resource block remains in the configuration. Removing the resource block also removes the guard, so it is not a rollback method. Read every plan. Remove the guard only for an approved maintenance change after you have reviewed the replacement and rollback plan.

To bring an existing dedicated DKIM TXT record set under Terraform, configure the zcp_dns_record.dkim resource above to match its current values, then import it:

terraform import zcp_dns_record.dkim exampleca/TXT/mailer1._domainkey

The record import format is <domain-slug>/<type>/<relative-name>. The provider cannot read record content back in a comparable form, so import seeds identity only. Run terraform plan after import and do not approve a replacement of a live record set until you have confirmed the full planned value and rollback plan.

First query both authoritative servers. Get their names from zcp dns show <domain-slug> for your zone. The names below are illustrative. Then query a public recursive resolver after delegation and caches have refreshed.

for ns in ns1.zsoftly.ca ns2.zsoftly.ca; do
dig TXT mailer1._domainkey.example.ca "@$ns" +short
dig TXT _dmarc.example.ca "@$ns" +short
done
dig TXT mailer1._domainkey.example.ca @1.1.1.1 +short
dig TXT _dmarc.example.ca @1.1.1.1 +short

For a CNAME-based selector, replace the first TXT query with CNAME. Compare the answer with the exact instructions from the sending system. A successful DNS lookup does not prove that the sender is signing messages, so this is only the first check.

Send a message from every legitimate source to a test mailbox you control. Review the received message headers from a trusted receiver. Its Authentication-Results header should show the authentication results for the message. Confirm the visible From domain aligns with a passing SPF or DKIM result. Test replies, application notices, forwarded mail, and any sending path that uses a different envelope sender or selector.

Start with p=none and collect reports. Reconcile each report source against your sender inventory. Fix missing DKIM signing, missing SPF authorization, or domain alignment before changing policy.

When the reports and delivery tests show that legitimate traffic authenticates correctly, move to p=quarantine. Continue review during the change. Move to p=reject only when the remaining failures are understood and accepted. Signed messages can be forwarded or changed by intermediary systems, so include those paths in your review before tightening the policy.

Use the same management path that owns _dmarc to make each change. With the CLI, record the prior record content before the approved maintenance change, then restore that exact content if delivery testing fails. With Terraform or OpenTofu, keep the prior reviewed configuration and use a reviewed plan to return to it. Do not run a broad destroy for DNS changes.

Review DMARC reports, sender inventory, DNS changes, and test evidence on a regular schedule. Give new systems a sender review before they use your domain. Remove retired senders from SPF and retire unused DKIM selectors and signing configuration after mail queues and verification needs are clear.

DNS does not replace controls outside DNS:

  • Require multi-factor authentication for administrator and DNS access.
  • Apply least privilege and review recovery access, forwarding, delegation, and offboarding.
  • Tune inbound filtering and impersonation controls, then review quarantine results and narrow exceptions.
  • Review external group membership and data-sharing settings.
  • Treat message encryption certificates and AI-connected mail features as separate decisions about recipients, key recovery, data access, and cost.

For the policy and ownership decisions behind this technical work, read Email Security Needs an Owner.