-
Pick the record type by what you're pointing at — do not CNAME the apex.
| Need | Record | Notes |
|---|
| Name → IPv4 | A | Bare IP only |
| Name → IPv6 | AAAA | Add alongside A; serve dual-stack |
| Subdomain → another hostname | CNAME | e.g. www → app.example.com; cannot coexist with other records on that name |
Apex (example.com) → hostname | ALIAS/ANAME/flattened-CNAME | Apex can't be a real CNAME (breaks SOA/NS/MX). Use the provider's ALIAS (Route 53 alias, Cloudflare CNAME-flattening, etc.) |
| Mail | MX | Priority + target; target must be an A/AAAA, never a CNAME |
| SPF/DKIM/DMARC/verification | TXT | One SPF per domain; DMARC at _dmarc; DKIM at <sel>._domainkey |
| Who may issue certs | CAA | 0 issue "letsencrypt.org" + 0 issuewild "letsencrypt.org" |
Set CAA before first ACME issuance, or issuance fails with CAA record prevents issuance. Example:
example.com. CAA 0 issue "letsencrypt.org"
example.com. CAA 0 issuewild "letsencrypt.org"
example.com. CAA 0 iodef "mailto:security@example.com"
-
Zero-downtime cutover: lower the TTL BEFORE the change — this is the whole trick. Resolvers cache the old answer for up to its TTL; if you cut over while TTL is 3600, clients hit the dead origin for an hour.
- Drop the record's TTL to
60 (or 30). Wait out the old TTL (e.g. wait the full prior 3600s) so every cache holds the short TTL.
- Run both origins in parallel (old + new healthy) during the switch — never tear down old first.
- Change the record value to the new target.
- Verify the new answer is served (step in Verify) and the new origin takes real traffic.
- Only after traffic has fully drained from the old origin (watch its access logs go quiet for > one TTL), decommission it and raise TTL back to 3600+ to cut query volume/cost.
-
Automate certificates — manual renewal is a guaranteed future outage. Use ACME (Let's Encrypt / ZeroSSL). Never click-issue a 1-year cert you have to remember to renew; LE is 90-day by design to force automation.
- VM / bare proxy:
certbot with a renewal timer, or the proxy's built-in ACME (Caddy auto-HTTPS, Traefik resolver, nginx + acme.sh).
- Kubernetes: cert-manager — a
ClusterIssuer + Certificate (or Ingress annotation) reconciles renewal automatically; renews at ~⅔ of lifetime.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: letsencrypt-prod }
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef: { name: letsencrypt-prod-key }
solvers:
- dns01:
cloudflare:
apiTokenSecretRef: { name: cloudflare-token, key: api-token }
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata: { name: example-tls, namespace: web }
spec:
secretName: example-tls
issuerRef: { name: letsencrypt-prod, kind: ClusterIssuer }
dnsNames: ["example.com", "*.example.com"]
Iterate against the staging ACME server first — set the ClusterIssuer spec.acme.server to https://acme-staging-v02.api.letsencrypt.org/directory (or certbot --test-cert) to dodge LE prod rate limits (50 certs / registered-domain / week) while debugging, then flip the server back to prod and re-issue.
-
Choose the ACME challenge and cert shape deliberately.
| Axis | Pick | Why |
|---|
| HTTP-01 | single host, port 80 reachable from internet | simplest; needs /.well-known/acme-challenge/ served; cannot do wildcards |
| DNS-01 | wildcards, internal hosts, no inbound 80, or many SANs | proves control via a _acme-challenge TXT; needs DNS-provider API creds; works behind a firewall |
Wildcard *.example.com | many dynamic subdomains | DNS-01 only; one cert, but a single shared private key (bigger blast radius) |
| SAN / multi-domain | a known fixed set of names | explicit per-name; rotate one without touching others; preferred when the list is stable |
Default: SAN cert via DNS-01 for anything non-trivial; wildcard only when subdomains are unbounded/dynamic.
-
Set a modern TLS policy at the terminator — TLS 1.2+ only, redirect, HSTS, stapling. Configure on whatever terminates (see configure-reverse-proxy-lb), but the policy is owned here:
- Protocols: TLS 1.3 + TLS 1.2 only. Disable TLS 1.0/1.1 and SSLv3 entirely.
- Ciphers: TLS 1.3 defaults; for 1.2 use forward-secret AEAD suites (ECDHE + AES-GCM/CHACHA20), no CBC/RC4/3DES.
- Redirect 80→443 with
301, then serve everything over HTTPS.
- HSTS on HTTPS responses:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload — but only add preload/includeSubDomains once every subdomain is HTTPS (it's hard to undo). Roll out short → long → preload.
- OCSP stapling on (
ssl_stapling on; in nginx) so clients don't round-trip the CA.
- Serve the full chain (leaf + intermediates), not just the leaf — the #2 cause of "works in my browser, fails in
curl/old Android".
server {
listen 443 ssl http2;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # full chain
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_stapling on; ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
}
server { listen 80; server_name example.com; return 301 https://$host$request_uri; }
-
Prove auto-renew works before you trust it. A cert that issues fine but never renews is a 90-day time bomb. Force a dry-run/staging renewal now (step in Verify) so you discover broken DNS creds or a missing port today, not at 2am on day 89.
Done = every name resolves to the new target on external resolvers, HTTPS serves the full chain over TLS 1.2/1.3 only with HSTS + stapling + 80→443 redirect and no mixed content (SSL Labs/testssl ≥ A), CAA locks issuance to your CA, and a staging force-renew has proven auto-renewal works before any cert nears expiry.