Skip to main content

Configure DNS records for email

After setting up a mail server, configure its DNS records so that other mail systems can locate the server and authenticate messages sent from the domain. A, MX, PTR, SPF, DKIM, and DMARC records provide the addressing and authentication information used for mail delivery and anti-spoofing checks.

The domain must already be delegated to CDB name servers — the Getting started guide covers zone creation and delegation.

Add each record in the CDB Technical Web Portal using the DNS records portal procedure.

Step 1. Set up an A record

An A record maps a subdomain to an IP address — for a mail server at 192.0.2.1, the record for mail.sample-test.com looks like this:

mail.sample-test.com. IN A 192.0.2.1
DNS records list with an A record added for mail.sample-test.com

Step 2. Set up an MX record

An MX record binds a domain to the mail server. Without an MX record, incoming mail will not work. It is recommended to have more than one mail server so that the second one acts as a backup if the first one fails. For a domain sample-test.com with a mail server at mail.sample-test.com, the record looks like this:

sample-test.com. IN MX 10 mail.sample-test.com.
DNS records list with A and MX records added

Step 3. Set up a PTR record

A PTR record, or reverse DNS record, links an IP address to a domain name to help verify server authenticity. The reverse DNS guide covers the full configuration.

PTR records are provisioned by the hosting provider that owns the IP address. Contact the provider to create a reverse DNS zone (RDNS).

For a mail server at 192.0.2.1, the PTR record looks like this:

1.2.0.192.in-addr.arpa IN PTR mail.sample-test.com.

Step 4. Set up SPF (TXT)

An SPF (Sender Policy Framework) record defines which IP addresses are allowed to send mail from a domain. It is added as a TXT record in DNS and helps prevent spam from being sent. An example SPF record for sample-test.com is:

sample-test.com. IN TXT "v=spf1 mx -all"
DNS records list with A, MX, and SPF TXT records added

This means that only the IP addresses associated with the MX records for sample-test.com — here, 192.0.2.1 for mail.sample-test.com — are allowed to send mail on behalf of the domain. -all means that senders not matched by the preceding SPF mechanisms are not authorized to send mail for the domain.

Step 5. Set up a DKIM record

A DKIM record (DomainKeys Identified Mail) adds a digital signature to outgoing messages, allowing recipients to verify that the mail came from the listed server.

A DKIM record requires adjustments to the mail server configuration, including generating a public/private key pair for signing and verification. The EasyDMARC and DMARCLY key generators automate this process.

Once the key pair is generated, the public key is published as a TXT record under a selector subdomain. For a selector named selector1 on sample-test.com, the record looks like this:

selector1._domainkey.sample-test.com. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC..."

The p= field contains the base64-encoded public key generated for the domain.

Step 6. Set up a DMARC policy (TXT)

A DMARC (Domain-based Message Authentication, Reporting, and Conformance) record defines how to handle emails that fail SPF and DKIM checks. When a message fails the applicable authentication checks, the recipient's mail server applies the DMARC policy: none (no requested action), quarantine, or reject.

The following record sets the policy to quarantine and configures report recipients:

_dmarc.sample-test.com. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@sample-test.com; ruf=mailto:dmarc-forensics@sample-test.com;"
DNS records list with DMARC TXT record entry

In this example, the domain owner has set a DMARC policy to quarantine any emails that fail authentication. The rua parameter specifies the email address where aggregate reports will be sent, while the ruf parameter specifies the email address where forensic (detailed) reports will be sent.

Step 7. Verify the records

The CDB DNS Lookup tool and dig confirm whether each record is published:

dig A mail.sample-test.com
dig MX sample-test.com
dig TXT sample-test.com
dig TXT selector1._domainkey.sample-test.com
dig TXT _dmarc.sample-test.com

For the PTR record, run a reverse lookup against the mail server IP address:

dig -x 192.0.2.1

Configuring a mail server requires six DNS record types. The CDB DNS API manages five of them — PTR records are provisioned by the hosting provider that owns the IP address block.

All requests authenticate with an API token. Export these variables before running the commands:

export GCORE_API_KEY="{YOUR_API_KEY}"
export ZONE_NAME="{YOUR_ZONE_NAME}"

Add an A record

An A record maps the mail subdomain to the server IP address. Receiving servers perform forward-confirmed reverse DNS (FCrDNS) checks — the A record must resolve to the same IP as the PTR record configured on the hosting side.

import os
from gcore import CDB

client = CDB()

rrset = client.dns.zones.rrsets.create(
    "A",
    zone_name=os.environ["ZONE_NAME"],
    rrset_name="mail." + os.environ["ZONE_NAME"],
    ttl=300,
    resource_records=[{"content": ["192.0.2.1"]}],
)
print(rrset.type, rrset.name)
package main

