A CNAME record associates an alias name with another DNS name. When resolving an address through that alias, a resolver follows the target until it obtains the requested data or encounters a failure. This lets a public hostname remain stable while its DNS dependency changes. It does not redirect a browser to another URL or replace the application’s requested hostname.
When it breaks
An alias can resolve perfectly while the destination serves the wrong website. HTTP still identifies the original hostname, and HTTPS certificate verification is tied to the requested service identity. Configuring only the target’s virtual host or certificate is insufficient. DNS supplies an address path; the selected server must also accept the hostname that users and applications actually request.
A CNAME cannot coexist with ordinary A, AAAA, TXT, or other data at the same owner name. DNSSEC-related metadata has specific exceptions, but those do not make a CNAME plus an A record valid. The ordinary zone apex already requires SOA and NS data, so placing a conventional CNAME there conflicts with those requirements. Provider alias-flattening features need their own documented semantics.
Chains can terminate at missing data or form loops. Each dependency also has its own lifetime and administrative boundary. Shortening an alias TTL does not shorten a target address already cached under a different RRset. Nor does every extra alias require another network round trip: a server may include several chain records in one answer, or the resolver may already have them cached.
Check it quickly
Save this as check-cname.py and run python3 check-cname.py with Docker. The pinned environment uses BIND 9.18.49, dnsmasq 2.90, and a small HTTP server. All DNS names belong to an isolated test zone. Resolver configuration changes and candidate zone files stay inside the disposable container; no public domain or host resolver is modified:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,re,signal,subprocess,time
import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype
def run(*args,check=True):
result=subprocess.run(args,capture_output=True,text=True,timeout=15)
if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
return result
def query(name,kind='A',port=1053):
return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1)
def values(response,kind='A'):
wanted=dns.rdatatype.from_text(kind)
return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset]
def show(response):
def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records]
return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)}
def wait(predicate,timeout=15):
deadline=time.monotonic()+timeout
while time.monotonic()<deadline:
try:
if predicate():return
except (OSError,dns.exception.Timeout):pass
time.sleep(.05)
raise AssertionError('DNS fixture condition did not become true')
def stop(process):
if process.poll() is None:
process.terminate()
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)
@contextmanager
def lab(records,soa_ttl=8,minimum=4,extra=''):
with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack:
root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1
def save(body):
zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n')
run('named-checkzone','lab.test',str(zone))
save(records)
conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none;
{extra} }};
controls {{}};
zone "lab.test" {{ type primary; file "{zone}"; }};
''')
run('named-checkconf',str(conf))
output=stack.enter_context(log.open('w'))
authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
stack.callback(stop,authority)
def ready():
assert authority.poll() is None,log.read_text()
response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial
wait(ready)
def update(body):
nonlocal serial
serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready)
def cache(port=1054,upstream=1053,size=150,options=()):
logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w'))
command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces',
'--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream),
'--cache-size='+str(size),'--log-queries','--log-facility=-',*options]
process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process)
def cache_ready():
assert process.poll() is None,logfile.read_text()
return query('lab.test.','SOA',port).rcode()==0
wait(cache_ready);return process,logfile
def queries(name,kind='A'):
return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines())
yield root,update,cache,queries
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import http.client,threading
records='alias 9 IN CNAME middle.lab.test.\nmiddle 12 IN CNAME target.lab.test.\ntarget 20 IN A 127.0.0.1\nloop-a 9 IN CNAME loop-b.lab.test.\nloop-b 9 IN CNAME loop-a.lab.test.'
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def do_GET(self):
body=json.dumps({'host':self.headers['Host'],'path':self.path}).encode()
self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
with lab(records) as (root,update,cache,queries),ThreadingHTTPServer(('127.0.0.1',0),Handler) as server:
chain=query('alias.lab.test.');assert values(chain)==['127.0.0.1']
assert values(chain,'CNAME')==['middle.lab.test.','target.lab.test.']
assert [r.ttl for r in chain.answer]==[9,12,20]
invalid={}
for case,addition in [('same_owner','alias IN A 127.0.0.2'),('apex','@ IN CNAME target.lab.test.')]:
candidate=root/(case+'.zone');candidate.write_text((root/'zone').read_text()+addition+'\n')
checked=run('named-checkzone','lab.test',str(candidate),check=False)
assert checked.returncode!=0 and 'CNAME and other data' in checked.stdout+checked.stderr
invalid[case]={'returncode':checked.returncode,'diagnostic':'CNAME and other data'}
seen=set();name='loop-a.lab.test.';hops=[]
while name not in seen:
seen.add(name);answer=query(name,'CNAME');target=values(answer,'CNAME')[0];hops.append([name,target]);name=target
assert len(hops)==2 and name=='loop-a.lab.test.'
cache(port=53)
Path('/etc/resolv.conf').write_text('nameserver 127.0.0.1\noptions timeout:1 attempts:1\n')
nss=Path('/etc/nsswitch.conf');nss.write_text(re.sub(r'^hosts:.*$','hosts: dns',nss.read_text(),flags=re.M))
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
try:
client=http.client.HTTPConnection('alias.lab.test',server.server_port,timeout=2)
client.request('GET','/original');response=client.getresponse();body=json.loads(response.read());client.close()
assert response.status==200 and body=={'host':'alias.lab.test:'+str(server.server_port),'path':'/original'}
print(json.dumps({'chain':show(chain),'invalid_candidates':invalid,'loop_detected_by_client':hops,'HTTP':body,'HTTP_status':response.status},sort_keys=True))
finally:server.shutdown();thread.join(timeout=2)
"""
name='xbcatm-dns-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'Dockerfile').write_text(recipe)
built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr
result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name,
'/usr/bin/python3','-',input=program)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
docker('rm','-f',name);docker('image','rm',name)
The address query for alias.lab.test returns two CNAME records followed by an A record: alias points to middle, middle points to target, and target has 127.0.0.1. Their respective TTLs are 9, 12, and 20 seconds. This single authoritative response contains the complete local chain, so the example does not claim that its two aliases necessarily cost two additional DNS exchanges.
Two separate candidate zones demonstrate invalid combinations. One adds an A record at the alias owner; the other adds a CNAME at the zone apex. named-checkzone rejects both with “CNAME and other data.” These candidates never replace the running valid zone. Syntax and data validation therefore establish the configuration restriction without making a failed candidate look like a successfully activated change.
A second pair of aliases points in a circle. The example client queries each CNAME explicitly, remembers visited names, and detects the repeat after two hops. This is a bounded demonstration of the loop itself. It is not a measurement of every recursive resolver’s timeout, retry count, or chosen error response when presented with that same dependency cycle.
Finally, Python’s HTTP client requests alias.lab.test through the container’s normal name-resolution path. The server reports Host alias.lab.test with its temporary port and path /original, returning status 200. The client is not manually told to substitute the target hostname. The result demonstrates address resolution through the chain while preserving the original HTTP service identity and request path.
Move the dependency and preserve the service contract
Before switching an alias, provision the destination to serve the original hostname, including routing and certificates where applicable. Verify both address families and any provider-specific domain ownership checks. An alias into an external service also depends on that service retaining the intended resource; a dangling target is an ownership and availability concern, not a harmless unused name.
Plan the change around all relevant cached RRsets. Updating the alias target and updating the old target’s address are different operations. Observe authoritative answers, recursive answers, and actual application traffic so a cached link in the chain is distinguishable from a misconfigured destination. Retain the old endpoint for the overlap your migration requires rather than assuming the stable public spelling makes the transition instantaneous.
Keep the chain understandable, with a named owner for each dependency and explicit checks for missing targets and cycles. Use an HTTP redirect when changing the URL presented to the client is the intended behavior. Use CNAME when the DNS alias relationship is the intended behavior, and validate the application separately after that relationship successfully resolves.
Sources
- RFC 1034 domain names, alias processing, canonical names, and loop handling, checked 2026-09-12.
- RFC 2181 DNS clarifications, CNAME coexistence constraints and RRset TTL rules, checked 2026-09-12.
- BIND 9.18.49 tools, validating candidate zone data with named-checkzone, checked 2026-09-12.