Diagnostic TLS Certificates / CAA

TLS certificate nearing or past expiry

Let's Encrypt certificates only last ninety days, so every host quietly depends on a renewal job running on time. Most of the time it does. Then one day it does not, nobody notices, and the certificate just keeps serving traffic on borrowed time until the moment it expires and every browser and API client starts failing at once, with no warning to whoever is on call.

Python and Node.js TLS socket check + Cloudflare CAA repair Safe by default (dry run)
A laptop with a green screen
Photo by Moritz Erken on Unsplash
The short answer

Let's Encrypt certificates last ninety days, so a renewal job has to succeed on its own on a schedule, usually around day sixty to sixty seven. If that job fails quietly, a blocked port, a broken DNS-01 record, an expired ACME account, or a missing reload hook, the old certificate keeps serving traffic right up to its expiry date, then every client starts rejecting the connection with no warning. Check the live certificate directly with openssl, not just the renewal tool's logs, fix whatever is blocking the ACME challenge, force a renewal, and put an independent expiry monitor in place so the next silent failure is caught weeks early instead of at outage time. Full code, tests, and the check are below.

The problem in plain words

Let's Encrypt, and most modern certificate authorities, only issue certificates that last ninety days. That is short on purpose. It means a host has to renew constantly, so it relies completely on an automated job, certbot's systemd timer or a cron entry, to request a new certificate every day and actually replace the old one around the sixty to sixty seven day mark.

Most of the time that job just works and nobody thinks about it again. But if something breaks the renewal, a firewall change that blocks port 80 or 443, a DNS-01 challenge record that no longer resolves, an ACME account that expired, or a deploy hook that never reloads the web server, the job can fail over and over without anyone seeing it. The old certificate keeps answering requests right up until its notAfter date passes, and then, with zero advance warning, every browser and every API client starts rejecting the connection at the same time.

Renewal job runs day ~60 of 90 ACME challenge fails quietly nobody is watching Old cert keeps serving traffic notAfter passes, clients reject weeks can pass between the first failed renewal and the actual outage
The renewal job can fail long before anyone notices, since the old certificate still works fine right up to the day it expires.

Why it happens

Let's Encrypt's short ninety day lifetime is a deliberate design choice that pushes every host toward automation, but automation only helps if it keeps working. A few common ways the renewal job quietly breaks:

Every one of these looks fine from the outside until the day the old certificate's notAfter timestamp passes. The renewal tool's own "success" log from months ago tells you nothing about whether the certificate being served right now is actually still valid.

The key insight

The renewal tool's logs are not the source of truth. What matters is the certificate the server is actually presenting on port 443 right now. A renewal can report success while a stale or self-signed fallback certificate is still what clients receive, so always check the live socket, not just the job history.

The fix, as a flow

Do not just retry the renewal blind. Find out which step is actually broken, fix that specific blocker, force an immediate renewal, and confirm the web server reloaded with the new certificate. Then add a monitor that is independent of the renewal tool itself, so the next silent failure gets caught with weeks of runway instead of at the moment of the outage.

Check live cert openssl s_client on 443 Diagnose blocker certbot renew --dry-run Fix the blocker port, TXT record, or CAA Renewal succeeds? yes no, keep fixing Reload + monitor independent expiry check
Force a renewal once the specific blocker is confirmed and cleared, then reload the web server and keep an independent monitor watching from then on.

How to fix it

1

Check the live certificate expiry against the server

Check the certificate the server is actually presenting right now, not what the renewal tool's logs claim. A bad result is a notAfter date that is today, in the past, or less than about two weeks away with no renewal activity logged.

Terminal
check-live-cert.sh
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
  | openssl x509 -noout -dates

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
2

Check certbot's own view of the certificate

Confirm what certbot thinks the status is on the host itself. A bad result shows INVALID: EXPIRED, or an expiry date under the renewal threshold, which defaults to thirty days remaining on a ninety day certificate.

Terminal
certbot-status.sh
sudo certbot certificates
3

Reproduce the failure with a dry run

A dry run reproduces the exact failure against Let's Encrypt's staging environment without touching the live certificate. A bad result is any non-zero exit or a printed error such as "Could not bind TCP port 80", "DNS problem: NXDOMAIN", or "urn:ietf:params:acme:error:unauthorized".

Terminal
dry-run.sh
sudo certbot renew --dry-run
4

