Mert Özen Further With Every Line
DevOps Domain, DNS and CDN — #02

Setting Up DNS Records for a Web App: Apex, www, api, CNAME Flattening, CAA and Verification TXT Records

Mert Özen Nov 14, 2026 16 min 2 views
Setting Up DNS Records for a Web App: Apex, www, api, CNAME Flattening, CAA and Verification TXT Records

You can't put a CNAME at the apex because a name with a CNAME can have no other records and the apex must have SOA and NS; the fix is your provider's ALIAS, CNAME flattening or Azure DNS alias feature. We build Bookstore's apex, www, api and staging records in BIND, watch ALIAS's TTL behaviour in PowerDNS, and look at App Service and Front Door verification TXT records, how CAA lookup climbs to parent names, and a script that finds dangling CNAMEs, all with real runs.

Version note: The examples in this post were run with BIND 9.20, PowerDNS Authoritative Server 5.0.8 and dig 9.20 on Docker 29; the Azure and Cloudflare steps are taken from the official documentation as of 14 November 2026 and were not run in an Azure or Cloudflare account.

Guess First

In the previous post we bought the domain for Bookstore, the online bookstore used as the shared example across this site's series, and delegated its DNS to a separate provider. Now Ayşe wants to move Bookstore's Angular app to a PaaS, and the platform tells her to "point your domain to bookstore-web.azurewebsites.net with a CNAME". In the DNS panel she enters @ (that is, bookstore.example itself) as the name and CNAME as the type, and tries to save.

Guess: will the panel accept the record? If not, why not? And if the panel had not objected, what would have happened to Bookstore's mail and verification records? In this post we build Bookstore's whole DNS map, starting with the answer to that question.

Bookstore's Target Map

First, decide what goes where. Bookstore's web front end (bookstore-web, Angular) and its API (Bookstore.Api) run in different places, and staging follows the same naming as production:

NameRecordTargetPurpose
bookstore.exampleA + AAAA203.0.113.10, 2001:db8:10::10Angular app behind Nginx
www.bookstore.exampleCNAMEbookstore.example301 redirect to the apex
api.bookstore.exampleCNAME + TXTbookstore-api.azurewebsites.netBookstore.Api on App Service
staging.bookstore.exampleA203.0.113.11Staging web
staging-api.bookstore.exampleCNAME + TXTbookstore-api-staging.azurewebsites.netStaging API

Two decisions may stand out. First, the canonical address is the apex (bookstore.example) and www redirects to it. It could be the other way round; as we covered in the Technical SEO series posts on redirects and canonical URLs, what matters is picking one and sending the other to it with a 301. DNS cannot redirect: a CNAME only says "the answer for this name is the same as the answer for that name"; it does not change the www in the browser's address bar. The 301 comes from the web server (or, as we'll see in the sixth post, a CDN rule). Second, staging names are flat like staging-api, not nested like staging.api. That way a single *.bookstore.example wildcard certificate covers all of them; as we saw in the first post of the SSL/TLS Certificates and HTTPS series, a wildcard matches only one level.

The zone file for this map, in the lab's DNS provider container:

$TTL 3600
@               IN SOA   ns1.dnshost.example. hostmaster.bookstore.example. 2026111402 7200 3600 1209600 300
@               IN NS    ns1.dnshost.example.

@               IN A     203.0.113.10
@               IN AAAA  2001:db8:10::10
www             IN CNAME bookstore.example.
staging         IN A     203.0.113.11

api             IN CNAME bookstore-api.azurewebsites.net.
asuid.api       IN TXT   "9ABEB8D35A69D8E0F4A0BFF18E629B9F43A21C43C122B267F86E53CCD94D5DDD"
staging-api     IN CNAME bookstore-api-staging.azurewebsites.net.
asuid.staging-api IN TXT "E0569773CCBDE09F6A6E5713B605DB0178702CB5C816155CD142437EF63CE573"

