Skip to main content

Certificate Management

Source: Marc Mercer (SRE Lead) — sre-iac repository, Rev 1.0, 2026-01-24

This document covers wildcard TLS certificate lifecycle managed via acme.sh + ZeroSSL and deployed automatically through Ansible. For the end-to-end domain setup process, see Domain & URL Setup.

Architecture

certificates.yml playbook
|
+-- cert-state role (decrypt) # Decrypt vaulted cert files from git
+-- certificates role
| +-- Clone/setup acme.sh # One-time, idempotent
| +-- Issue wildcard certs # acme.sh --issue via DNS-01 challenge
| +-- deploy_k8s.yml # kubectl create secret tls (dry-run | apply)
| +-- deploy_web.yml # Copy to GitLab (for registry TLS), reload nginx
+-- cert-state role (encrypt) # Re-encrypt (runs in `always:` block)

Certificate state (private keys, cert files, CA chain, renewal configs) lives in ansible/acme.sh/ and is committed to git in Ansible Vault AES-256 encrypted form. The vault password file ansible/.vault_pass is gitignored and never committed.


Key Files

FilePurpose
playbooks/certificates.ymlMain playbook (decrypt → issue → deploy → encrypt)
playbooks/cert-encrypt.ymlManual encrypt recovery tool
playbooks/cert-decrypt.ymlManual decrypt for inspection
roles/certificates/defaults/main.ymlDomain list + K8s secret mappings
roles/certificates/tasks/main.ymlIssue logic (acme.sh calls)
roles/certificates/tasks/deploy_k8s.ymlK8s TLS secret deployment
roles/certificates/tasks/deploy_web.ymlGitLab cert deployment
roles/certificates/templates/account.conf.j2acme.sh account config template
roles/cert-state/tasks/decrypt.ymlVault decrypt logic
roles/cert-state/tasks/encrypt.ymlVault encrypt logic
inventory/group_vars/all/vault.ymlVaulted secrets (AWS, Cloudflare, acme creds)

Certificate Specifications

AttributeValue
CAZeroSSL
Challenge typeDNS-01
Key typeECDSA (Elliptic Curve)
Validity90 days
Renewal threshold30 days before expiration
CoverageApex + wildcard per domain (e.g., anshinhealth.net + *.anshinhealth.net)

DNS Providers for ACME Challenges

ProviderConfig KeyWhen Used
AWS Route53dns_aws (default)anshinhealth.net and its subzones, anshin.us, orchestrate.anshin.us, accelerate.onnex.app
Porkbundns_porkbunanshinhealth.com, anshin-orchestrate., anshin-travel., anshintech.*, anshintechsolutions.com, etc.
Cloudflaredns_cfsweetpealinens.com
anshinhealth.net NS Migration Pending

The anshinhealth.net zone and its subzones (dev, mon, mcp, erp, svcs, apps) currently use Route53 for ACME DNS challenges. When the NS migration to Porkbun completes, those entries in roles/certificates/defaults/main.yml must be updated to dns_provider: dns_porkbun. See the sre-iac runbook Section 9 for the migration plan.


Managed Domains (30 total)

Defined in roles/certificates/defaults/main.yml under certificate_domains. Each entry issues both the apex and wildcard (*.domain).

DomainACME DNS ProviderNotes
anshinhealth.netRoute53 (dns_aws)Primary — pending Porkbun migration
anshin.usRoute53 (dns_aws)Pending Porkbun migration
apps.anshinhealth.netRoute53 (dns_aws)Pending migration
svcs.anshinhealth.netRoute53 (dns_aws)Internal services, pending migration
dev.anshinhealth.netRoute53 (dns_aws)Development, pending migration
mon.anshinhealth.netRoute53 (dns_aws)Monitoring (VPN only), pending migration
mcp.anshinhealth.netRoute53 (dns_aws)MCP servers (VPN only), pending migration
erp.anshinhealth.netRoute53 (dns_aws)External ERP, pending migration
orchestrate.anshin.usRoute53 (dns_aws)Orchestrate product, pending migration
accelerate.onnex.appRoute53 (dns_aws)Onnex Accelerate (staying on Route53)
anshinhealth.comPorkbun (dns_porkbun)
anshin-orchestrate.appPorkbun (dns_porkbun)Reserved brand
anshin-orchestrate.comPorkbun (dns_porkbun)
anshin-orchestrate.netPorkbun (dns_porkbun)
anshin-travel-leisure.appPorkbun (dns_porkbun)Reserved brand
anshin-travel-leisure.comPorkbun (dns_porkbun)
anshin-travel-leisure.devPorkbun (dns_porkbun)
anshin-travel.appPorkbun (dns_porkbun)Reserved brand
anshin-travel.comPorkbun (dns_porkbun)
anshin-travel.netPorkbun (dns_porkbun)
anshintech.appPorkbun (dns_porkbun)
anshintech.devPorkbun (dns_porkbun)
anshintech.netPorkbun (dns_porkbun)
anshintech.usPorkbun (dns_porkbun)
anshintechsolutions.comPorkbun (dns_porkbun)
anshintechnologysolutions.comPorkbun (dns_porkbun)
anshintechnologysolutions.netPorkbun (dns_porkbun)
anshinhealthsolutions.comPorkbun (dns_porkbun)
anshinhs.comPorkbun (dns_porkbun)
sweetpealinens.comCloudflare (dns_cf)Sweet Pea Linens

