Automatic HTTPS certificate renewal in Caddy is not a cron job. Caddy obtains a certificate, serves HTTPS, redirects HTTP, and renews in the background when the hostname, public DNS, ACME challenge path, and certificate storage all work. A running Caddy process does not prove the next ACME attempt will succeed.

Flow from Caddy reading a hostname, completing ACME validation, storing the certificate, renewing in the background, and checking TLS from outside
After issuance, the certificate, private key, and ACME account state are written to the data directory. Background renewal uses the same challenge path.

Automatic HTTPS certificate renewal only starts when these conditions hold

Caddy’s Automatic HTTPS documentation(opens in a new tab) requires all of the following at once:

  • The config contains a qualifying hostname, not only :80, :443, or an http:// address.
  • The A, AAAA, or CNAME chain ends at the current entry.
  • Caddy can bind the challenge ports, or traffic is forwarded to those ports.
  • The data directory(opens in a new tab) is writable and still present after a restart or container rebuild.

A minimal Caddyfile is a hostname and an upstream:

example.com {
    reverse_proxy app:8080
}

Caddy then manages the certificate and adds HTTP to HTTPS redirection. Writing the site as http://example.com turns automatic HTTPS off for that site.

ACME challenges follow the public entry, not the local cache

A certificate authority has to see that the requester controls the name. Let’s Encrypt documents three common challenges(opens in a new tab):

ChallengePublic entryUse whenCommon failure
HTTP-01http://name/.well-known/acme-challenge/... on port 80Ordinary public sitesPort 80 closed, DNS pointing elsewhere, another proxy answering
TLS-ALPN-01TLS handshake on port 443 with a specific ALPN443 reaches this Caddy instanceAnother entry terminates 443, SNI routed to the wrong process
DNS-01_acme-challenge TXT recordWildcards, internal sites, closed origin portsDNS API scope, TXT placement, wrong authoritative zone

HTTP-01 and TLS-ALPN-01 are on by default. Caddy picks one at random at first, then prefers whichever has been succeeding, and falls back if needed. Wildcard certificates can only be issued with DNS-01. That requires a DNS provider module and narrowly scoped API credentials. Enabling DNS-01 disables the other challenges by default.

The CA reads the address from authoritative DNS, not from the machine’s cache. After a change, query the nameservers themselves:

dig +short example.com NS
dig @ns.example.net example.com A
dig @ns.example.net example.com AAAA

A stale AAAA record is enough to fail validation. If there is no working IPv6 entry, delete the bad AAAA. Do not wait for clients to prefer IPv4.

Persist the Caddy data directory across container rebuilds

Caddy stores certificates, private keys, ACME account material, and renewal state in its data directory. That directory is not a cache. In the official Caddy Docker image(opens in a new tab) the data mount is /data and the config directory is /config. Put the Caddyfile in a directory mounted at /etc/caddy:

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./conf:/etc/caddy
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:

NET_ADMIN only raises UDP buffer limits for HTTP/3. It is not required for ACME. Ports 80 and 443 must still reach Caddy for HTTP-01 and TLS-ALPN-01. UDP 443 is for HTTP/3.

If several Caddy instances must share certificates, they have to use the same storage backend that can lock and stay consistent. Copying a directory does not give those instances a lock.

Confirm issuance from outside the host

Validate the config, then check DNS, the HTTP redirect, and TLS from outside the service:

caddy validate --config /etc/caddy/Caddyfile

curl -I http://example.com/
curl -I https://example.com/

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

Those commands should show:

  • HTTP redirects to HTTPS on the same hostname.
  • The TLS handshake succeeds and the SAN includes the current name.
  • The certificate is unexpired, and the issuer and chain match what is expected.
  • HTTPS returns the application, not a default site on the entry.

openssl s_client -status can show whether the server staples OCSP. Renewal monitoring still belongs on expiry time and certificate-management logs. OCSP behavior varies by CA, certificate type, and server settings.

Background renewal still needs an external expiry check

Caddy manages certificates in the background. It renews when remaining lifetime falls below its configured ratio. Default Let’s Encrypt certificates are valid for 90 days(opens in a new tab). Let’s Encrypt recommends renewing when about one third of the lifetime remains(opens in a new tab), and an ACME CA may also send an ARI window. Caddy’s default ratio sits in that band. A browser that opens the site today only proves the current certificate still works.

domain=example.com
expiry="$(
  openssl s_client -connect "${domain}:443" -servername "${domain}" </dev/null 2>/dev/null \
    | openssl x509 -noout -enddate \
    | cut -d= -f2-
)"

openssl s_client -connect "${domain}:443" -servername "${domain}" </dev/null 2>/dev/null \
  | openssl x509 -checkend $((30 * 24 * 60 * 60)) -noout

printf 'certificate_expires=%s\n' "${expiry}"

External monitoring should cover remaining lifetime, Caddy certificate-management errors, external reachability of 80 and 443, and free space plus write permission on the data volume. The alert has to fire before expiry, with enough time to fix DNS, the CA, or permissions. The 30-day checkend window is a monitoring choice for current 90-day lifetimes. If certificate lifetime shortens, move the threshold.

Diagnose failures in ACME order

Log or symptomCheck firstRecovery
NXDOMAIN or missing authorization recordsSpelling, NS, A/AAAA/CNAMEFix records at the authoritative DNS and wait for that view to stabilize
HTTP challenge timeoutPort 80, security group, firewall, port forwardMake public port 80 reach this Caddy, or switch to DNS-01
TLS-ALPN challenge failureWhat terminates 443, SNI, and forwardingThe validation handshake must reach the instance that owns the certificate
DNS challenge missing TXTDNS API scope, zone choice, propagationQuery authoritative DNS, then fix credential scope and record placement
Repeat issuance after a rebuild/data not persisted, or ownership changedRestore the original volume and owner; do not keep creating new volumes
CA rate limitRapid retries or repeated identical name setsStop the failing loop, fix the cause; use staging while testing

Let’s Encrypt rate limits(opens in a new tab) change. Do not copy a limit number into a deploy script. Automation should read the CA error and any retry time. Caddy retries with backoff. The default issuers are Let’s Encrypt and then ZeroSSL. During Let’s Encrypt retries, Caddy can move to staging(opens in a new tab) so production quota is not the first thing consumed.

First-time ACME setup, or a change of challenge type, belongs on staging:

{
    acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

example.com {
    reverse_proxy app:8080
}

After DNS, ports, and storage are correct, switch back to the production CA. Batch config changes into one reload. A reload aborts in-flight ACME work.

Automatic HTTPS certificate renewal is working when a later ACME check can still reach the same challenge path and write a new certificate into the same store. Keep the data volume and watch remaining lifetime from outside the container. A CDN hostname is a second certificate at the edge. Attach it only after that origin handshake is already independently monitored.