Check whether CAA is blocking the CA you actually use

A bad result is a CAA record that no longer permits the CA the client uses, for example only 0 issue "digicert.com" present while certbot is requesting from Let's Encrypt. That makes every future renewal fail with a CAA-related error even though the rest of DNS looks fine.

Terminal
check-caa.sh
dig +short CAA example.com
5

Fix whatever the dry run pointed at

Open port 80 or 443 to the ACME client for HTTP-01, or fix the automation behind the _acme-challenge TXT record for DNS-01, making sure the record can actually be created and updated by the renewal hook, and that it is deleted after validation so stale tokens do not pile up.

DNS record
DNS-01 challenge record
_acme-challenge.example.com.  300  IN  TXT  "<token-from-acme-client>"
6

Add a CAA record if that was the blocker

If CAA is blocking issuance, add a record that permits the CA in use. Add an issuewild entry too if wildcard certificates are issued from this domain.

DNS record
CAA record
example.com.  3600  IN  CAA  0  issue  "letsencrypt.org"
example.com.  3600  IN  CAA  0  issuewild  "letsencrypt.org"
7

Force an immediate renewal with a reload hook

Once the blocker is cleared, force the renewal instead of waiting for the next scheduled attempt. Wire in a post-hook that reloads the web server, since a missing reload hook is a very common silent-failure cause on its own: the new certificate gets issued but the running server never picks it up.

Terminal
force-renew.sh
sudo certbot renew --force-renewal --post-hook "systemctl reload nginx"
8

Put an independent expiry monitor in place

Add a check that lives outside the renewal tool itself, one that opens a TLS connection to the host and reads the certificate's real notAfter date on a schedule. That way a future silent failure is caught with weeks of runway instead of being discovered at outage time.

How to check it worked

Re-run the live check and confirm the date moved forward and is comfortably in the future, and that certbot and the client both agree the certificate is now valid.

Terminal
verify.sh
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
  | openssl x509 -noout -dates -issuer

sudo certbot certificates

curl -vI https://example.com/ 2>&1 | grep -i "SSL certificate verify"
curl -sI https://example.com

A good result looks like this: notAfter= is about ninety days out for Let's Encrypt, and issuer= matches the expected CA. sudo certbot certificates shows status VALID with the new expiry date. curl -sI returns normal HTTP headers with no TLS verify error, which proves clients accept the chain end to end.

The full code

This one is mostly a repair through DNS when the blocker is CAA, plus detection through a raw TLS socket. The scripts below open a TLS connection to the host with SNI set, read the real certificate, and compute how many days remain. If the failure turns out to be a missing or wrong CAA record, the repair calls the Cloudflare API to add a record that permits the CA in use, or updates the _acme-challenge TXT record used by a DNS-01 hook.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

check_tls_expiry.py
"""Check a host's live TLS certificate expiry over a raw socket, and if the
failure is a missing or wrong CAA record, repair it through the Cloudflare
DNS API. Safe by default: DRY_RUN only reports the plan until turned off.
"""
import os
import logging
from datetime import datetime, timezone
from typing import Literal

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_tls_expiry")

DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CA_TO_PERMIT = os.environ.get("CA_TO_PERMIT", "letsencrypt.org")
WARN_AT_DAYS = int(os.environ.get("WARN_AT_DAYS", "21"))
CRIT_AT_DAYS = int(os.environ.get("CRIT_AT_DAYS", "7"))


def days_until_expiry(not_after: datetime, now: datetime) -> int:
    """Pure function, no I/O. Both datetimes should be timezone-aware."""
    return (not_after - now).days


def classify(
    days: int, warn_at: int = 21, crit_at: int = 7
) -> Literal["ok", "warn", "critical", "expired"]:
    """Pure function, no I/O. Classifies remaining days into a severity."""
    if days < 0:
        return "expired"
    if days <= crit_at:
        return "critical"
    if days <= warn_at:
        return "warn"
    return "ok"


def fetch_peer_certificate(host: str, port: int = 443):
    """Open a raw TLS socket with SNI set and read the peer certificate."""
    import socket
    import ssl

    context = ssl.create_default_context()
    with socket.create_connection((host, port), timeout=15) as sock:
        with context.wrap_socket(sock, server_hostname=host) as tls_sock:
            return tls_sock.getpeercert()


