TLS Certificates skill for Codex

Certificates that renew themselves and never surprise you. Codex diagnoses TLS failures with openssl s_client and a bundled expiry checker before changing anything, then sets up issuance that renews itself: certbot with deploy hooks, DNS-01 for wildcards, or cert-manager Issuers in Kubernetes. It plans for shrinking certificate lifetimes, iterates on Let's Encrypt staging to avoid rate limits, and proves the reload path works.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- tls-certificates

Installs to ~/.agents/skills/tls-certificates/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $tls-certificates, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Issues, renews and debugs TLS certificates - Let's Encrypt via certbot or other ACME clients (HTTP-01, DNS-01, wildcards), cert-manager Issuers and Certificates in Kubernetes, private CAs - and diagnoses handshake failures with openssl s_client, curl and a bundled expiry checker. Use when a certificate expired or is about to, browsers or clients report untrusted or mismatched certificates, renewal silently stopped, cert-manager is stuck pending, a wildcard is needed, or certificate lifetimes are shrinking and automation must keep up.

Use it when

  • A certificate expired, or renewal silently stopped
  • Clients report untrusted, mismatched or incomplete certificate chains
  • You need a wildcard certificate or DNS-01 validation
  • cert-manager Certificates are stuck not ready

Use something else when

  • You want a proxy that handles HTTPS on its own: use caddy-server
  • The certificate is fine and you need nginx TLS settings or redirects: use nginx-config

How to check the result

Run the bundled check_cert.py against every public name and backend, confirm certbot renew --dry-run or cert-manager Ready=True, and force a staging renewal to prove the served serial changes.

The mistake it prevents

Serving cert.pem instead of fullchain.pem: browsers quietly fetch the missing intermediate, but curl, Java and mobile clients fail.

~/.agents/skills/tls-certificates/SKILL.md

--- name: tls-certificates description: Issues, renews and debugs TLS certificates - Let's Encrypt via certbot or other ACME clients (HTTP-01, DNS-01, wildcards), cert-manager Issuers and… license: MIT ---

Done well means: issuance and renewal are fully automated, the server sends the leaf plus intermediates (never the root), every name clients use is in the SANs, the process serving TLS reloads after renewal, and an independent check alerts before expiry.

Public certificate lifetimes are shrinking. Under the CA/Browser Forum schedule the maximum is 200 days from March 2026 and falls further in 2027 and 2029. Let’s Encrypt has announced moving its default from 90 days to 64 days (February 2027) and 45 days (February 2028), with shorter opt-in profiles already available. Treat any manual or calendar-driven renewal as a defect.

Diagnose first

python3 scripts/check_cert.py example.com api.example.com:8443 --days 21
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
curl -sv https://example.com -o /dev/null 2>&1 | grep -E 'SSL|subject|issuer|expire'

scripts/check_cert.py (standard library, needs network) verifies trust and hostname, prints expiry, issuer and SANs, and exits non-zero on failure or when fewer than --days remain. Use --sni when connecting to an IP or load balancer and --cafile for private CAs. If the system Python has an empty CA store (python.org builds on macOS), the script falls back to the OS bundle. Always pass -servername to s_client; without SNI you often get the default cert.

SymptomLikely causeFix
Browser fine, curl/Java/Android/Go fail “unable to get local issuer”Intermediate missing; browsers fetch it via AIA, others do notServe fullchain.pem, not cert.pem
“certificate has expired” right after renewalServer process still holds the old certReload hook (systemctl reload nginx), restart pods, re-upload to LB
Hostname mismatchName missing from SANs (CN is ignored), or wrong SNI vhostReissue with every name; check vhost server_name
Old cert on some requests onlyMultiple LB nodes or CDN edge with its own certCheck each backend IP with --sni; update the CDN/LB copy
Issuance fails “CAA record prevents issuance”CAA allows a different CAAdd 0 issue "letsencrypt.org" (and issuewild for wildcards)
HTTP-01 failsPort 80 blocked, redirect loop, or /.well-known/acme-challenge/ not routedAllow 80, exempt the challenge path from auth and redirects
Clients fail “certificate not yet valid”Clock skew on client or serverFix NTP (timedatectl)
Key mismatch error on server startCert and key from different issuancesCompare public key hashes (below)
# Do cert and key belong together? The two hashes must match.
openssl x509 -in cert.pem -noout -pubkey | openssl sha256
openssl pkey -in key.pem -pubout | openssl sha256
# Does the chain verify on its own?
openssl verify -untrusted chain.pem cert.pem

Pick the issuance path

SituationMethod
Single VM, nginx/Apache, port 80 reachablecertbot with --webroot (or the nginx plugin) and a deploy hook
Caddy or Traefik in frontLet the proxy handle ACME itself; do not add certbot
Kubernetes ingress or Gatewaycert-manager with a ClusterIssuer
Wildcard, or host not reachable from the internetDNS-01 with a scoped DNS API token
Internal services, mTLS between servicesPrivate CA (step-ca, Vault PKI, cert-manager CA issuer) or a service mesh
Managed LB/CDN (ALB, CloudFront, Cloudflare)Provider-managed certificates; do not import ACME certs by hand