@               IN CAA   0 issue "letsencrypt.org"
@               IN CAA   0 issue "digicert.com"
@               IN CAA   0 issuewild ";"
@               IN CAA   0 iodef "mailto:security@bookstore.example"
@               IN TXT   "google-site-verification=pX3c9QeLr0vT2mWb8yKdN5hJ7sFa1uZo4iGyE6"

promo           IN CNAME bookstore-promo-2025.azurewebsites.net.

The promo line at the bottom is not on the map; it is left over from last year's campaign and nobody deleted it. We come back to it at the end of the post. We will go through the asuid TXT records and the CAA records in turn as well.

Why Can't the Apex Be a CNAME?

I tried loading the record from Guess First into BIND:

$ cat apex-cname.zone
$TTL 3600
@       IN SOA   ns1.dnshost.example. hostmaster.bookstore.example. 2026111401 7200 3600 1209600 300
@       IN NS    ns1.dnshost.example.
@       IN CNAME bookstore-web.azurewebsites.net.
$ named-checkzone bookstore.example apex-cname.zone
dns_master_load: apex-cname.zone:4: bookstore.example: CNAME and other data
dns_master_load: apex-cname.zone:4: bookstore.example: CNAME and other data
zone bookstore.example/IN: loading from master file apex-cname.zone failed: CNAME and other data
zone bookstore.example/IN: not loaded due to errors.

The rule is: if a name has a CNAME, it can have no other records. A CNAME means "for everything about this name, look over there"; if the same name also had an MX or TXT, a resolver would not know which to believe. At the apex, SOA and NS records are mandatory, so a CNAME at the apex always breaks this rule. Had the panel not objected, resolvers following the CNAME would look for Bookstore's MX, SPF and verification TXT records on the azurewebsites.net side. Mail would stop flowing and Search Console verification would drop. It would be one of the hardest DNS problems to debug, because the website would keep working.

The same rule applies outside the apex, and the most common way you hit it is this: you point www at a platform with a CNAME, and then a SaaS tool asks you to "add a TXT on www for verification":

$ named-checkzone bookstore.example www-cname-txt.zone
dns_master_load: www-cname-txt.zone:6: www.bookstore.example: CNAME and other data
zone bookstore.example/IN: loading from master file www-cname-txt.zone failed: CNAME and other data
zone bookstore.example/IN: not loaded due to errors.

That is why platforms like Azure put the verification TXT not next to the CNAME but on a separate name such as asuid.www or _dnsauth.www.

ALIAS, ANAME, CNAME Flattening and Azure DNS Alias Records

Pointing the apex at a hostname is such a common need that every major DNS provider has written its own solution. The names differ but the idea is the same: instead of leaving the CNAME to the resolver, the authoritative server resolves the target itself and returns the result as plain A/AAAA records. There is no standard record type; ALIAS and ANAME are provider-side features with no equivalent in the zone file format itself. You can see this behaviour by running it in the open source PowerDNS. I added a temporary PowerDNS container to the lab and pointed the apex at a well-known public target, example.net, with an ALIAS:

# pdns.conf
launch=bind
bind-config=/etc/pdns-lab/named.conf
local-address=0.0.0.0
resolver=1.1.1.1
expand-alias=yes

; bookstore.example.zone
$TTL 3600
@       IN SOA   ns1.dnshost.example. hostmaster.bookstore.example. 2026111401 7200 3600 1209600 300
@       IN NS    ns1.dnshost.example.
@       IN ALIAS example.net.
www     IN CNAME example.net.
$ dig @192.0.2.30 bookstore.example A +norec +noall +comments +answer
;; flags: qr aa; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
bookstore.example.	61	IN	A	104.20.21.8
bookstore.example.	61	IN	A	172.66.175.59
$ dig @192.0.2.30 bookstore.example AAAA +norec +noall +answer
bookstore.example.	300	IN	AAAA	2606:4700:10::6814:1508
bookstore.example.	300	IN	AAAA	2606:4700:10::ac42:af3b
$ dig @192.0.2.30 www.bookstore.example A +norec +noall +answer
www.bookstore.example.	3600	IN	CNAME	example.net.
$ dig @1.1.1.1 example.net A +noall +answer
example.net.		60	IN	A	104.20.21.8
example.net.		60	IN	A	172.66.175.59