def parse_not_after(cert: dict) -> datetime:
    """Parse the certificate's notAfter field (e.g. 'Sep  1 00:00:00 2026 GMT')."""
    from datetime import datetime as dt

    return dt.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)


def check_caa(domain: str) -> list[str]:
    """Return the list of CA domains permitted to issue for this name via CAA."""
    import dns.resolver

    try:
        answers = dns.resolver.resolve(domain, "CAA")
    except dns.resolver.NoAnswer:
        return []
    except dns.resolver.NXDOMAIN:
        return []
    permitted = []
    for rdata in answers:
        if rdata.tag.decode() if isinstance(rdata.tag, bytes) else rdata.tag == "issue":
            value = rdata.value.decode() if isinstance(rdata.value, bytes) else rdata.value
            permitted.append(value)
    return permitted


def add_caa_record(domain: str, ca_domain: str) -> None:
    """Add a CAA record permitting ca_domain to issue for domain, via Cloudflare."""
    import requests

    if DRY_RUN:
        log.info("[dry run] would add CAA record on %s permitting %s", domain, ca_domain)
        return

    url = f"https://api.cloudflare.com/client/v4/zones/{CLOUDFLARE_ZONE_ID}/dns_records"
    headers = {
        "Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}",
        "Content-Type": "application/json",
    }
    payload = {
        "type": "CAA",
        "name": domain,
        "data": {"flags": 0, "tag": "issue", "value": ca_domain},
    }
    r = requests.post(url, headers=headers, json=payload, timeout=30)
    r.raise_for_status()
    log.info("Added CAA record on %s permitting %s", domain, ca_domain)


def run():
    log.info("Checking TLS certificate for %s (DRY_RUN=%s)", DNS_DOMAIN, DRY_RUN)

    cert = fetch_peer_certificate(DNS_DOMAIN)
    not_after = parse_not_after(cert)
    now = datetime.now(timezone.utc)
    days = days_until_expiry(not_after, now)
    severity = classify(days, WARN_AT_DAYS, CRIT_AT_DAYS)

    log.info("Certificate for %s expires in %d day(s): %s", DNS_DOMAIN, days, severity)

    if severity == "ok":
        log.info("OK: certificate has plenty of runway left.")
        return

    log.warning("Certificate for %s is %s (%d days remaining).", DNS_DOMAIN, severity, days)

    permitted_cas = check_caa(DNS_DOMAIN)
    if permitted_cas and not any(CA_TO_PERMIT in ca for ca in permitted_cas):
        log.warning(
            "CAA record on %s only permits %s, which blocks issuance from %s.",
            DNS_DOMAIN, permitted_cas, CA_TO_PERMIT,
        )
        if CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID:
            add_caa_record(DNS_DOMAIN, CA_TO_PERMIT)
        else:
            log.warning(
                "Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID to let this script add the CAA record."
            )
    else:
        log.warning(
            "CAA looks fine. Check the ACME client and its port 80/443 or DNS-01 automation by hand: "
            "run 'sudo certbot renew --dry-run' on the host to see the exact failure."
        )


if __name__ == "__main__":
    run()
check-tls-expiry.js
/**
 * Check a host's live TLS certificate expiry over a raw socket, and if the
 * failure is a missing or wrong CAA record, repair it through the Cloudflare
 * DNS API. Safe by default: DRY_RUN only reports the plan until turned off.
 */
import { pathToFileURL } from "node:url";

const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CA_TO_PERMIT = process.env.CA_TO_PERMIT || "letsencrypt.org";
const WARN_AT_DAYS = Number(process.env.WARN_AT_DAYS || 21);
const CRIT_AT_DAYS = Number(process.env.CRIT_AT_DAYS || 7);

/** Pure function, no I/O. Both dates should be Date objects. */
export function daysUntilExpiry(notAfter, now) {
  const msPerDay = 24 * 60 * 60 * 1000;
  return Math.floor((notAfter.getTime() - now.getTime()) / msPerDay);
}

/** Pure function, no I/O. Classifies remaining days into a severity. */
export function classify(days, warnAt = 21, critAt = 7) {
  if (days < 0) return "expired";
  if (days <= critAt) return "critical";
  if (days <= warnAt) return "warn";
  return "ok";
}