certbot on a VM

Installing certbot and every issuance needs network access; in a Codex sandbox ask for approval. Test against staging first; staging certs are untrusted but do not count against production rate limits.

sudo certbot certonly --webroot -w /var/www/html -d example.com -d www.example.com --dry-run
sudo certbot certonly --webroot -w /var/www/html -d example.com -d www.example.com \
  --deploy-hook "systemctl reload nginx"
sudo certbot certificates                       # what is installed, expiry, paths
sudo certbot renew --dry-run                    # proves the renewal path works
systemctl list-timers | grep -i certbot         # or snap.certbot.renew.timer
  • Point the server at /etc/letsencrypt/live/<name>/fullchain.pem and privkey.pem (symlinks that move on renewal). Copying the files elsewhere breaks renewal.
  • Deploy hooks run only when a certificate actually renews; put reloads there, not in cron.
  • Let’s Encrypt stopped sending expiry reminder emails in 2025 and ended OCSP; monitor expiry yourself and remove ssl_stapling expectations for Let’s Encrypt certs.
  • Rate limits (production): duplicate certificates for the same exact name set and new certificates per registered domain are capped weekly. Repeated failed debugging against production locks you out for hours; always iterate on staging.
  • Renewal timing: ACME clients that support ACME Renewal Information (ARI) let the CA pick the window. Otherwise renew at about two thirds of the lifetime, never on a fixed “every 60 days” schedule, which breaks once lifetimes drop below that.

DNS-01 and wildcards

sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
  -d example.com -d '*.example.com'
  • The API token gets only DNS edit on the one zone. Store the credentials file 0600.
  • Delegate _acme-challenge.example.com via CNAME to a dedicated validation zone when you cannot give automation write access to the main zone.
  • A wildcard covers one label: *.example.com covers api.example.com, not example.com or v2.api.example.com.

cert-manager

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-staging-account
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: web
  namespace: shop
spec:
  secretName: web-tls
  dnsNames: [shop.example.com, www.shop.example.com]
  issuerRef:
    kind: ClusterIssuer
    name: letsencrypt-staging

