DNS and TLS Certificates skill for Claude Code
Records verified at the source, certs that renew themselves. Claude records current DNS before changing it, lowers TTLs ahead of migrations and checks answers at the authoritative nameservers instead of trusting a cached resolver. Certificates are issued with the right ACME challenge, served as a full chain, renewed on ARI or two-thirds of lifetime as validity periods shrink, and checked by a bundled script that verifies chain, SANs and days left.
- name
dns-tls-certificates - size197 lines · ≈2,450 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Configures and debugs DNS records and TLS certificates - A/AAAA/CNAME/ALIAS, MX, TXT for SPF and verification, CAA, TTL planning for migrations, propagation checks against authoritative servers, Let's Encrypt and other ACME issuance with HTTP-01 or DNS-01, wildcard certs, automated renewal with certbot, lego, acme.sh or Caddy, shrinking certificate lifetimes and ARI, and certificate chain, SAN and expiry debugging with openssl and a bundled checker. Use when adding a domain, moving DNS, a cert expired or shows the wrong name, renewal failed, or browsers report an incomplete chain. Try asking
- Move app.example.com to our new load balancer without downtime
- Our cert renewal failed and the site expires in three days. Fix it and automate it
- Android clients say the certificate is untrusted but Chrome is fine. Why?
Use it when
- Adding or migrating domains, subdomains or nameservers
- Issuing wildcard or multi-name certificates with ACME
- A certificate expired, shows the wrong name, or the chain is incomplete
- Preparing renewal automation for shorter certificate lifetimes
Skip it when
- You are tuning TLS settings and proxying in nginx or Caddy — use nginx-reverse-proxy
- The question is about HSTS, CSP and other response headers — use security-headers
How to check the result
Authoritative nameservers return the new records, scripts/cert_check.py passes for every served name with the expected days remaining, and a renewal dry run succeeds.
The common mistake it prevents
Serving cert.pem instead of fullchain.pem: desktop browsers patch the gap by fetching intermediates, while curl, Java and mobile clients reject the certificate.
Done well means: records are changed with TTLs lowered in advance, verified against the authoritative nameservers (not a cached resolver), certificates cover every served name, the full chain is served, renewal is automated and monitored, and nothing depends on a human remembering a date.
Ground rules
- Before changing DNS, record the current state (
digoutput for every record touched) so rollback is a copy-paste. - Changing nameservers (NS at the registrar), MX records, or the apex record of a live domain needs explicit user confirmation and a rollback plan.
- Never test ACME clients against Let’s Encrypt production in a loop; use the staging endpoint until the flow works (production rate limits are per registered domain).
- Private keys never leave the host that serves them, never go in Git, and are mode 0600. Use DNS-01 API tokens scoped to one zone.
- Renewal must be automatic and alerting. Certificate lifetimes are shrinking (see below); any process with a manual step will fail.
Workflow
- [ ] 1. Inventory: registrar, authoritative DNS provider, current records, cert issuers
- [ ] 2. Plan the change (TTL lowering, record set, CAA)
- [ ] 3. Apply DNS; verify at authoritative servers, then public resolvers
- [ ] 4. Issue certificates with the right challenge type
- [ ] 5. Install with full chain; reload the server gracefully
- [ ] 6. Automate renewal and monitor expiry
- [ ] 7. Verify with openssl and the bundled checker
1. Inventory
whois example.com | grep -iE "registrar:|name server"
dig +short NS example.com
dig example.com ANY +noall +answer @$(dig +short NS example.com | head -1) # many servers refuse ANY; query types individually
for t in A AAAA CNAME MX TXT CAA; do echo "== $t"; dig +short $t example.com; done
dig +short CNAME www.example.com
2. Record decisions
| Need | Record |
|---|---|
| Apex to a load balancer/CDN hostname | ALIAS/ANAME/flattened CNAME (provider feature) or A/AAAA; a CNAME at apex is invalid |
| Subdomain to a hostname | CNAME (cannot coexist with other records at that name) |
MX + SPF TXT (v=spf1 include:... -all) + DKIM TXT/CNAME + DMARC TXT at _dmarc | |
| Restrict which CAs may issue | CAA: 0 issue "letsencrypt.org", 0 iodef "mailto:[email protected]" |
| Wildcard issuance allowed | CAA 0 issuewild "letsencrypt.org" (or ";" to forbid) |
| Domain verification | TXT at the name the provider asks for; remove after verification if allowed |
TTL plan for a migration:
- 24-48 h before (at least one old TTL ahead): lower TTL on records to change to 300 s.
- Make the change; verify; keep old target running for at least the old TTL.
- After a stable day, raise TTL back (3600 s or more).
NS changes at the registrar are governed by the parent zone’s TTL (often 1-2 days) and cannot be sped up; run old and new providers with identical records during the switch.
3. Verify propagation correctly
NS=$(dig +short NS example.com | head -1)
dig +norecurse A app.example.com @"$NS" # authoritative answer: is the change live?
dig A app.example.com @1.1.1.1 +noall +answer # TTL counting down = cached
dig A app.example.com @8.8.8.8 +noall +answer
dig +trace app.example.com # follow delegation from root
dig +dnssec example.com SOA # DNSSEC status; SERVFAIL often means broken DS/DNSKEY
“Propagation” is cache expiry. If the authoritative server is right, wait out the TTL; if it is wrong, fix the zone. Negative answers (NXDOMAIN) are cached for the SOA minimum TTL, so creating a record that was queried just before can take that long.
4. Issue certificates
| Situation | Challenge | Tool |
|---|---|---|
| Single server, port 80 reachable | HTTP-01 | Caddy (automatic), certbot --webroot or nginx plugin |
| Wildcard, internal hosts, or port 80 closed | DNS-01 | lego, acme.sh, certbot DNS plugin, cert-manager |
| Kubernetes ingress | HTTP-01 or DNS-01 | cert-manager with a ClusterIssuer |
| Behind a CDN/load balancer that terminates TLS | Provider-managed certs (ACM, Cloudflare) | Provider |
| Port 443 only, no HTTP | TLS-ALPN-01 | Caddy, lego |
# certbot, webroot, staging first
certbot certonly --webroot -w /var/www/html -d example.com -d www.example.com \
--staging --agree-tos -m [email protected] --non-interactive
certbot certonly --webroot -w /var/www/html -d example.com -d www.example.com # production
certbot renew --dry-run
# lego, DNS-01 with Cloudflare (token scoped to Zone:DNS:Edit for one zone)
CF_DNS_API_TOKEN=... lego --email [email protected] --dns cloudflare \
--domains example.com --domains '*.example.com' run
Caddy issues and renews automatically for every site address in the Caddyfile; it is the default recommendation when you control the web server (see nginx-reverse-proxy).
5. Install
- Serve
fullchain.pem(leaf + intermediates), notcert.pemalone. Never include the root. - Reload, don’t restart:
nginx -t && systemctl reload nginx; certbot--deploy-hook "systemctl reload nginx". - Certificates on load balancers/CDNs: upload or attach there; origin certs can come from the provider’s origin CA if only the CDN connects.
6. Renewal and lifetimes
Lifetime changes to design for:
- CA/Browser Forum ballot SC-081: maximum public TLS validity drops to 200 days (March 2026), 100 days (March 2027), and 47 days (March 2029).
- Let’s Encrypt: opt-in
tlsserverprofile issuing 45-day certificates from May 2026; default moves to 64 days in February 2027 and 45 days in February 2028. A 6-dayshortlivedprofile exists for clients that renew reliably. - Let’s Encrypt no longer runs OCSP and stopped expiry reminder emails in 2025; your own monitoring must catch failures.
Rules:
- Renew on ACME Renewal Information (ARI) where the client supports it (current certbot, lego, Caddy, cert-manager versions do; check yours). Otherwise renew at two-thirds of the lifetime, never on a fixed “every 60 days” schedule.
- Run renewal twice daily via the client’s systemd timer or cron; it no-ops until due.
- Monitor expiry from outside: alert at < 14 days remaining (< 7 days for short-lived profiles), plus on renewal job failure.
systemctl list-timers | grep -E "certbot|lego|acme"
certbot certificates # names and expiry per lineage
7. Verify
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
echo | openssl s_client -connect example.com:443 -servername example.com 2>&1 | grep -E "Verify return code|depth="
curl -vI https://example.com 2>&1 | grep -E "subject:|expire date:|issuer:|SSL certificate verify"
python3 scripts/cert_check.py example.com www.example.com api.example.com:8443 --warn-days 21
scripts/cert_check.py connects with SNI, verifies the chain against the system trust
store and the hostname, and prints issuer, SANs, days remaining and TLS version per
host. It exits 1 if any host fails verification or expires within --warn-days.
If every host fails with “unable to get local issuer certificate”, the Python build has
no system trust store (common with python.org installs on macOS); rerun with
--cafile /etc/ssl/cert.pem before blaming the servers.
Debugging
| Symptom | Cause | Fix |
|---|---|---|
| “unable to get local issuer certificate” in curl/Java/Android, fine in Chrome | Intermediate missing (browsers fetch via AIA) | Serve fullchain |
NET::ERR_CERT_COMMON_NAME_INVALID | Name not in SAN; wrong vhost served (SNI) | Reissue with the name; check server_name and default server |
| Old cert still served after renewal | Server not reloaded, or LB holds its own copy | Reload; update LB listener cert |
| HTTP-01 fails 404 | Redirects to another host, CDN caching, wrong webroot | Serve /.well-known/acme-challenge/ over HTTP directly |
| HTTP-01 fails connection | Port 80 firewalled, AAAA points somewhere wrong | Open 80; fix or remove the stale AAAA (LE prefers IPv6) |
| DNS-01 fails | TXT not yet at all authoritative servers, CNAME delegation wrong | Check _acme-challenge at each NS; raise propagation wait |
| “CAA record prevents issuance” | CAA lists a different CA | Add issue for your CA |
too many certificates already issued | Rate limit from repeated production attempts | Wait; use staging; reuse existing cert |
| Works on IPv4, fails IPv6 | Different server behind AAAA | curl -6 and curl -4 separately |
Gotchas
- A CNAME at a name blocks every other record there, including the MX and TXT you add later. Apex records need ALIAS/flattening.
- Cloud DNS providers have their own “proxied” modes (e.g. orange-cloud) that change which IP and certificate clients see; check before debugging the origin.
- Wildcard
*.example.comdoes not coverexample.comora.b.example.com. - Internal names and IPs need a private CA (step-ca, cloud private CA); public CAs will
not issue for
.internalnames. - Changing the apex record at a registrar with “parking” defaults can wipe other records; export the zone file first.
- Long-lived pinned certificates in mobile apps break on the shorter lifetimes; pin keys or intermediates, not leaves.
Output
**DNS:** app.example.com A 203.0.113.10 -> ALIAS lb-123.eu-west-1.elb.amazonaws.com (TTL 300, raised to 3600 on 2026-03-06)
**Verified:** authoritative ns1/ns2 answer new target; 1.1.1.1 and 8.8.8.8 updated after TTL
**Cert:** Let's Encrypt, SANs example.com, www, app; DNS-01 via lego; CAA issue letsencrypt.org
**Renewal:** lego timer twice daily with ARI; reload hook nginx; expiry alert at 14 days
**Checks:** cert_check.py all hosts OK, 89 days left, TLSv1.3, chain verified Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/cert_check.py
#!/usr/bin/env python3
"""Check TLS certificates served by one or more hosts.
Usage:
python3 scripts/cert_check.py example.com www.example.com:443 api.example.com:8443 [--warn-days 21] [--timeout 8] [--cafile PATH]
For each host: connects with SNI, verifies the chain against the system trust store and
the hostname, then prints issuer, SANs, days remaining and negotiated TLS version.
Exit 1 if any host fails verification, cannot connect, or expires within --warn-days.
"""
import argparse
import datetime as dt
import socket
import ssl
import sys
def parse_target(t):
if t.startswith("["): # [ipv6]:port
host, _, port = t[1:].partition("]:")
return host, int(port or 443)
if t.count(":") == 1:
host, port = t.split(":")
return host, int(port)
return t, 443
CAFILE = None
def fetch(host, port, timeout, verify=True):
ctx = ssl.create_default_context(cafile=CAFILE)
if not verify:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, port), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as tls:
return tls.getpeercert(), tls.version(), tls.getpeercert(binary_form=True)
def name_of(rdns, key):
for rdn in rdns:
for k, v in rdn:
if k == key:
return v
return "?"
def check(target, warn_days, timeout):
host, port = parse_target(target)
label = f"{host}:{port}"
try:
cert, version, _ = fetch(host, port, timeout)
except ssl.SSLCertVerificationError as e:
# fetch unverified details to explain the failure
detail = ""
try:
_, version, der = fetch(host, port, timeout, verify=False)
detail = f" (served cert {len(der)} bytes, {version}); check chain/SAN with openssl s_client"
except Exception: # noqa: BLE001
pass
return False, f"FAIL {label}: verification failed: {e.verify_message}{detail}"
except (OSError, ssl.SSLError) as e:
return False, f"FAIL {label}: cannot connect: {e}"
not_after = dt.datetime.fromtimestamp(ssl.cert_time_to_seconds(cert["notAfter"]), dt.timezone.utc)
days = (not_after - dt.datetime.now(dt.timezone.utc)).days
sans = [v for k, v in cert.get("subjectAltName", ()) if k in ("DNS", "IP Address")]
issuer = f"{name_of(cert.get('issuer', ()), 'organizationName')} / {name_of(cert.get('issuer', ()), 'commonName')}"
ok = days >= warn_days
status = "OK " if ok else "WARN "
shown = ", ".join(sans[:6]) + (f" (+{len(sans) - 6} more)" if len(sans) > 6 else "")
return ok, (f"{status} {label}: {days} days left (until {not_after:%Y-%m-%d}), {version}, "
f"issuer {issuer}, SANs [{shown}]")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("hosts", nargs="+")
ap.add_argument("--warn-days", type=int, default=14)
ap.add_argument("--timeout", type=float, default=8.0)
ap.add_argument("--cafile", help="CA bundle (e.g. /etc/ssl/cert.pem on macOS python.org builds without system certs)")
a = ap.parse_args()
global CAFILE
CAFILE = a.cafile
failures = 0
for t in a.hosts:
ok, line = check(t, a.warn_days, a.timeout)
failures += not ok
print(line)
print(f"\n{len(a.hosts) - failures}/{len(a.hosts)} hosts OK (warn threshold {a.warn_days} days)")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.