Three things show up. The apex question got plain A and AAAA answers with the aa flag, not a CNAME, and they could sit next to SOA and NS. www returned an ordinary CNAME, leaving the target to the resolver. The most interesting part is the TTL: although the zone says 3600, the apex answer's TTL is 61. PowerDNS asked its own resolver for the target and passed on the remaining TTL from there. So the apex's cache lifetime is controlled by the target, not by your zone. When the target changes its IP, the apex follows; that is the point of ALIAS.

Three commercial equivalents of this behaviour:

  • Cloudflare CNAME flattening: you write a CNAME at the apex and Cloudflare follows the chain itself, returning A/AAAA. This is the default at the apex; for other names, "flatten all CNAMEs" is on paid plans. Cloudflare's documentation gives two warnings: because a flattened name does not return a CNAME, third-party verifications that check for the CNAME itself can fail; and if the target has no A/AAAA records, the answer comes back empty and looks like a propagation problem.
  • Azure DNS alias records: A, AAAA and CNAME records can point to an Azure resource (a Standard SKU public IP, a Traffic Manager profile, an Azure CDN endpoint, a Front Door endpoint) instead of a DNS value, and they can be used at the apex. If the resource is deleted, the record becomes an empty record set, so it does not keep pointing at a deleted IP. App Service itself is not on that list; for App Service, Microsoft recommends an A record to the app's IP at the apex.
  • Other providers' ALIAS/ANAME: the logic is the same, but how the TTL is set, whether IPv6 is supported and what is returned when the target cannot be resolved differ from provider to provider. Look for answers to those three questions in the provider's documentation before you use it.

There is one more important side effect: with flattening, the provider's resolver resolves the target, not the user's. If the target is a CDN that returns different IPs depending on the user's location, proximity is computed for the provider's resolver rather than for the user. Large providers compensate with EDNS Client Subnet or their own anycast networks, but that too varies by provider. We'll see the details when we put Bookstore's apex behind a CDN in the fifth and sixth posts.

Connecting to a PaaS: App Service, Container Apps and Front Door

Before a PaaS platform accepts your domain, it asks for two things: a record that sends traffic to it (A or CNAME) and a verification record proving that the domain is yours. The current tables on Microsoft Learn are:

App Service: for the apex, an A record to the app's IP address and a TXT named asuid. For a subdomain, a CNAME to <app>.azurewebsites.net (recommended over an A record because the IP can change) and a TXT at asuid.<subdomain>. The TXT value is the app's "domain verification ID". Microsoft writes that the TXT is not strictly required but is highly recommended, because it prevents other App Service apps from taking over your subdomain. The Free (F1) tier does not support custom domains.

Container Apps: the same asuid scheme; an A record to the environment's IP for the apex, a CNAME to the app's generated address for a subdomain. The free managed certificate has two extra requirements, both directly about DNS. The CNAME must point straight at the app's address; if there is an intermediate CNAME such as Traffic Manager or Cloudflare in between, the certificate cannot be issued or renewed. If the domain has a CAA record, a 0 issue "digicert.com" entry is required.

Azure Front Door Standard/Premium: a TXT named _dnsauth.<subdomain> for validation, and a CNAME to the Front Door endpoint (*.azurefd.net) for traffic. For the apex you need an alias record if you use Azure DNS, or the flattening/ALIAS feature of your provider otherwise.

In the lab, the api records follow the App Service scheme:

