ACME & Certbot

Practical ACME issuance and renewal: challenge choice, Certbot vs acme.sh vs lego, wildcard DNS-01, reload hooks, rate limits, CAA, and real failure modes.

Use HTTP-01 only when the challenge path is deterministic and reachable on port 80, use DNS-01 for wildcards or hidden origins, and always debug against the staging endpoint before touching production rate limits.

Where ACME fits

ACME is the protocol a public CA uses to prove you control a name and then issue or renew a certificate automatically. The moving parts are simple in theory: a client such as certbot, acme.sh, or lego talks to the CA, completes a challenge, writes the certificate and key to disk, and then triggers a reload in the service that uses them.

What breaks in practice is almost never "TLS is weird" and almost always one of these: the CA cannot reach the challenge path, DNS automation did not finish propagating, the wrong service was reloaded, or the renewed files were written somewhere the web server is not actually using. If you need a refresher on keys, full chains, and what lives in fullchain.pem vs privkey.pem, read Certificate Basics first.

Common Certbot paths
/etc/letsencrypt/live/example.com/fullchain.pem
/etc/letsencrypt/live/example.com/privkey.pem
/etc/letsencrypt/renewal/example.com.conf
/etc/letsencrypt/renewal-hooks/{pre,deploy,post}/

For internet-facing sites, ACME is the default answer. For private internal PKI, see PKI Design instead.

Challenge types

Pick the challenge based on topology, not habit.

HTTP-01
The CA fetches a token over plain HTTP from http://host/.well-known/acme-challenge/.... Best when the host is directly reachable on port 80 and you can make that path bypass app routing, CDN cache, and auth.
DNS-01
The client creates a TXT record under _acme-challenge. Required for wildcard certificates and ideal when the origin is not reachable from the public internet.
TLS-ALPN-01
The CA connects on 443 and expects a temporary ACME certificate via ALPN. Useful in some load-balancer layouts, but less common operationally than HTTP-01 or DNS-01.
A wildcard like *.example.com always means DNS-01. HTTP-01 can issue a normal multi-SAN cert for api.example.com, www.example.com, and friends, but not a wildcard.

If you terminate traffic behind a CDN or WAF, either carve out the ACME path explicitly or prefer DNS-01. The edge layer can cache, redirect, challenge, or normalize the request in ways that make HTTP-01 look randomly broken. That interaction is covered more broadly on CDN / WAF Concepts.

Certbot vs acme.sh vs lego

There is no universal winner. Choose for the environment you actually run.

ClientGood fitOperational notes
certbot Classic Linux servers running nginx or Apache directly Excellent packaging, widely documented, clean renewal hook directories, and first-party webserver integrations. Strong default for VM or bare-metal fleets.
acme.sh DNS-heavy automation, small shell-driven environments, or teams that want many DNS provider plugins Shell-based, flexible, very popular for wildcard + DNS automation. Writes certs wherever you tell it and can run a reload command after install.
lego Containers, CI jobs, and simple one-binary automation Small Go binary with good provider support. Common in container images, Kubernetes jobs, and custom automation pipelines.

The practical split is usually this: if the certificate lives on a traditional web server host, use certbot; if the workflow is DNS-automation-heavy or wrapped in your own scripts, acme.sh or lego may fit better. What matters more than client choice is that renewal is unattended, idempotent, and reloads the right service only after success.

HTTP-01 with nginx

The least fragile nginx pattern is a dedicated webroot for the challenge path plus a normal HTTP-to-HTTPS redirect for everything else. That keeps ACME outside your app routing and works whether nginx serves files directly or acts as a reverse proxy.

server {
    listen 80;
    server_name example.com www.example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /srv/acme-webroot;
        default_type "text/plain";
        try_files $uri =404;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}
install -d -m 0755 /srv/acme-webroot/.well-known/acme-challenge

certbot certonly \
  --webroot -w /srv/acme-webroot \
  -d example.com -d www.example.com \
  --email ops@example.com \
  --agree-tos --no-eff-email

After issuance, point nginx at the live symlinks Certbot maintains:

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://app_backend;
        include /etc/nginx/snippets/proxy-headers.conf;
    }
}

This pairs cleanly with the patterns on Nginx Reverse Proxy. If SELinux is enforcing and nginx cannot read the challenge directory or cert files, validate contexts as part of the reload workflow; the broader debugging flow is on SELinux Debugging.

Apache and standalone patterns

With Apache, the same principle applies: make the challenge path boring. Many teams avoid certbot --apache because they prefer certificate issuance to be separate from vhost edits; certonly plus a stable webroot is easier to reason about.

Alias /.well-known/acme-challenge/ "/var/www/acme/.well-known/acme-challenge/"

<Directory "/var/www/acme/.well-known/acme-challenge/">
    Options None
    AllowOverride None
    Require all granted
</Directory>
install -d -m 0755 /var/www/acme/.well-known/acme-challenge

certbot certonly \
  --webroot -w /var/www/acme \
  -d app.example.com

If nothing else is bound to port 80, or you are issuing for a host before the webserver is configured, standalone mode is fine:

certbot certonly \
  --standalone \
  -d app.example.com \
  --pre-hook 'systemctl stop nginx' \
  --post-hook 'systemctl start nginx'

Use standalone sparingly. It is simple, but it depends on stopping whatever owns 80 or 443. For established sites, webroot or DNS-01 is usually safer. For Apache-specific validation and reload habits, see Apache Basics.

DNS-01 and wildcard certificates

DNS-01 is mandatory for *.example.com and often the right answer for internal origins, API gateways behind a CDN, or environments where exposing port 80 is politically or technically awkward. The real operational issue is DNS automation and propagation time.

Certbot manual DNS