import (
    "context"
    "fmt"
    "os"

    gcore "github.com/G-Core/gcore-go"
    "github.com/G-Core/gcore-go/dns"
)

func main() {
    ctx := context.Background()
    client := gcore.NewClient()
    zone := os.Getenv("ZONE_NAME")

    rr, err := client.DNS.Zones.Rrsets.New(ctx, "A", dns.ZoneRrsetNewParams{
        ZoneName:  zone,
        RrsetName: "mail." + zone,
        Ttl:       gcore.Int(300),
        ResourceRecords: []dns.ZoneRrsetNewParamsResourceRecord{
            {Content: []any{"192.0.2.1"}},
        },
    })
    if err != nil {
        panic(err)
    }
    fmt.Printf("%s %s\n", rr.Type, rr.Name)
}
curl -X POST "https://api.cdb-staging.cdn.orange.com/dns/v2/zones/$ZONE_NAME/mail.$ZONE_NAME/A" \
  -H "Authorization: APIKey $GCORE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ttl": 300,
    "resource_records": [
      {"content": ["192.0.2.1"]}
    ]
  }'

The API returns:

{
  "name": "mail.example.com",
  "type": "A",
  "ttl": 300,
  "resource_records": [
    {"content": ["192.0.2.1"]}
  ]
}

Add an MX record

An MX record designates the mail server for the domain. The priority value (first element of content) determines preference when multiple mail servers exist — a lower number is preferred. The hostname requires a trailing dot to indicate an absolute domain name.

import os
from gcore import CDB

client = CDB()

rrset = client.dns.zones.rrsets.create(
    "MX",
    zone_name=os.environ["ZONE_NAME"],
    rrset_name=os.environ["ZONE_NAME"],
    ttl=300,
    resource_records=[
        {"content": [10, "mail." + os.environ["ZONE_NAME"] + "."]}
    ],
)
print(rrset.type, rrset.name)
package main

import (
    "context"
    "fmt"
    "os"

    gcore "github.com/G-Core/gcore-go"
    "github.com/G-Core/gcore-go/dns"
)

func main() {
    ctx := context.Background()
    client := gcore.NewClient()
    zone := os.Getenv("ZONE_NAME")

    rr, err := client.DNS.Zones.Rrsets.New(ctx, "MX", dns.ZoneRrsetNewParams{
        ZoneName:  zone,
        RrsetName: zone,
        Ttl:       gcore.Int(300),
        ResourceRecords: []dns.ZoneRrsetNewParamsResourceRecord{
            {Content: []any{10, "mail." + zone + "."}},
        },
    })
    if err != nil {
        panic(err)
    }
    fmt.Printf("%s %s\n", rr.Type, rr.Name)
}
curl -X POST "https://api.cdb-staging.cdn.orange.com/dns/v2/zones/$ZONE_NAME/$ZONE_NAME/MX" \
  -H "Authorization: APIKey $GCORE_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"ttl\": 300,
    \"resource_records\": [
      {\"content\": [10, \"mail.$ZONE_NAME.\"]}
    ]
  }"

The API returns:

{
  "name": "example.com",
  "type": "MX",
  "ttl": 300,
  "resource_records": [
    {"content": [10, "mail.example.com."]}
  ]
}

PTR record

PTR records link an IP address to a domain name and are managed by the provider that owns the IP address block, not by CDB Managed DNS.

Contact the hosting provider to configure a reverse DNS zone (RDNS) for the mail server IP address.

Add an SPF record

An SPF record is a TXT record published at the zone apex. The mx mechanism authorizes all IPs listed in MX records to send mail for the domain; -all rejects all other senders.

import os
from gcore import CDB

client = CDB()

rrset = client.dns.zones.rrsets.create(
    "TXT",
    zone_name=os.environ["ZONE_NAME"],
    rrset_name=os.environ["ZONE_NAME"],
    ttl=300,
    resource_records=[{"content": ["v=spf1 mx -all"]}],
)
print(rrset.type, rrset.name)
package main

import (
    "context"
    "fmt"
    "os"

    gcore "github.com/G-Core/gcore-go"
    "github.com/G-Core/gcore-go/dns"
)

func main() {
    ctx := context.Background()
    client := gcore.NewClient()
    zone := os.Getenv("ZONE_NAME")

    rr, err := client.DNS.Zones.Rrsets.New(ctx, "TXT", dns.ZoneRrsetNewParams{
        ZoneName:  zone,
        RrsetName: zone,
        Ttl:       gcore.Int(300),
        ResourceRecords: []dns.ZoneRrsetNewParamsResourceRecord{
            {Content: []any{"v=spf1 mx -all"}},
        },
    })
    if err != nil {
        panic(err)
    }
    fmt.Printf("%s %s\n", rr.Type, rr.Name)
}
curl -X POST "https://api.cdb-staging.cdn.orange.com/dns/v2/zones/$ZONE_NAME/$ZONE_NAME/TXT" \
  -H "Authorization: APIKey $GCORE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ttl": 300,
    "resource_records": [
      {"content": ["v=spf1 mx -all"]}
    ]
  }'

The API returns:

{
  "name": "example.com",
  "type": "TXT",
  "ttl": 300,
  "resource_records": [
    {"content": ["v=spf1 mx -all"]}
  ]
}

Add a DKIM record

A DKIM record is a TXT record published under the selector subdomain selector1._domainkey.zone-name. Generate the public/private key pair using EasyDMARC or DMARCLY, then publish the public key as the TXT content.

import os
from gcore import CDB

client = CDB()

zone = os.environ["ZONE_NAME"]
dkim_value = "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC..."

rrset = client.dns.zones.rrsets.create(
    "TXT",
    zone_name=zone,
    rrset_name="selector1._domainkey." + zone,
    ttl=300,
    resource_records=[{"content": [dkim_value]}],
)
print(rrset.type, rrset.name)
package main

import (
    "context"
    "fmt"
    "os"

    gcore "github.com/G-Core/gcore-go"
    "github.com/G-Core/gcore-go/dns"
)

func main() {
    ctx := context.Background()
    client := gcore.NewClient()
    zone := os.Getenv("ZONE_NAME")

    dkimValue := "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC..."

    rr, err := client.DNS.Zones.Rrsets.New(ctx, "TXT", dns.ZoneRrsetNewParams{
        ZoneName:  zone,
        RrsetName: "selector1._domainkey." + zone,
        Ttl:       gcore.Int(300),
        ResourceRecords: []dns.ZoneRrsetNewParamsResourceRecord{
            {Content: []any{dkimValue}},
        },
    })
    if err != nil {
        panic(err)
    }
    fmt.Printf("%s %s\n", rr.Type, rr.Name)
}
curl -X POST "https://api.cdb-staging.cdn.orange.com/dns/v2/zones/$ZONE_NAME/selector1._domainkey.$ZONE_NAME/TXT" \
  -H "Authorization: APIKey $GCORE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ttl": 300,
    "resource_records": [
      {"content": ["v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC..."]}
    ]
  }'

The API returns:

{
  "name": "selector1._domainkey.example.com",
  "type": "TXT",
  "ttl": 300,
  "resource_records": [
    {"content": ["v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC..."]}
  ]
}

Add a DMARC policy

A DMARC record is a TXT record at _dmarc.zone-name. It instructs receiving servers how to handle messages that fail SPF or DKIM checks — set p to none, quarantine, or reject, and configure rua and ruf to receive aggregate and forensic reports.

import os
from gcore import CDB

client = CDB()

zone = os.environ["ZONE_NAME"]
dmarc_value = (
    "v=DMARC1; p=quarantine; "
    + "rua=mailto:dmarc@" + zone + "; "
    + "ruf=mailto:dmarc-forensics@" + zone + ";"
)

rrset = client.dns.zones.rrsets.create(
    "TXT",
    zone_name=zone,
    rrset_name="_dmarc." + zone,
    ttl=300,
    resource_records=[{"content": [dmarc_value]}],
)
print(rrset.type, rrset.name)
package main

import (
    "context"
    "fmt"
    "os"

    gcore "github.com/G-Core/gcore-go"
    "github.com/G-Core/gcore-go/dns"
)

func main() {
    ctx := context.Background()
    client := gcore.NewClient()
    zone := os.Getenv("ZONE_NAME")

    dmarcValue := "v=DMARC1; p=quarantine; " +
        "rua=mailto:dmarc@" + zone + "; " +
        "ruf=mailto:dmarc-forensics@" + zone + ";"

    rr, err := client.DNS.Zones.Rrsets.New(ctx, "TXT", dns.ZoneRrsetNewParams{
        ZoneName:  zone,
        RrsetName: "_dmarc." + zone,
        Ttl:       gcore.Int(300),
        ResourceRecords: []dns.ZoneRrsetNewParamsResourceRecord{
            {Content: []any{dmarcValue}},
        },
    })
    if err != nil {
        panic(err)
    }
    fmt.Printf("%s %s\n", rr.Type, rr.Name)
}
curl -X POST "https://api.cdb-staging.cdn.orange.com/dns/v2/zones/$ZONE_NAME/_dmarc.$ZONE_NAME/TXT" \
  -H "Authorization: APIKey $GCORE_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"ttl\": 300,
    \"resource_records\": [
      {\"content\": [\"v=DMARC1; p=quarantine; rua=mailto:dmarc@$ZONE_NAME; ruf=mailto:dmarc-forensics@$ZONE_NAME;\"]}
    ]
  }"