Switch to the production directory (https://acme-v02.api.letsencrypt.org/directory) in a second ClusterIssuer once staging issues cleanly. On an Ingress, the annotation cert-manager.io/cluster-issuer: letsencrypt-prod plus a tls: block creates the Certificate for you.

Debug top-down; each resource’s events name the failing step:

kubectl -n shop describe certificate web
kubectl -n shop get certificaterequest,order,challenge
kubectl -n shop describe challenge <name>        # the real error is usually here
kubectl -n cert-manager logs deploy/cert-manager --since=15m | grep -i shop

Common causes: the solver’s ingress class does not match the controller, the HTTP-01 self-check cannot reach the public address from inside the cluster (hairpin NAT), DNS-01 token lacks permission, or a stale Order keeps retrying with backoff. Delete only the failed CertificateRequest to force a retry, after confirming with the user.

Private CAs and internal TLS

  • Distribute the root, never the intermediate key. Clients trust the root via the OS store, SSL_CERT_FILE, a JVM truststore, or NODE_EXTRA_CA_CERTS.
  • Keep internal leaf lifetimes short (days to weeks) and automated; long-lived internal certs are where outages hide.
  • For service-to-service mTLS across many workloads, a mesh is often simpler: see $service-mesh-istio.

Hard rules

  • Never commit private keys or ACME account keys. Never paste them into chat or logs.
  • Never disable verification (curl -k, verify=False, InsecureSkipVerify) as a fix; only as a temporary diagnostic, removed before finishing.
  • Revoking a certificate, deleting a cert-manager Secret, or switching production traffic to a new certificate are outward-facing changes: confirm with the user first.
  • Do not pin intermediates or leaf certs in clients. CAs rotate intermediates regularly.

Verification

  • python3 scripts/check_cert.py <every public name> --days 30 returns 0.
  • certbot renew --dry-run succeeds, or cert-manager shows Ready=True with a renewalTime in the future (kubectl get certificate -o wide).
  • The reload path is proven: in a staging environment force a renewal (cmctl renew <certificate> for cert-manager; certbot renew --force-renewal on a staging host only, with user approval) and confirm the served serial changes. Never force-renew production certs repeatedly; it burns rate limits.
  • External expiry monitoring exists (uptime checker or the script in CI on a schedule).

Report template

**Names:** example.com, www.example.com, api.example.com (SNI checked on both LB nodes)
**Issuer:** Let's Encrypt via cert-manager ClusterIssuer letsencrypt-prod (HTTP-01)
**Root cause:** nginx served cert.pem without intermediate; Android clients failed
**Fix:** ssl_certificate -> fullchain.pem; deploy hook reloads nginx
**Checks:** check_cert.py OK (63 days left); openssl verify OK; renew --dry-run OK
**Follow-up:** add expiry check to uptime monitoring (alert at 21 days)

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "TLS Certificates"
  short_description: "Certificates that renew themselves and never surprise you"
  default_prompt: "$tls-certificates Our cert expired over the weekend even though certbot is installed, find out why"
scripts/check_cert.py112 lines, 4.6 KB
#!/usr/bin/env python3
"""Check the TLS certificate a server presents: trust, hostname, SANs, expiry.

Usage:
    python3 scripts/check_cert.py example.com
    python3 scripts/check_cert.py example.com:8443 api.example.com --days 21
    python3 scripts/check_cert.py 10.0.0.5:443 --sni app.internal --cafile ca.pem

Exit codes: 0 all good, 1 at least one host failed or expires within --days,
2 usage error. Standard library only; needs network access to the hosts.
"""
from __future__ import annotations

import argparse
import datetime as dt
import socket
import ssl
import sys


def parse_target(target: str) -> tuple[str, int]:
    if target.startswith("["):  # [::1]:443
        host, _, rest = target[1:].partition("]")
        return host, int(rest.lstrip(":") or 443)
    if target.count(":") == 1:
        host, port = target.split(":")
        return host, int(port)
    return target, 443


def name_of(rdns) -> str:
    short = {"commonName": "CN", "organizationName": "O"}
    parts = [f"{short[k]}={v}" for rdn in rdns for k, v in rdn if k in short]
    return ", ".join(parts) or "?"


SYSTEM_BUNDLES = (
    "/etc/ssl/certs/ca-certificates.crt",  # Debian, Ubuntu, Alpine
    "/etc/pki/tls/certs/ca-bundle.crt",  # RHEL, Fedora
    "/etc/ssl/cert.pem",  # macOS, BSD
)


def make_context(cafile: str | None) -> ssl.SSLContext:
    ctx = ssl.create_default_context(cafile=cafile)
    # Some Python builds (python.org on macOS) ship with an empty default store.
    if not ctx.get_ca_certs() and ctx.cert_store_stats()["x509_ca"] == 0:
        for bundle in SYSTEM_BUNDLES:
            try:
                ctx.load_verify_locations(cafile=bundle)
                break
            except OSError:
                continue
    return ctx


def check(target: str, sni: str | None, cafile: str | None, days: int, timeout: float) -> bool:
    host, port = parse_target(target)
    server_name = sni or host
    ctx = make_context(cafile)
    label = f"{host}:{port}" + (f" (SNI {server_name})" if server_name != host else "")
    try:
        with socket.create_connection((host, port), timeout=timeout) as sock:
            with ctx.wrap_socket(sock, server_hostname=server_name) as tls:
                cert = tls.getpeercert()
                version = tls.version()
    except ssl.SSLCertVerificationError as exc:
        print(f"FAIL {label}: verification failed: {exc.verify_message} (code {exc.verify_code})")
        hints = {
            10: "certificate expired - renewal is not running or the server was not reloaded",
            18: "self-signed certificate - wrong cert served or missing --cafile",
            19: "self-signed CA in chain - private CA not trusted here",
            20: "unable to get local issuer - server likely omits the intermediate (serve fullchain)",
            62: "hostname mismatch - check SANs and SNI",
        }
        if exc.verify_code in hints:
            print(f"     hint: {hints[exc.verify_code]}")
        return False
    except (OSError, ssl.SSLError) as exc:
        print(f"FAIL {label}: {type(exc).__name__}: {exc}")
        return False

    not_after = dt.datetime.fromtimestamp(ssl.cert_time_to_seconds(cert["notAfter"]), dt.timezone.utc)
    not_before = dt.datetime.fromtimestamp(ssl.cert_time_to_seconds(cert["notBefore"]), dt.timezone.utc)
    left = (not_after - dt.datetime.now(dt.timezone.utc)).days
    sans = [v for k, v in cert.get("subjectAltName", ()) if k in ("DNS", "IP Address")]
    lifetime = (not_after - not_before).days
    ok = left >= days
    status = "OK  " if ok else "WARN"
    print(f"{status} {label}: {version}, expires {not_after:%Y-%m-%d} ({left} days left, {lifetime}-day cert)")
    print(f"     subject: {name_of(cert.get('subject', ()))}")
    print(f"     issuer:  {name_of(cert.get('issuer', ()))}")
    print(f"     SANs:    {', '.join(sans) if sans else '(none)'}")
    if not ok:
        print(f"     expires within the {days}-day threshold - check the renewal job and reload hook")
    return ok


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("targets", nargs="+", help="host or host:port")
    ap.add_argument("--days", type=int, default=14, help="warn if fewer days remain (default 14)")
    ap.add_argument("--sni", help="server name to send instead of the host (for IPs or load balancers)")
    ap.add_argument("--cafile", help="extra CA bundle for private CAs")
    ap.add_argument("--timeout", type=float, default=8.0)
    args = ap.parse_args()
    results = [check(t, args.sni, args.cafile, args.days, args.timeout) for t in args.targets]
    return 0 if all(results) else 1


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem