Your Cert Automation Should Default to Let's Encrypt Staging
Somebody changed the DNS plugin credentials, so you re-ran certbot to check it worked. It failed, so you fixed a typo and ran it again. And again. On the sixth attempt the ACME server stopped talking to you, and now the certificate that expires in four days cannot be renewed by anyone, including the automation that was working perfectly before you touched it.
The advice you will find is "wait a week". That is true and useless. The interesting question is why a routine test was allowed to consume a production resource at all, and the answer is that almost every certbot setup I have inherited defaults to production issuance and requires you to opt out for testing. That is backwards. Testing should be the default and production issuance should be the thing you have to ask for explicitly.
keep staging and production in physically separate certbot directory trees, and never let a test share a config directory with a live certificate.
- Staging:
https://acme-staging-v02.api.letsencrypt.org/directory - Production:
https://acme-v02.api.letsencrypt.org/directory - Audit what each lineage is pinned to:
grep -r '^server' /etc/letsencrypt/renewal/ --dry-runuses staging only if you have not also passed--server. Combining them issues real certificates.- Run tests with
--config-dir /etc/letsencrypt-stagingso a mistake cannot touch a live lineage.
The error you get when the limit is gone
An unexpected error occurred:
There were too many requests of a given type :: Error creating new order ::
too many certificates (5) already issued for this exact set of domains in the
last 168 hours: example.com, retry after 2026-07-29T04:11:00Z
The exact phrasing has shifted between ACME server releases, and newer messages talk about identifiers rather than domains, but the shape is constant: a count, a window in hours, and a retry timestamp. The retry timestamp is not negotiable and there is no self-service reset.
The other one you will meet while thrashing on a broken DNS plugin:
too many failed authorizations recently
That is a separate, much shorter limit, and it is the one that catches people who are debugging a challenge rather than an issuance.
The limits, and which one you actually hit
| Limit | Value | Window | Who hits it |
|---|---|---|---|
| Certificates per registered domain | 50 | 7 days | Multi-tenant platforms issuing per-customer subdomains |
| Duplicate certificate, same exact set of identifiers | 5 | 7 days | Anyone testing renewal on a single site. This is the one. |
| Failed authorizations per identifier per account | 5 | 1 hour | Anyone debugging a DNS-01 or HTTP-01 challenge |
Note the middle row is keyed on the exact set of identifiers. A certificate for
example.com and one for example.com, www.example.com are different sets and are
counted separately. That is a fact worth remembering, and there is a legitimate use for it at the end of this
article. The current numbers are on the
Let's Encrypt rate limits
page, which is the only source worth trusting because these change.
Why the automation drifts to production without anyone deciding to
Three mechanisms, and they compound.
1. Certbot persists the server per certificate lineage. When you issue a certificate,
certbot writes a renewal configuration to /etc/letsencrypt/renewal/<certname>.conf containing
the parameters used, including the ACME server. certbot renew then replays those saved parameters.
A --server flag you passed once during a late-night experiment is now a permanent property of that
certificate, and nothing on the command line reminds you of it again.
2. --dry-run is conditional, not absolute. The certbot documentation is explicit:
--dry-run performs a test run against the staging server, and it does so unless you have
also specified --server. Combine --dry-run with an explicit
--server, or run it against a lineage whose saved parameters already carry a server value, and you
can issue real certificates while the log says "Simulating renewal". This has been reported against
certbot reconfigure, where the output claimed a simulation and the requests went to the production
endpoint, burning the duplicate certificate limit.
3. The account is keyed to the server, but the certificate is not. Certbot keeps separate
account directories per ACME server under /etc/letsencrypt/accounts/. So there is state that knows
the difference. But your certificates all live in one live/ tree regardless, which means a
staging run can overwrite the same lineage a production run manages. That is why
--break-my-certs exists: certbot will refuse to replace a valid certificate with a staging one
unless you say that is genuinely what you want.
The fix, step by step
Step 1: Find out what every lineage is currently pinned to
grep -r '^server' /etc/letsencrypt/renewal/
/etc/letsencrypt/renewal/example.com.conf:server = https://acme-v02.api.letsencrypt.org/directory
/etc/letsencrypt/renewal/staging.example.com.conf:server = https://acme-staging-v02.api.letsencrypt.org/directory
If any production certificate shows the staging URL, that is your bug: renewals are silently producing
untrusted certificates and you will find out when a browser does. If a certificate shows no
server line at all, it renews against whatever certbot's built-in default is at the time, which is
production but is not something you have declared.
Step 2: Give staging its own directory tree
This is the change that actually prevents recurrence. Do not rely on flags; rely on the filesystem.
certbot certonly \
--config-dir /etc/letsencrypt-staging \
--work-dir /var/lib/letsencrypt-staging \
--logs-dir /var/log/letsencrypt-staging \
--server https://acme-staging-v02.api.letsencrypt.org/directory \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d example.com -d '*.example.com'
Now a test run has its own accounts, its own renewal configs, and its own live/ tree. It is
structurally incapable of overwriting a production certificate or consuming a production rate limit, no matter
what flag someone forgets. Expected output ends with a certificate written under
/etc/letsencrypt-staging/live/example.com/.
Step 3: Make production issuance an explicit, loud action
Wrap it. A four-line script beats a policy document.
#!/usr/bin/env bash
# /usr/local/bin/issue-prod-cert
set -euo pipefail
PROD="https://acme-v02.api.letsencrypt.org/directory"
if [[ "${I_MEAN_PRODUCTION:-no}" != "yes" ]]; then
echo "Refusing: set I_MEAN_PRODUCTION=yes to issue against $PROD" >&2
echo "For testing use: issue-staging-cert" >&2
exit 1
fi
exec certbot certonly --server "$PROD" "$@"
The point is not the environment variable. The point is that the production path now has a name, a gate, and a message that tells the next person what to do instead.
Step 4: Add a pre-flight check to the renewal job
Renewals should fail loudly if a lineage has drifted, rather than quietly installing a staging certificate.
#!/usr/bin/env bash
set -euo pipefail
EXPECTED="https://acme-v02.api.letsencrypt.org/directory"
bad=$(grep -L "server = $EXPECTED" /etc/letsencrypt/renewal/*.conf || true)
if [[ -n "$bad" ]]; then
echo "ACME server mismatch in: $bad" >&2
exit 1
fi
certbot renew --quiet --deploy-hook 'systemctl reload nginx'
Run this from a systemd timer rather than cron if you want the failure to show up in
systemctl list-timers and the journal. My notes on
choosing between cron and systemd timers cover
why that matters for anything whose silent failure is expensive.
Step 5: Test renewals with dry-run and nothing else
certbot renew --dry-run
No --server. No --force-renewal. Adding either of those to a dry run is how the
accident happens. Expected output:
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/example.com/fullchain.pem (success)
Verification
Check what actually issued the certificate on disk, because this is the one thing that cannot lie:
openssl x509 -noout -issuer -dates -in /etc/letsencrypt/live/example.com/cert.pem
A staging certificate has (STAGING) in the issuer common name. If you see that on a production
host, your renewal config has drifted and the site is serving a certificate no browser trusts. Then confirm what
is actually being served, which is not always what is on disk:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -issuer -dates
If the issuer on the wire differs from the issuer on disk, nginx did not reload after the last renewal. That is a deploy-hook problem, not a certbot problem.
What people get wrong
- "Just wait a week." It is the only option once you are limited, but it is not a fix. If you change nothing, the next person to debug a DNS plugin repeats the outage. The fix is the separate config directory in step 2.
- "Put
--force-renewalin the cron job so it definitely renews." This is the most dangerous piece of advice in this whole area. A daily forced renewal consumes the duplicate certificate limit in five days and then fails permanently. Certbot already skips certificates that are not near expiry; let it. - "Delete /etc/letsencrypt and start clean." Rate limits are enforced by the CA against your domain, not against your local state. Deleting local state loses your account key and your renewal configs while changing nothing about the limit.
- "Use
--dry-run, it is safe." It is safe on its own. It is not safe combined with--server, and the docs say so plainly. Read the certbot user guide on that flag before you build automation around it.
When you are already rate limited and the certificate expires tomorrow
- Change the identifier set. The duplicate certificate limit applies to an exact set of
identifiers. Issuing for
example.com, www.example.comwhen you were previously limited onexample.comalone is a different set and is permitted, as long as you are still under the 50 per registered domain limit. Use this once, deliberately, then fix the automation. - Use a different ACME CA temporarily. Certbot's
--serverworks with any ACME v2 directory URL. Other free CAs exist and issuing from one of them buys you the week you need. - Check whether the old certificate is genuinely expired. Let's Encrypt certificates are valid
for 90 days and certbot renews at 30 days remaining, so being rate limited on day 30 leaves you a month of
runway. Panic is usually premature; check
openssl x509 -noout -enddatebefore doing anything irreversible. - Look at Certificate Transparency logs to count exactly how many certificates were issued and when the window opens again. Every publicly trusted certificate is logged, so the record of your five test runs is right there with timestamps.
The general principle is older than ACME: any automation that can consume a scarce shared resource should default to the harmless mode. If production is the default and safety is a flag, the flag will be forgotten, and it will be forgotten on the day it matters.
Frequently asked questions
- Does certbot --dry-run always use the staging server?
- No. The certbot documentation states that --dry-run performs a test run against the staging server unless --server is also specified, in which case real certificates can be issued. That combination is exactly how teams burn the production rate limit while their logs say they are simulating a renewal. Run dry runs with no --server flag at all.
- How many Let's Encrypt certificates can I get before hitting a rate limit?
- Let's Encrypt currently allows up to 50 certificates per registered domain every 7 days, up to 5 certificates for the exact same set of identifiers every 7 days, and up to 5 failed authorizations per identifier per account per hour. The duplicate certificate limit is the one that catches people testing renewal on a single site. The staging environment has significantly higher limits, which is why testing belongs there.
- Why did my renewal suddenly start producing untrusted certificates?
- Certbot saves the parameters used at issuance into /etc/letsencrypt/renewal/CERTNAME.conf, including the ACME server URL, and certbot renew replays them. A --server flag passed once during testing becomes a permanent property of that certificate lineage. Run grep -r '^server' /etc/letsencrypt/renewal/ to see what each certificate is pinned to.
- I am rate limited and my certificate expires tomorrow. What can I do?
- The duplicate certificate limit applies to an exact set of identifiers, so issuing for a slightly different set, such as adding www, is a separate count and is usually permitted. You can also point certbot at a different ACME certificate authority with --server to buy a week. First check the actual expiry with openssl x509 -noout -enddate, because certbot renews at 30 days remaining and you may have more runway than you think.