K8s TLS Secret Deployment

Defined in roles/certificates/defaults/main.yml under k8s_tls_secrets. One domain can map to multiple namespaces. The deploy step runs:

kubectl create secret tls <name> \
--cert=<fullchain.cer> \
--key=<domain.key> \
-n <namespace> \
--dry-run=client -o yaml | kubectl apply -f -

Always add new domains to ingress-nginx namespace at minimum, plus any application namespaces that need direct TLS access.


Common Operations

Issue and deploy ALL certificates

cd ansible/
ansible-playbook playbooks/certificates.yml

This decrypts cert state, issues/renews all certs, deploys to K8s and GitLab, then re-encrypts. Running on unexpired certs is safe — acme.sh skips domains not due for renewal.

Issue and deploy a SINGLE domain

ansible-playbook playbooks/certificates.yml -e cert_domain=accelerate.onnex.app

Encryption/decryption still scopes to that domain's directory plus shared CA files.

Deploy only (skip issue)

ansible-playbook playbooks/certificates.yml --tags deploy

Skips issue, only deploys to K8s and web servers. Useful when pushing to new namespaces without re-issuing.

Deploy to K8s only (skip GitLab)

ansible-playbook playbooks/certificates.yml --tags deploy-k8s
# or
ansible-playbook playbooks/certificates.yml -e deploy_gitlab_certs=false

Debug mode

ansible-playbook playbooks/certificates.yml -v

Adds verbose acme.sh output.


Adding a New Domain

  1. DNS prerequisite: The domain must have NS records pointing to Route53, Porkbun, or Cloudflare. The ACME challenge will create a TXT record in the provider during issuance.

  2. Add to certificate_domains in roles/certificates/defaults/main.yml:

    certificate_domains:
    # ... existing domains ...
    - domain: new.example.com # Route53 (default, dns_aws)
    # or
    - domain: new.example.com
    dns_provider: dns_porkbun # Porkbun
    # or
    - domain: new.example.com
    dns_provider: dns_cf # Cloudflare
  3. Add K8s TLS secret mappings under k8s_tls_secrets:

    k8s_tls_secrets:
    # ... existing entries ...
    - domain: new.example.com
    secret_name: wildcard-new-example-cert
    namespace: ingress-nginx # Always add to ingress-nginx

    - domain: new.example.com
    secret_name: wildcard-new-example-cert
    namespace: my-app-namespace # Add per-namespace as needed
  4. IPA/FreeIPA enrollment (if applicable): If the domain needs internal DNS resolution via FreeIPA, handle that separately — add the zone and run ipa-dns-grant.py as described in External DNS. This is not part of the certificates playbook.

  5. Issue and deploy:

    ansible-playbook playbooks/certificates.yml -e cert_domain=new.example.com
  6. Commit the encrypted cert state:

    # Verify certs are encrypted (must show $ANSIBLE_VAULT header)
    head -1 acme.sh/new.example.com_ecc/new.example.com.key

    git add acme.sh/new.example.com_ecc/
    git add roles/certificates/defaults/main.yml
    git commit -m "Add wildcard cert for new.example.com"

Certificate Renewal

ZeroSSL certs are valid for 90 days. acme.sh tracks renewal dates in each domain's .conf file. Running the playbook on an unexpired cert is safe — acme.sh reports "Domains not changed" or "Next renewal time is:" and skips.

To force renewal regardless of expiry:

# Not a built-in playbook feature — use acme.sh directly after decrypting
ansible-playbook playbooks/cert-decrypt.yml -e cert_domain=example.com
./acme.sh/acme.sh --renew -d example.com -d '*.example.com' --force --server zerossl
ansible-playbook playbooks/cert-encrypt.yml -e cert_domain=example.com

Recovery: Files Left Decrypted

If a playbook run fails mid-execution, cert files may remain decrypted on disk. The always: block handles this normally, but if it doesn't:

# Re-encrypt all domains
ansible-playbook playbooks/cert-encrypt.yml

# Re-encrypt specific domain
ansible-playbook playbooks/cert-encrypt.yml -e cert_domain=dev.anshinhealth.net
Never commit decrypted cert files

Verify encryption before any git add:

head -1 acme.sh/*/fullchain.cer # Must show $ANSIBLE_VAULT;1.1;AES256

Manual Inspection

# Decrypt for inspection
ansible-playbook playbooks/cert-decrypt.yml -e cert_domain=dev.anshinhealth.net

# ... inspect files ...

# Re-encrypt when done
ansible-playbook playbooks/cert-encrypt.yml -e cert_domain=dev.anshinhealth.net

Secrets Reference

All secrets sourced from inventory/group_vars/all/vault.yml (Ansible Vault AES-256 encrypted):

VariablePurpose
aws_access_key_idRoute53 DNS-01 challenge validation
aws_secret_access_keyRoute53 DNS-01 challenge validation
cf_tokenCloudflare DNS-01 challenge validation
acme_emailZeroSSL account email
acme_upgrade_hashacme.sh upgrade tracking

The vault password file is at ansible/.vault_pass and is gitignored.


Document Control

RevDateAuthorDescription
1.02026-01-24Marc MercerInitial release