Domain & URL Setup — Complete Operations Guide
Source: Marc Mercer (SRE Lead) —
sre-iacrepositorydocs/runbooks/dns-certificates-reverseproxy.md, Rev 2026-03-12This is the single reference for operating the DNS, TLS certificate, and reverse proxy infrastructure at Anshin Health. It covers the complete path from a new domain to a live K8s service.
System Overview
Infrastructure Hosts
| Host | IP | Role |
|---|---|---|
| rp-01 | 10.10.96.22 (private) / 65.182.226.114 (public) | Caddy reverse proxy — TLS termination for all external traffic |
| dc-01 | internal | FreeIPA primary — DNS, LDAP, Kerberos (realm: ANSHINHEALTH.NET) |
| dc-02 | internal | FreeIPA replica — DNS, LDAP, Kerberos |
| K3s workers | internal | K3s cluster nodes |
| MetalLB VIP | 10.10.98.40 | ingress-nginx LoadBalancer IP |
External Traffic Flow
Internet Client
│
│ DNS lookup: *.dev.anshinhealth.net
│ Public DNS (Porkbun/Route53) → 65.182.226.114
│
▼
rp-01 (65.182.226.114)
│
│ Caddy matches *.dev.anshinhealth.net
│ TLS terminated using wildcard cert for dev.anshinhealth.net
│
▼
reverse_proxy → k8s-ingress.dev.anshinhealth.net
│
│ IPA DNS: k8s-ingress.dev.anshinhealth.net → 10.10.98.40
│
▼
ingress-nginx (MetalLB VIP 10.10.98.40)
│
│ Routes by Host header to matching Ingress resource
│
▼
Application Pod
Internal / VPN Traffic Flow
VPN / On-prem Client
│
│ DNS lookup: app.dev.anshinhealth.net
│ IPA DNS → 10.10.98.40 (A record written by external-dns)
│
▼
ingress-nginx (MetalLB VIP 10.10.98.40)
│
│ Routes by Host header
│
▼
Application Pod
Internal clients bypass rp-01 entirely. FreeIPA holds per-service A records pointing to the MetalLB IP, written automatically by external-dns when an Ingress is created.
Split-Brain DNS
Every managed domain has two DNS views — one public, one internal.
External (Public) View
- Managed by: Porkbun (most domains) or Route53 (anshinhealth.net subzones, pending migration)
- Record pattern: Apex A + wildcard A →
65.182.226.114 - Purpose: Route external traffic to the reverse proxy for TLS termination
Example for dev.anshinhealth.net:
dev.anshinhealth.net. A 65.182.226.114
*.dev.anshinhealth.net. A 65.182.226.114
Internal (IPA) View
- Managed by: FreeIPA on dc-01/dc-02 via external-dns (RFC2136)
- Record pattern: Per-service A records →
10.10.98.40
Example for dev.anshinhealth.net:
k8s-ingress.dev.anshinhealth.net. A 10.10.98.40
myapp.dev.anshinhealth.net. A 10.10.98.40 (written by external-dns)
Why Split-Brain?
- External clients hit the reverse proxy (TLS termination, WAF)
- Internal clients (VPN/on-prem) resolve directly to the cluster MetalLB IP — faster, no hairpin NAT
- Internal-only services (
mon.anshinhealth.net,mcp.anshinhealth.net) have no public DNS records at all
DNS Catalog
Source of Truth
The single source of truth for all managed DNS zones is:
ansible/inventory/group_vars/all/dns_zones.yml
Every zone entry has four sections:
- zone: example.anshinhealth.net
intent: "Human-readable description"
public_dns:
provider_current: porkbun # Where public DNS lives today
provider_target: porkbun # Migration target
authoritative: true # Whether Anshin controls the zone
routing_mode: homestead_wildcard # See routing modes below
wildcard_enabled: true # Creates apex + wildcard A records
apex_target: "65.182.226.114" # A record for the apex
wildcard_target: "65.182.226.114"# A record for *
internal_dns:
provider: ipa
zone_enabled: true # Zone exists in FreeIPA
grant_profile: externaldns_standard
external_dns_enabled: true # external-dns writes records here
certificates:
enabled: true
dns_provider_current: dns_aws # ACME DNS challenge provider
dns_provider_target: dns_porkbun
reverse_proxy:
enabled: true # Caddy has an upstream for this zone
Routing Modes
| Mode | Meaning |
|---|---|
homestead_wildcard | Apex + wildcard A records → 65.182.226.114. Standard for most zones. |
internal_only | No public DNS. Zone exists only in FreeIPA. Used for mcp.anshinhealth.net. |
exception | Special handling. Used for accelerate.onnex.app (Anshin does not own the parent zone). |
Key Fields
| Field | Effect |
|---|---|
external_dns_enabled: true | external-dns writes records to this IPA zone for matching Ingresses |
reverse_proxy.enabled: true | Caddy on rp-01 has an upstream for this zone — external traffic works |
certificates.enabled: true | Wildcard certs are issued for this zone |
Certificates
- CA: ZeroSSL (via acme.sh)
- Type: Wildcard (
domain+*.domain) - Storage:
ansible/acme.sh/<domain>_ecc/— Ansible Vault AES-256 encrypted in git - Deployed to: Caddy on rp-01, K8s TLS secrets, GitLab
For full certificate lifecycle details, see Certificate Management.
Reverse Proxy (Caddy on rp-01)
Configuration Files
| Location | Purpose |
|---|---|
ansible/inventory/group_vars/groupproxy/vars.yml | Source of truth — reverse_proxy_upstreams list |
ansible/roles/reverse-proxy/templates/upstream.caddyfile.j2 | Jinja2 template |
/etc/caddy/Caddyfile.d/*.caddyfile (on rp-01) | Generated Caddyfile fragments, one per upstream |
/etc/caddy/certs/<domain>.crt and .key (on rp-01) | Deployed wildcard certificates |
Caddyfile Template (Generated)
*.dev.anshinhealth.net {
reverse_proxy https://k8s-ingress.dev.anshinhealth.net {
header_up Host {host}
}
tls /etc/caddy/certs/dev.anshinhealth.net.crt /etc/caddy/certs/dev.anshinhealth.net.key
log {
output file /var/log/caddy/wildcard_dev_anshinhealth_net.log
level INFO
}
}
header_up Host {host} preserves the original Host header so ingress-nginx can route correctly.
Current Upstreams
| Domain Pattern | Upstream | Cert Domain |
|---|---|---|
gitlab.anshinhealth.net | https://gitlab-01.anshinhealth.net | anshinhealth.net |
*.erp.anshinhealth.net | https://k8s-ingress.erp.anshinhealth.net | erp.anshinhealth.net |
*.anshinhealth.com | https://k8s-ingress.anshinhealth.com | anshinhealth.com |
*.dev.anshinhealth.net | https://k8s-ingress.dev.anshinhealth.net | dev.anshinhealth.net |
*.orchestrate.anshin.us | https://k8s-ingress.orchestrate.anshin.us | orchestrate.anshin.us |
*.anshin-orchestrate.com | https://k8s-ingress.anshin-orchestrate.com | anshin-orchestrate.com |
*.anshin-orchestrate.net | https://k8s-ingress.anshin-orchestrate.net | anshin-orchestrate.net |
*.anshin-travel-leisure.com | https://k8s-ingress.anshin-travel-leisure.com | anshin-travel-leisure.com |
*.anshin-travel-leisure.dev | https://k8s-ingress.anshin-travel-leisure.dev | anshin-travel-leisure.dev |
*.anshin-travel.com | https://k8s-ingress.anshin-travel.com | anshin-travel.com |
*.anshin-travel.net | https://k8s-ingress.anshin-travel.net | anshin-travel.net |
*.anshintech.dev | https://k8s-ingress.anshintech.dev | anshintech.dev |
*.accelerate.onnex.app | https://k8s-ingress.accelerate.onnex.app | accelerate.onnex.app |
*.sweetpealinens.com | https://k8s-ingress.sweetpealinens.com | sweetpealinens.com |
gitlab.anshinhealth.net is the only non-wildcard upstream — it routes directly to the GitLab server, not through K8s ingress.
Applying Reverse Proxy Changes
cd ansible/
ansible-playbook playbooks/reverse-proxy.yml
This renders each upstream from the template, validates with caddy validate, deploys atomically to rp-01, and reloads Caddy (caddy reload).
external-dns
external-dns runs in the external-dns namespace and watches Ingress resources. For every matching hostname, it creates an A record in FreeIPA via RFC2136 dynamic update (TSIG key: externaldns.anshinhealth.net, HMAC-SHA384, target: dc-01.anshinhealth.net:53).
For detailed external-dns documentation, see External DNS.
Kubernetes Ingress Integration
When you create a K8s Ingress resource, three things happen automatically:
- external-dns creates an IPA A record:
<host> → 10.10.98.40 - ingress-nginx routes traffic by Host header to your service
- Caddy forwards external traffic to
k8s-ingress.<zone>→ 10.10.98.40
TLS Secret Reference Table
Reference the correct secret name in your Ingress tls.secretName field:
| Domain | Secret Name | Available In |
|---|---|---|
| anshinhealth.net | wildcard-anshinhealth-cert | ingress-nginx, anshin-erp, kubernetes-dashboard, monitoring |
| anshinhealth.com | wildcard-anshinhealth-com-cert | ingress-nginx |
| dev.anshinhealth.net | wildcard-dev-cert | ingress-nginx, anshin-cms, anshin-dev-svc, mcp-servers |
| orchestrate.anshin.us | wildcard-orchestrate-cert | ingress-nginx, orchestrate |
| anshin-orchestrate.com | wildcard-anshin-orchestrate-com-cert | ingress-nginx |
| anshin-orchestrate.net | wildcard-anshin-orchestrate-net-cert | ingress-nginx |
| anshin-travel-leisure.com | wildcard-anshin-travel-leisure-com-cert | ingress-nginx |
| anshin-travel-leisure.dev | wildcard-anshin-travel-leisure-dev-cert | ingress-nginx |
| anshin-travel.com | wildcard-anshin-travel-com-cert | ingress-nginx |
| anshin-travel.net | wildcard-anshin-travel-net-cert | ingress-nginx |
| anshintech.dev | wildcard-anshintech-dev-cert | ingress-nginx |
| sweetpealinens.com | wildcard-sweetpealinens-cert | ingress-nginx, spl-erp |
| accelerate.onnex.app | wildcard-accelerate-onnex-app-cert | ingress-nginx, onnex-crm-prod, onnex-frappe-prod |
| mon.anshinhealth.net | wildcard-mon-cert | monitoring, kubernetes-dashboard |
| mcp.anshinhealth.net | wildcard-mcp-cert | mcp-servers |
| erp.anshinhealth.net | wildcard-erp-cert | ingress-nginx, anshin-erp |
| svcs.anshinhealth.net | wildcard-svcs-cert | infisical, speech-services |
Example Ingress Resource
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp
namespace: anshin-dev-svc
# No special annotations needed for external-dns —
# it reads the host field directly from the Ingress spec
spec:
ingressClassName: nginx
tls:
- hosts:
- myapp.dev.anshinhealth.net
secretName: wildcard-dev-cert # from the table above
rules:
- host: myapp.dev.anshinhealth.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp
port:
number: 8080
After applying: external-dns creates myapp.dev.anshinhealth.net A 10.10.98.40 in IPA within ~60 seconds.
Need a TLS Secret in a New Namespace?
Add an entry to k8s_tls_secrets in roles/certificates/defaults/main.yml:
- domain: dev.anshinhealth.net
secret_name: wildcard-dev-cert
namespace: my-new-namespace
Then deploy:
ansible-playbook playbooks/certificates.yml --tags deploy-k8s \
-e cert_domain=dev.anshinhealth.net
Adding a New Domain — End-to-End Checklist
This is the complete 7-step process for a brand-new domain zone needing external access, certificates, DNS, and reverse proxy.
Step 1: DNS Catalog
Add the zone to ansible/inventory/group_vars/all/dns_zones.yml:
- zone: newapp.anshinhealth.net
intent: "New application zone"
public_dns:
provider_current: porkbun
provider_target: porkbun
authoritative: true
routing_mode: homestead_wildcard
wildcard_enabled: true
apex_target: "{{ dns_public_edge_ipv4 }}"
wildcard_target: "{{ dns_public_edge_ipv4 }}"
preserve_non_wildcard_records: false
internal_dns:
provider: ipa
zone_enabled: true
grant_profile: externaldns_standard
external_dns_enabled: true
certificates:
enabled: true
dns_provider_current: dns_porkbun
dns_provider_target: dns_porkbun
reverse_proxy:
enabled: true
Step 2: Public DNS Records
For Porkbun-managed domains:
cd ansible/
# Plan first (dry run)
ansible-playbook playbooks/dns-porkbun.yml
# Review artifacts in ansible/artifacts/dns-porkbun/
# Apply
ansible-playbook playbooks/dns-porkbun.yml -e dns_porkbun_apply=true
For Route53-managed domains: Create apex and wildcard A records manually in the AWS Console or via CLI.
Step 3: IPA Zone and Permissions
# Create the zone if it doesn't exist
ipa dnszone-add newapp.anshinhealth.net
# Grant external-dns permissions (CRITICAL — do not skip)
python3 scripts/ipa-dns-grant.py -d newapp.anshinhealth.net
# Or process all catalog zones at once
python3 scripts/ipa-dns-grant.py --catalog --external-dns-only
Skipping the grant step causes REFUSED errors in external-dns logs.
Step 4: External-DNS Configuration
Add the domain to helm/edns_values.yaml in all three locations (see External DNS for details):
domainFilters:
- newapp.anshinhealth.net
extraArgs:
- --domain-filter=newapp.anshinhealth.net
- --rfc2136-zone=newapp.anshinhealth.net
Then upgrade:
helm upgrade external-dns external-dns/external-dns \
-n external-dns \
-f helm/edns_values.yaml
Step 5: Certificate
Add to certificate_domains and k8s_tls_secrets in roles/certificates/defaults/main.yml, then issue:
ansible-playbook playbooks/certificates.yml -e cert_domain=newapp.anshinhealth.net
Verify encryption before committing:
head -1 acme.sh/newapp.anshinhealth.net_ecc/newapp.anshinhealth.net.key
# Must show $ANSIBLE_VAULT;1.1;AES256
git add acme.sh/newapp.anshinhealth.net_ecc/
git add roles/certificates/defaults/main.yml
git commit -m "Add wildcard cert for newapp.anshinhealth.net"
Step 6: Reverse Proxy
Add the upstream to ansible/inventory/group_vars/groupproxy/vars.yml:
- domain: "*.newapp.anshinhealth.net"
upstream: https://k8s-ingress.newapp.anshinhealth.net
cert_domain: newapp.anshinhealth.net
Apply:
ansible-playbook playbooks/reverse-proxy.yml
Step 7: Create Your Ingress
Create a K8s Ingress resource referencing the new TLS secret. external-dns will create the IPA A record automatically within ~60 seconds.
Troubleshooting
Service not reachable externally
-
Public DNS — does the domain resolve to
65.182.226.114?dig +short myapp.dev.anshinhealth.net @8.8.8.8 -
IPA DNS — does
k8s-ingress.<zone>resolve to10.10.98.40?dig +short k8s-ingress.dev.anshinhealth.net @dc-01.anshinhealth.net -
Caddy upstream — is there a Caddyfile for this zone on rp-01?
ssh rp-01 ls /etc/caddy/Caddyfile.d/ -
Caddy logs:
ssh rp-01 tail -50 /var/log/caddy/wildcard_dev_anshinhealth_net.log -
Ingress resource:
kubectl get ingress -A | grep myapp -
external-dns logs:
kubectl logs -n external-dns -l app.kubernetes.io/name=external-dns --tail=50
Service not reachable internally (VPN)
-
IPA DNS — does the hostname resolve to
10.10.98.40?dig +short myapp.dev.anshinhealth.net @dc-01.anshinhealth.netIf not: check external-dns domain filter and IPA zone permissions.
-
Ingress resource — is it present with the correct host?
kubectl get ingress -A | grep myapp -
ingress-nginx logs:
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx --tail=50
Validate the full DNS topology
cd ansible/
ansible-playbook playbooks/dns-validate.yml
Review artifacts in ansible/artifacts/dns-topology/:
| Artifact | Content |
|---|---|
derived-topology.json | Full derived state |
external-dns-zones.txt | Zones managed by external-dns |
certificate-zones.txt | Zones with certificates |
reverse-proxy-zones.txt | Zones with reverse proxy upstreams |
Pending: anshinhealth.net NS Migration
anshinhealth.net and its subzones (apps, dev, mon, mcp, erp, svcs, orchestrate.anshin.us) currently use Route53 for public DNS and ACME challenges. The target state is Porkbun.
What Remains
| Step | Action |
|---|---|
| 1 | Add anshinhealth.net to Porkbun DNS panel (web UI — cannot be automated) |
| 2 | Run Porkbun DNS planner: ansible-playbook playbooks/dns-porkbun.yml — review artifacts, then apply with -e dns_porkbun_apply=true |
| 3 | Update dns_zones.yml: change provider_current: route53 → provider_current: porkbun for all anshinhealth.net zones |
| 4 | Update certificate providers: change dns_provider_current: dns_aws → dns_porkbun in dns_zones.yml and roles/certificates/defaults/main.yml |
| 5 | Change NS records at AWS Route53 Registrar (domain registration, not the hosted zone) to point to Porkbun nameservers |
| 6 | Verify resolution works through Porkbun |
| 7 | Decommission Route53 hosted zone (wait 48h after NS propagation) |
The NS flip is the most consequential step. If Porkbun records are not correct before flipping, all anshinhealth.net services go down. Always verify Porkbun records match Route53 records before flipping, and have Route53 NS values ready for rollback.
Document Control
| Rev | Date | Author | Description |
|---|---|---|---|
| 1.0 | 2026-03-12 | Marc Mercer | Initial release |