Good for a one-off test, bad for unattended renewal.

certbot certonly \
  --manual \
  --preferred-challenges dns \
  -d example.com \
  -d '*.example.com'

acme.sh with a DNS provider plugin

export CF_Token='REDACTED'
export CF_Account_ID='REDACTED'

acme.sh --issue \
  --dns dns_cf \
  -d example.com \
  -d '*.example.com'

acme.sh --install-cert -d example.com \
  --fullchain-file /etc/nginx/tls/example.com/fullchain.pem \
  --key-file /etc/nginx/tls/example.com/privkey.pem \
  --reloadcmd 'nginx -t && systemctl reload nginx'

lego in an automation job

export CLOUDFLARE_DNS_API_TOKEN='REDACTED'

lego \
  --email ops@example.com \
  --dns cloudflare \
  --domains example.com \
  --domains '*.example.com' \
  run

Two practical warnings: first, split-horizon DNS can fool you into thinking the TXT record exists when the public CA still cannot see it; second, wildcard certificates increase blast radius. If one key protects every subdomain, key hygiene and renewal safety matter even more. If your public DNS is already managed as code, it can be worth pairing DNS-01 with Terraform + Cloudflare or equivalent automation so records and provider credentials are not maintained ad hoc.

Renewal hooks and safe reloads

Renewal is only finished when the service presents the new certificate. For Certbot, the safest pattern is a deploy hook: it runs only after a successful renewal, and only for the lineages that changed.

# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/usr/bin/env bash
set -euo pipefail

echo "Renewed lineage: $RENEWED_LINEAGE"
echo "Domains: $RENEWED_DOMAINS"

nginx -t
systemctl reload nginx
# /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh
#!/usr/bin/env bash
set -euo pipefail

apachectl configtest
systemctl reload httpd
chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh

systemctl list-timers 'certbot*'
certbot renew --dry-run

Do not blindly restart a process that has a config test. Validate, then reload. If you deploy certs to multiple services, use one deploy hook that branches on $RENEWED_DOMAINS or writes a clear log trail. The same principle applies with acme.sh and lego: keep the "install cert" step separate from the "reload service" step so failures are obvious.

Staging and rate limits

Never burn production issuance attempts while you are still debugging routing, DNS, or permissions. Use staging until the challenge succeeds repeatedly, then switch endpoints.

# Certbot staging
certbot certonly \
  --staging \
  --webroot -w /srv/acme-webroot \
  -d example.com

# Renewal rehearsal
certbot renew --dry-run

# acme.sh staging
acme.sh --issue --staging --dns dns_cf -d example.com

Public CAs enforce rate limits because issuance is not free. The exact numbers change, so check the CA's current documentation, but the buckets that bite in practice are always the same: too many duplicate certificates, too many certificates for one registered domain, and too many failed validations while you keep retrying a broken challenge. Staging exists to protect you from yourself.

Repeated failure is not harmless. If a challenge fails, stop and inspect routing, DNS, or CAA before retrying ten more times. Fast blind retries are how you lock yourself out during a maintenance window.

CAA records

CAA is a DNS control that tells certificate authorities which CA is allowed to issue for a name. If you publish restrictive CAA and forget to include the CA you are using, renewal fails even though challenge validation is otherwise correct.

example.com. 300 IN CAA 0 issue "letsencrypt.org"
example.com. 300 IN CAA 0 issuewild "letsencrypt.org"
example.com. 300 IN CAA 0 iodef "mailto:pki-alerts@example.com"

Notes that matter:

CAA problems often show up only at renewal time because someone tightened DNS months after the original certificate was issued. Treat CAA as part of DNS hygiene, not a one-time setup step.

Troubleshooting challenge failures

Start with direct evidence, not guesses:

curl -i http://example.com/.well-known/acme-challenge/probe
dig TXT _acme-challenge.example.com +short
journalctl -u certbot -n 100 --no-pager
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -dates
SymptomLikely causeFix
HTTP-01 challenge returns 404 or app HTML The ACME path is falling through to the app, redirect logic, or the wrong document root. Test the exact path with curl; use a dedicated webroot and a ^~ /.well-known/acme-challenge/ location.
HTTP-01 times out Port 80 is blocked by firewall, security group, or upstream load balancer. Open 80 for validation or switch to DNS-01. Review firewalld if the host itself is blocking the request.
Wildcard request fails immediately Trying to use HTTP-01 for a wildcard or using a manual DNS flow for unattended renewal. Move to DNS-01 with provider automation.
TXT record exists for you but CA still says not found Split-horizon DNS, slow provider propagation, or the record was created in the wrong zone. Check authoritative public DNS, wait for propagation, and confirm the exact _acme-challenge name.
CAA forbids issuance CAA records do not allow the CA you are using. Add or correct the issue/issuewild records and wait for DNS propagation.
Renewal succeeded but clients still see the old cert The service was not reloaded, is reading a different file path, or another proxy is terminating TLS. Confirm the live cert path, reload the right process, and inspect the endpoint with openssl s_client.
Standalone mode fails to bind nginx, Apache, or another process already owns port 80 or 443. Use webroot instead, or stop the listener with an explicit pre-hook.
Challenge worked once, then later starts failing behind the edge A CDN, WAF, or redirect rule changed behavior on the challenge path. Bypass caching and security rules for /.well-known/acme-challenge/ or switch that hostname to DNS-01.
Renewal retries hit limits Debugging happened against production repeatedly. Stop retrying, move to staging, and only return to production once the flow is stable.
Related pages: use Certificate Basics for chain and key checks, Nginx Reverse Proxy and Apache Basics for the server side, and CDN / WAF Concepts when an edge layer sits between the CA and your origin.