/** Open a raw TLS socket with SNI set and read the peer certificate. */
async function fetchPeerCertificate(host, port = 443) {
  const tls = await import("node:tls");
  return new Promise((resolve, reject) => {
    const socket = tls.connect(
      { host, port, servername: host, timeout: 15000 },
      () => {
        const cert = socket.getPeerCertificate();
        socket.end();
        resolve(cert);
      }
    );
    socket.on("error", reject);
    socket.on("timeout", () => {
      socket.destroy();
      reject(new Error("TLS connection timed out"));
    });
  });
}

/** Return the list of CA domains permitted to issue for this name via CAA. */
async function checkCaa(domain) {
  const dns = await import("node:dns/promises");
  try {
    const records = await dns.resolveCaa(domain);
    return records.filter((r) => r.critical !== undefined || true).map((r) => r.issue).filter(Boolean);
  } catch (err) {
    if (err.code === "ENODATA" || err.code === "ENOTFOUND") return [];
    throw err;
  }
}

/** Add a CAA record permitting caDomain to issue for domain, via Cloudflare. */
async function addCaaRecord(domain, caDomain) {
  if (DRY_RUN) {
    console.log(`[dry run] would add CAA record on ${domain} permitting ${caDomain}`);
    return;
  }

  const url = `https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records`;
  const res = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "CAA",
      name: domain,
      data: { flags: 0, tag: "issue", value: caDomain },
    }),
  });
  if (!res.ok) throw new Error(`Cloudflare API returned ${res.status}`);
  console.log(`Added CAA record on ${domain} permitting ${caDomain}`);
}