$ dig @192.0.2.20 api.bookstore.example A +norec +noall +answer
api.bookstore.example.	3600	IN	CNAME	bookstore-api.azurewebsites.net.
$ dig @192.0.2.20 asuid.api.bookstore.example TXT +norec +noall +answer
asuid.api.bookstore.example. 3600 IN	TXT	"9ABEB8D35A69D8E0F4A0BFF18E629B9F43A21C43C122B267F86E53CCD94D5DDD"

The managed certificate's dependency on DNS is the part people usually forget. When the platform renews the certificate it validates the domain again: does the CNAME still go to the right place, does CAA allow it, is the verification record still there? A domain set up a year ago and working fine can fail at renewal because someone said "let's clean up unused TXT records", or because a new CA was added to CAA and DigiCert was forgotten. The certificate side is covered in the SSL/TLS Certificates and HTTPS series post on certificates on managed platforms.

CAA: Which CA May Issue Certificates?

CAA is defined in RFC 8659. A CA must check CAA before issuing a certificate. Bookstore's records:

$ dig @192.0.2.20 bookstore.example CAA +norec +noall +answer
bookstore.example.	3600	IN	CAA	0 iodef "mailto:security@bookstore.example"
bookstore.example.	3600	IN	CAA	0 issuewild ";"
bookstore.example.	3600	IN	CAA	0 issue "letsencrypt.org"
bookstore.example.	3600	IN	CAA	0 issue "digicert.com"

Read it like this: only Let's Encrypt (for Certbot on the Nginx side) and DigiCert (for the App Service and Container Apps managed certificates) may issue certificates for Bookstore's names. issuewild ";" allows nobody to issue a wildcard; when you need a wildcard certificate, you have to change this line deliberately. Per RFC 8659, if issuewild is present for a name, the issue lines are ignored for wildcards. iodef tells the CA where it can report a request that violates the policy; CAs are not required to support it. The leading 0 is the flags field; 128 would mean "if you don't understand this tag, don't issue".

The most misunderstood part of CAA is how the lookup works. The CA looks at the exact name first; if there is no CAA there, it climbs to the parent and uses the first CAA set it finds. If it finds none at any level, any CA may issue. A short script that mimics this:

#!/bin/sh
# Usage: caa-lookup.sh <name> <server>
# Finds the CAA record set a CA would use: the name itself, then each parent.
name=${1%.}; server=$2
while [ -n "$name" ]; do
    caa=$(dig @"$server" "$name" CAA +noall +answer | awk '$4 == "CAA"')
    if [ -n "$caa" ]; then
        echo "relevant CAA set found at $name:"
        echo "$caa" | awk '{$1=$2=$3=$4=""; print " " $0}'
        exit 0
    fi
    echo "no CAA at $name, climbing"
    case "$name" in *.*) name=${name#*.} ;; *) name="" ;; esac
done
echo "no CAA anywhere: any CA may issue"
$ ./caa-lookup.sh staging-api.bookstore.example 192.0.2.20
no CAA at staging-api.bookstore.example, climbing
relevant CAA set found at bookstore.example:
     0 issue "letsencrypt.org"
     0 iodef "mailto:security@bookstore.example"
     0 issue "digicert.com"
     0 issuewild ";"
$ ./caa-lookup.sh www.google.com 1.1.1.1
no CAA at www.google.com, climbing
relevant CAA set found at google.com:
     0 issue "pki.goog"
$ ./caa-lookup.sh www.example.org 1.1.1.1
no CAA at www.example.org, climbing
no CAA at example.org, climbing
no CAA at org, climbing
no CAA anywhere: any CA may issue

So CAA on the apex covers every name below it. If a subdomain needs a different rule (say, a different CA only for api), you put a CAA record on that name and the lookup stops there. Names with a CNAME have a subtlety: since api is a CNAME, the CA's resolver follows it for the CAA query and uses a CAA set at the target (on the azurewebsites.net side) if one exists. If the target has none, the climb continues in your zone from bookstore.example.

Verification TXT Records and Zone Hygiene

Bookstore's zone has one Search Console TXT for now. A year later this number is usually ten: the analytics tool, the e-mail provider, a SaaS help desk, Microsoft 365, Apple, Atlassian, Facebook domain verification... Each one asks for a TXT on the apex. It looks harmless but causes two problems. The TXT answer for the apex grows; many resolvers and some networks struggle with large UDP answers and have to fall back to TCP. More importantly, nobody dares to delete records when nobody knows whom they belong to.

The rule is simple: next to every verification record, write down its owner and purpose. If you keep the zone as code (the topic of the fourth post) that is a comment line; if you use a panel, it is the provider's "comment" field. Be careful when deleting: Google Search Console re-checks the verification record periodically and verification drops if the record is removed. So "it's verified, I can delete it now" is wrong for most services. The ones to delete are those belonging to services you no longer use.

Dangling CNAMEs and Subdomain Takeover

Now back to the promo line. Last year an App Service app was created for a campaign and promo.bookstore.example was pointed at it with a CNAME. When the campaign ended, the app was deleted and the DNS record forgotten. This is a dangling CNAME: it points at something that no longer exists. The risk: the name bookstore-promo-2025 is now free on Azure and anyone can create a new app with it. The moment they do, promo.bookstore.example starts showing their site. A fake campaign page under Bookstore's name, on Bookstore's domain. Once cookies scoped to the domain and CSP rules come into play, the impact can go beyond a phishing page.

A script that checks the targets of every CNAME in the zone:

#!/bin/sh
# Usage: find-dangling-cnames.sh <zone-name> <zone-file> <resolver>
zone=$1; file=$2; resolver=$3
dump=$(named-checkzone -D -o - "$zone" "$file" 2>/dev/null)
echo "$dump" | awk '$4 == "CNAME" {print $1, $5}' |
while read -r name target; do
    # targets inside our own zone are checked against the zone itself
    case "$target" in
        *"$zone.") echo "$dump" | awk -v t="$target" '$1 == t {f=1} END {exit !f}' && status=NOERROR || status=NXDOMAIN ;;
        *) status=$(dig @"$resolver" "$target" A +noall +comments | grep -o 'status: [A-Z]*' | cut -d' ' -f2) ;;
    esac
    asuid=$(echo "$dump" | awk -v n="asuid.$name" '$1 == n && $4 == "TXT" {print "yes"}')
    case "$status" in
        NOERROR) echo "OK        $name -> $target" ;;
        NXDOMAIN) echo "DANGLING  $name -> $target (verification TXT: ${asuid:-no})" ;;
        *) echo "CHECK     $name -> $target ($status)" ;;
    esac
done
$ ./find-dangling-cnames.sh bookstore.example bookstore.example.zone 1.1.1.1
DANGLING  api.bookstore.example. -> bookstore-api.azurewebsites.net. (verification TXT: yes)
DANGLING  promo.bookstore.example. -> bookstore-promo-2025.azurewebsites.net. (verification TXT: no)
DANGLING  staging-api.bookstore.example. -> bookstore-api-staging.azurewebsites.net. (verification TXT: yes)
OK        www.bookstore.example. -> bookstore.example.

None of the App Service apps actually exist in the lab, so all three Azure targets return NXDOMAIN on public DNS. Even that is instructive: api and staging-api have an asuid TXT next to them. If someone in another subscription creates an app with that name and tries to add the domain to it, App Service checks this verification ID and rejects the domain because it doesn't match. promo has no such protection. In a real system the first and third lines would be OK, and promo would stay exactly as it is.

The NXDOMAIN check doesn't work on every platform. Some platforms answer for all of their subdomains with a wildcard:

$ dig @1.1.1.1 bookstore-docs-2019.github.io A +noall +answer
bookstore-docs-2019.github.io. 3600 IN	A	185.199.108.153
bookstore-docs-2019.github.io. 3600 IN	A	185.199.109.153
bookstore-docs-2019.github.io. 3600 IN	A	185.199.110.153
bookstore-docs-2019.github.io. 3600 IN	A	185.199.111.153

There is no such account, yet the name resolves. On platforms like GitHub Pages you catch a dangling record at the HTTP level, not in DNS: does the page return the platform's "there is no site here" response? The most robust fix is a process: when you delete a cloud resource, delete the DNS record pointing to it in the same change. The order matters too: delete the DNS record first, then the resource. In the reverse order, the name is unclaimed in between. Azure DNS alias records enforce this order on their own, because the record empties when the resource is deleted.

In Production

Choose the TTL for its purpose. The default in Bookstore's zone is 3600 seconds: a change spreads within an hour and resolvers don't query needlessly. The 300-second TTL many providers suggest in their panels is not always better. It increases the number of queries to the authoritative servers and users' cache miss rate, and the bill if the provider charges per query. Switch to a short TTL temporarily, before a planned change; the fourth post shows how.

Add a AAAA record only if something actually listens on that address. A AAAA record whose IPv6 address doesn't work causes delays for users with IPv6 connectivity and errors in some clients. Browsers' Happy Eyeballs behaviour often hides this, but a mobile app or a server-side HttpClient calling the API may not be as tolerant. After adding it, test with curl -6 https://bookstore.example.

Scan the zone regularly. A check like find-dangling-cnames.sh in a weekly job is cheap insurance. If you use Azure there is also a tool Microsoft publishes for finding dangling DNS and related recommendations in Defender for Cloud, but you know your own zone best.

Manage CAA together with your certificate inventory. When a new platform or CA is added, the first thing to check is CAA. DigiCert for the Container Apps, App Service and Front Door managed certificates, Let's Encrypt for Certbot and Caddy; keep the list current so that when someone on the team changes it, you don't end up with a certificate that silently fails on renewal day.

Common Pitfalls

Looking for redirects in DNS: "redirect www to the apex" cannot be solved with DNS. The "URL forwarding" feature of some registrars actually puts an A record pointing to their own web server and issues an HTTP redirect from there; it can cause certificate problems over HTTPS, and part of your traffic passes through a third party's server.

Forgetting the intermediate CNAME: pointing api first at Traffic Manager and from there at App Service is valid DNS. But services that expect a direct CNAME, such as the Container Apps managed certificate, cannot issue a certificate through that chain. Always follow the CNAME chain end to end, with dig +trace or a public resolver.

Setting CAA without DigiCert: writing only letsencrypt.org into CAA because "all our certificates come from Let's Encrypt" breaks the renewal of an App Service managed certificate under the same domain. The breakage shows up not on the day you add CAA but on renewal day.

Nesting staging names: two-level names like api.staging.bookstore.example fall outside the scope of a *.bookstore.example wildcard certificate. Either you get a separate certificate for each level or you keep the names flat.

Leaving the DNS record of a deleted resource behind: the promo story. Every ticket opened to delete a resource should have a "has the DNS record been removed?" item.

A Small Experiment

★☆☆ Run caa-lookup.sh against one of your own domains (or a site you know well). Is there a CAA record? If not, list the CAs you use and write the right CAA set.

★★☆ In the lab, add a CAA record just for api (only digicert.com). Where does caa-lookup.sh stop now? Does the letsencrypt.org permission on the apex still apply to api?

★★★ Extend find-dangling-cnames.sh: in addition to NXDOMAIN, if the target resolves, send an HTTP request with curl and recognise the "no app here" responses of well-known platforms such as github.io and azurewebsites.net. Run the script as a weekly CI/CD job.

Bookstore's web and API records are ready. But we haven't touched the most fragile part of the zone yet: e-mail. When order confirmations and password reset mails land in spam, the problem is usually in DNS, not in the application. In the next post we set up MX, SPF, DKIM and DMARC and look at the bulk sender rules of the big mailbox providers.