A useful name gives people and software a reference they can carry across an address change. That reference needs a defined scope and owner. A machine’s local hostname, a service’s DNS name, and an instance identifier may intentionally differ. Naming them clearly helps operators understand logs and deployments without pretending that one label automatically establishes every other identity in the system.
When it breaks
Setting a local hostname does not, by itself, publish an A or AAAA record in an authoritative zone. Registration may be performed by separate provisioning or network-management software, but it must actually exist. Conversely, changing a DNS address does not rename the local kernel hostname. Confusing these operations produces servers whose local label looks correct while clients cannot discover the intended service.
Short names depend on lookup context. service can resolve differently under two search domains even when both machines appear to use normal DNS settings. A copied runbook command may therefore reach a different environment from the one its author tested. Use the complete intended DNS name in configuration when ambiguity matters, and preserve the context that explains short names in interactive use.
Human meaning also has limits. A name such as app-prod-01 can suggest role, environment, and instance, but it is not authoritative evidence of permissions, ownership, or current workload. Reusing an old instance name can confuse historical logs. Keep stable service names separate from replacement-machine identity when their lifetimes differ, and attach operational metadata instead of trying to encode every changing fact in the hostname.
Check it quickly
Save this as check-hostnames.py and run python3 check-hostnames.py with Docker. The pinned environment uses BIND 9.18.49, dnsmasq 2.90, and Python 3.11. The container’s resolver and NSS files are disposable. Forwarding cache is disabled for this exercise so the search-domain and address changes can be observed directly without waiting for a previous cached generation:
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
import socket
records='service 30 IN A 127.0.0.2\nservice.branch 30 IN A 127.0.0.3\n_echo._tcp 30 IN SRV 0 0 8080 service.lab.test.'
with lab(records) as (root,update,cache,queries):
cache(port=53,size=0)
nss=Path('/etc/nsswitch.conf');nss.write_text(re.sub(r'^hosts:.*$','hosts: dns',nss.read_text(),flags=re.M))
def search(domain):Path('/etc/resolv.conf').write_text('nameserver 127.0.0.1\nsearch '+domain+'\noptions timeout:1 attempts:1\n')
def address(name):
return json.loads(run('/usr/bin/python3','-c','import socket,json,sys;print(json.dumps(sorted({r[4][0] for r in socket.getaddrinfo(sys.argv[1],80,socket.AF_INET,socket.SOCK_STREAM)})))',name).stdout)
local_name=socket.gethostname();unpublished=query(local_name+'.lab.test.');assert unpublished.rcode()==dns.rcode.NXDOMAIN
lower=query('service.lab.test.');mixed=query('SeRvIcE.LaB.TeSt.');assert values(lower)==values(mixed)==['127.0.0.2']
search('lab.test');first=address('service');absolute_first=address('service.lab.test.');assert first==absolute_first==['127.0.0.2']
search('branch.lab.test');second=address('service');absolute_second=address('service.lab.test.');assert second==['127.0.0.3'] and absolute_second==first
update(records.replace('service 30 IN A 127.0.0.2','service 30 IN A 127.0.0.4'));changed=address('service.lab.test.')
assert changed==['127.0.0.4'] and socket.gethostname()==local_name
invalid=root/'invalid.zone';invalid.write_text((root/'zone').read_text()+'bad_name IN A 127.0.0.1\n')
check=run('named-checkzone','-k','fail','lab.test',str(invalid),check=False)
assert check.returncode!=0 and 'bad owner name' in check.stdout+check.stderr
assert values(query('_echo._tcp.lab.test.','SRV'),'SRV')==['0 0 8080 service.lab.test.']
print(json.dumps({'local_hostname_published_in_zone':False,'case_insensitive_A':values(mixed),
'short_name_by_search_domain':{'lab.test':first,'branch.lab.test':second},'absolute_name_unchanged_by_search':[absolute_first,absolute_second],
'DNS_address_after_change':changed,'local_hostname_unchanged':True,'invalid_host_owner_rejected':check.returncode,'underscore_SRV_owner_accepted':True},sort_keys=True))
"""
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 script reads the container’s local hostname, then queries that label under lab.test. The authority returns NXDOMAIN because no corresponding zone record was published. The result illustrates the missing registration in this setup; it does not assert that every installation lacks hostname registration. Lowercase and mixed-case queries for service.lab.test both return 127.0.0.2, demonstrating ASCII DNS case-insensitive comparison.
With search domain lab.test, a fresh application’s lookup of service returns 127.0.0.2. Changing the search domain to branch.lab.test makes the same short input return 127.0.0.3. The absolute name service.lab.test. continues returning 127.0.0.2 in both contexts. The trailing root dot makes that test’s absolute DNS name explicit rather than relying on search-list behavior.
Next, the authority changes service.lab.test to 127.0.0.4. A fresh lookup sees that address while the local hostname remains unchanged. This verifies independence between the DNS mapping and local machine label. It does not prove that existing connections move or that other caches refresh immediately; those behaviors have separate lifetimes and are intentionally excluded from this cache-disabled comparison.
Finally, a candidate A owner named bad_name fails named-checkzone -k fail with a bad-owner-name diagnostic. The valid _echo._tcp SRV owner remains accepted and queryable. This separates strict host-address owner checks from the broader DNS namespace: underscores used for service labels do not imply that ordinary hostnames should follow the same naming rules or that every DNS label is a host label.
Choose names for the lifetime and audience they serve
Use a documented, predictable convention with enough meaning to help distinguish environments and instances. Keep sensitive or rapidly changing details out of publicly visible names. When replacing a machine behind a stable service name, update discovery and service configuration deliberately, retaining any required address overlap. The stable spelling helps callers, but working TLS, routing, and application state still need their own preparation.
Record fully qualified names alongside local hostnames in inventory and incident evidence. Preserve case for display if useful, but do not treat ASCII DNS names differing only by case as separate services. When joining datasets, normalize according to the identifier’s actual rules; an arbitrary application ID or a URL path may have different case semantics from its DNS hostname.
Check generated names before deployment against the rules of the systems that consume them. DNS labels, certificate identities, operating-system hostnames, and service-discovery labels are related but not interchangeable validation domains. A name that a kernel accepts is not automatically suitable as an ordinary internet hostname. Meaningful names work best when both their human interpretation and their technical scope remain explicit.
Sources
- RFC 1123 Internet host requirements, preferred hostname syntax and host naming requirements, checked 2026-09-12.
- RFC 4343 DNS case insensitivity, ASCII case-insensitive DNS comparison, checked 2026-09-12.
- Python 3.11 socket documentation, gethostname and application address lookup, checked 2026-09-12.