export async function run() {
  console.log(`Checking TLS certificate for ${DNS_DOMAIN} (DRY_RUN=${DRY_RUN})`);

  const cert = await fetchPeerCertificate(DNS_DOMAIN);
  const notAfter = new Date(cert.valid_to);
  const now = new Date();
  const days = daysUntilExpiry(notAfter, now);
  const severity = classify(days, WARN_AT_DAYS, CRIT_AT_DAYS);

  console.log(`Certificate for ${DNS_DOMAIN} expires in ${days} day(s): ${severity}`);

  if (severity === "ok") {
    console.log("OK: certificate has plenty of runway left.");
    return;
  }

  console.warn(`Certificate for ${DNS_DOMAIN} is ${severity} (${days} days remaining).`);

  const permittedCas = await checkCaa(DNS_DOMAIN);
  if (permittedCas.length > 0 && !permittedCas.some((ca) => ca.includes(CA_TO_PERMIT))) {
    console.warn(
      `CAA record on ${DNS_DOMAIN} only permits ${JSON.stringify(permittedCas)}, which blocks issuance from ${CA_TO_PERMIT}.`
    );
    if (CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID) {
      await addCaaRecord(DNS_DOMAIN, CA_TO_PERMIT);
    } else {
      console.warn("Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID to let this script add the CAA record.");
    }
  } else {
    console.warn(
      "CAA looks fine. Check the ACME client and its port 80/443 or DNS-01 automation by hand: " +
      "run 'sudo certbot renew --dry-run' on the host to see the exact failure."
    );
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((e) => { console.error(e); process.exit(1); });
}

Add a test

The part worth testing is the pure decision logic: how many days remain, and what severity that maps to. Both functions take plain dates in and return a plain value, so the tests use synthetic datetimes and need no network access.

test_expiry_classify.py
from datetime import datetime, timedelta, timezone

from check_tls_expiry import days_until_expiry, classify


def test_days_until_expiry_future():
    now = datetime(2026, 1, 1, tzinfo=timezone.utc)
    not_after = now + timedelta(days=45)
    assert days_until_expiry(not_after, now) == 45


def test_days_until_expiry_past():
    now = datetime(2026, 1, 1, tzinfo=timezone.utc)
    not_after = now - timedelta(days=3)
    assert days_until_expiry(not_after, now) == -3


def test_classify_ok_when_plenty_of_runway():
    assert classify(60) == "ok"


def test_classify_warn_at_boundary():
    assert classify(21, warn_at=21, crit_at=7) == "warn"


def test_classify_warn_just_inside_window():
    assert classify(15, warn_at=21, crit_at=7) == "warn"


def test_classify_critical_at_boundary():
    assert classify(7, warn_at=21, crit_at=7) == "critical"


def test_classify_critical_just_inside_window():
    assert classify(2, warn_at=21, crit_at=7) == "critical"


def test_classify_expired_when_negative():
    assert classify(-1) == "expired"


def test_classify_expired_many_days_past():
    assert classify(-30) == "expired"
expiry-classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { daysUntilExpiry, classify } from "./check-tls-expiry.js";

test("days until expiry, future date", () => {
  const now = new Date("2026-01-01T00:00:00Z");
  const notAfter = new Date("2026-02-15T00:00:00Z");
  assert.equal(daysUntilExpiry(notAfter, now), 45);
});

test("days until expiry, past date", () => {
  const now = new Date("2026-01-01T00:00:00Z");
  const notAfter = new Date("2025-12-29T00:00:00Z");
  assert.equal(daysUntilExpiry(notAfter, now), -3);
});

test("classify ok when plenty of runway", () => {
  assert.equal(classify(60), "ok");
});

test("classify warn at boundary", () => {
  assert.equal(classify(21, 21, 7), "warn");
});

test("classify warn just inside window", () => {
  assert.equal(classify(15, 21, 7), "warn");
});

test("classify critical at boundary", () => {
  assert.equal(classify(7, 21, 7), "critical");
});

test("classify critical just inside window", () => {
  assert.equal(classify(2, 21, 7), "critical");
});

test("classify expired when negative", () => {
  assert.equal(classify(-1), "expired");
});

test("classify expired many days past", () => {
  assert.equal(classify(-30), "expired");
});

Case studies

Firewall change

The security update that broke its own renewals

A team tightened inbound firewall rules on a fleet of hosts and closed port 80 everywhere except a small allowlist. Nobody realized certbot's HTTP-01 challenge needed that port open. Every renewal attempt for the next two months failed quietly, and nobody was watching certbot's own logs.

The certificate expired on a Saturday morning, and every API client integrating with the service started failing at once. Reopening port 80 for the ACME client and forcing a renewal fixed it within minutes, and an independent expiry monitor went in the same day.

CAA record

The CAA record left over from a CA migration

A domain had switched certificate authorities a year earlier and a CAA record was added at the time to lock issuance to the new CA. When the team later moved back to Let's Encrypt for a subset of hosts, nobody updated the CAA record, so every renewal attempt failed with a CAA-related ACME error that did not obviously point at DNS.

Running dig +short CAA against the domain showed the mismatch immediately. Adding a CAA record permitting letsencrypt.org alongside the existing one let renewals succeed on the very next scheduled run.

What good looks like

Once an independent monitor is watching the live certificate on a schedule, an expiring certificate stops being a surprise outage. It becomes an alert that lands weeks before the notAfter date, with enough time to fix the actual blocker calmly instead of scrambling while every client is already failing.

FAQ

Why did my TLS certificate expire when I use certbot?

Certbot only renews on a schedule if its renewal job actually runs and succeeds. If port 80 or 443 gets blocked, a DNS-01 TXT record stops resolving, the ACME account breaks, or a deploy hook never reloads the web server, the renewal fails quietly and the old certificate keeps serving traffic until its expiry date.

How do I know if my certificate is about to expire?

Do not trust the renewal tool's logs alone. Check the live certificate directly with openssl s_client against the server on port 443, or run sudo certbot certificates, which shows the real expiry date and VALID or EXPIRED status for each certificate on the host.

Can a CAA record block certificate renewal?

Yes. If a CAA record on the domain only permits a different certificate authority than the one your ACME client uses, every renewal attempt fails with a CAA-related error even though the rest of DNS looks fine. Add a CAA record that permits the CA you actually use, for example letsencrypt.org.

Related field notes

Citations

On the problem:

  1. RFC 5280, Internet X.509 Public Key Infrastructure Certificate and CRL Profile (validity period, notAfter). datatracker.ietf.org/doc/html/rfc5280
  2. Let's Encrypt Community: Certbot renewal failure discussion. community.letsencrypt.org
  3. Let's Encrypt Community: Certificate expired, but certbot says not time for renewal. community.letsencrypt.org

On the solution:

  1. How to Check SSL Certificate Expiration Date Using OpenSSL. ssldragon.com
  2. Let's Encrypt Community: Certbot renewal frequency with systemd timers. community.letsencrypt.org
  3. Cloudflare API documentation: DNS records endpoint (for CAA/TXT automation). api.cloudflare.com/client/v4/zones/{zone_id}/dns_records

Stuck on a tricky one?

If you have a DNS, TLS, or certificate automation problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this save you from a cert outage?

If this caught an expiring certificate before it took your site down, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all DNS and Domains field notes