The API returns:

{
  "name": "_dmarc.example.com",
  "type": "TXT",
  "ttl": 300,
  "resource_records": [
    {"content": ["v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; ruf=mailto:dmarc-forensics@example.com;"]}
  ]
}

Verify the DNS records

Run DNS queries to confirm each record is published. CDB authoritative nameservers reflect changes immediately; external resolvers may cache responses for up to the record's TTL.

dig A mail.$ZONE_NAME
dig MX $ZONE_NAME
dig TXT $ZONE_NAME
dig TXT selector1._domainkey.$ZONE_NAME
dig TXT _dmarc.$ZONE_NAME

For the PTR record, run a reverse lookup — this queries the IP provider's nameservers, not CDB DNS:

dig -x 192.0.2.1

Declare all five email DNS records in one configuration using the gcore_dns_zone_rrset resource from the CDB Terraform provider v2. PTR records are managed by the hosting provider that owns the IP block — contact that provider to configure reverse DNS separately.

Configure email records

The configuration below creates the zone, the mail server A record, and the MX, SPF, DKIM, and DMARC TXT records. Apply it once to provision the full email DNS setup.

resource "gcore_dns_zone" "example" {
  name = "yourdomain.com"
}

# A record: mail server address
resource "gcore_dns_zone_rrset" "mail_a" {
  zone_name  = gcore_dns_zone.example.name
  rrset_name = "mail.${gcore_dns_zone.example.name}"
  rrset_type = "A"
  ttl        = 300

  resource_records = [
    { content = ["192.0.2.1"] },  # replace with the mail server IP
  ]
  # terraform import gcore_dns_zone_rrset.mail_a yourdomain.com/mail.yourdomain.com/A
}

# MX record: directs incoming mail to the mail server
resource "gcore_dns_zone_rrset" "mx" {
  zone_name  = gcore_dns_zone.example.name
  rrset_name = gcore_dns_zone.example.name
  rrset_type = "MX"
  ttl        = 300

  resource_records = [
    { content = ["10", "mail.${gcore_dns_zone.example.name}."] },  # priority, then hostname with trailing dot
  ]
  # terraform import gcore_dns_zone_rrset.mx yourdomain.com/yourdomain.com/MX
}

# SPF record: authorizes the mail server to send on behalf of the domain
resource "gcore_dns_zone_rrset" "spf" {
  zone_name  = gcore_dns_zone.example.name
  rrset_name = gcore_dns_zone.example.name
  rrset_type = "TXT"
  ttl        = 300

  resource_records = [
    { content = ["v=spf1 mx -all"] },
  ]
  # terraform import gcore_dns_zone_rrset.spf yourdomain.com/yourdomain.com/TXT
}

# DKIM record: publishes the public key for message signing verification
resource "gcore_dns_zone_rrset" "dkim" {
  zone_name  = gcore_dns_zone.example.name
  rrset_name = "selector1._domainkey.${gcore_dns_zone.example.name}"
  rrset_type = "TXT"
  ttl        = 300

  resource_records = [
    { content = ["v=DKIM1; k=rsa; p=<BASE64_PUBLIC_KEY>"] },  # replace with the generated public key
  ]
  # terraform import gcore_dns_zone_rrset.dkim yourdomain.com/selector1._domainkey.yourdomain.com/TXT
}

# DMARC record: policy for messages that fail SPF or DKIM checks
resource "gcore_dns_zone_rrset" "dmarc" {
  zone_name  = gcore_dns_zone.example.name
  rrset_name = "_dmarc.${gcore_dns_zone.example.name}"
  rrset_type = "TXT"
  ttl        = 300

  resource_records = [
    { content = ["v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com; ruf=mailto:dmarc-forensics@yourdomain.com;"] },
  ]
  # terraform import gcore_dns_zone_rrset.dmarc yourdomain.com/_dmarc.yourdomain.com/TXT
}

Generate the DKIM key pair using EasyDMARC or DMARCLY, then paste the base64 public key into the p= field.

Delete the email configuration

Remove all resource blocks — Terraform deletes each RRset on the next terraform apply. Remove gcore_dns_zone.example last, or let Terraform handle the dependency order automatically.

# Remove or comment out all blocks:
# resource "gcore_dns_zone_rrset" "mail_a"  { ... }
# resource "gcore_dns_zone_rrset" "mx"      { ... }
# resource "gcore_dns_zone_rrset" "spf"     { ... }
# resource "gcore_dns_zone_rrset" "dkim"    { ... }
# resource "gcore_dns_zone_rrset" "dmarc"   { ... }
# resource "gcore_dns_zone" "example"       { ... }
terraform apply