Skip to main content
You attach a domain, you get HTTPS, and 90 days later you get a fresh certificate without touching anything. The provisioning system behind that handles per-project Let’s Encrypt accounts, and rate limits. What that article didn’t cover is what happens when a renewal fails. The failure is rarely in the certificate authority or in the provisioning code. It’s almost always in the path between Let’s Encrypt and your application, and the error message you get back is short on detail. Here are the causes that come up most often.

How the challenge works

Before a certificate authority issues a certificate for example.com, it has to confirm that whoever asked for it controls that domain. That confirmation step is called a challenge. Upsun mostly uses the HTTP-01 challenge. The flow has three steps:
  1. Upsun asks Let’s Encrypt for a certificate covering your domains, and Let’s Encrypt replies with a random token for each domain.
  2. Upsun serves each token at http://<your-domain>/.well-known/acme-challenge/<token>, for the duration of the certificate activity only.
  3. Let’s Encrypt resolves your domain in public DNS, sends an HTTP request to that URL, and reads the response.
If the response body matches the token it handed out, the domain is validated and the certificate is issued. If it doesn’t match, or the request never arrives, the challenge fails and there’s no certificate. The tokens exist only while that activity runs. Once it finishes, the path stops serving them, which is why requesting a challenge URL yourself returns a 404 even on a project whose certificates are working. Two properties of that flow explain nearly every failure. Let’s Encrypt resolves your domain from public DNS, not from anything Upsun tells it. And the request has to travel all the way to the origin, through every layer you’ve put in front of it.

The DNS record doesn’t point to Upsun

This is the most common cause by a wide margin. If your A record points somewhere else, Let’s Encrypt sends the validation request somewhere else, and the token it’s looking for isn’t there. It sounds too obvious to be worth checking first, which is why it stays at the top of the list. The usual shape is a domain attached to a project before the DNS cutover, or a route defined for www.example.com when only the apex has a record. Both produce the same error:
Every domain in your routes configuration goes into the certificate, and every domain in the certificate needs its own challenge. One unresolvable route fails the whole renewal.

The AAAA record you forgot about

Here’s the variant that costs people the most time. The A record is correct, the site loads fine in a browser, and the renewal still fails. The reason is the AAAA record. If your domain has one, Let’s Encrypt tries IPv6 first, and it doesn’t fall back to IPv4 the way a browser would. Upsun doesn’t publish AAAA records, so any IPv6 address on a domain pointed at your project sends the validation request to a server that has never heard of the token. Nobody looks at AAAA records by default, which is what makes this one persistent. Check both families explicitly:
If the second command returns anything at all, that’s your renewal failure. There’s no Upsun IPv6 address to point it at instead, so the fix is to delete the record.

A CAA record that excludes Let’s Encrypt

A CAA record is your DNS zone declaring which certificate authorities are allowed to issue for the domain. If the record exists and Let’s Encrypt isn’t in it, issuance is refused before the challenge even runs.
This is working as designed. Someone, often a security team consolidating on a single CA, decided that only that CA can issue certificates for the domain. It’s a good control to have. It also means an automated Let’s Encrypt renewal has no path forward until letsencrypt.org is added to the record, or until you upload your own certificates instead. Let’s Encrypt’s CAA documentation covers the record format, the value to add, and how it checks the record at issuance time. The same applies to the fallback path. If a custom certificate you uploaded gets close to expiry, Upsun treats the domain as uncovered and tries to provision a Let’s Encrypt certificate for it. With a CAA record that excludes Let’s Encrypt, that safety net doesn’t work either.

Your CDN never forwards the challenge

When a CDN sits in front of your project, the validation request hits the CDN, and the CDN decides whether it reaches the origin. If /.well-known/acme-challenge/ is cached, rewritten, or blocked at the edge, Let’s Encrypt gets a response that Upsun didn’t produce. On Fastly, this usually comes from custom VCL. A rule that serves a static response for unknown paths, or a caching rule broad enough to cover /.well-known/, is enough to break it. Tokens are single-use and short-lived, which makes any cached response the wrong one. Upsun’s gateway checks the challenge path before handing validation over to Let’s Encrypt, which is where this error comes from:
Whatever you have at the edge, /.well-known/acme-challenge/ has to reach the origin uncached, and the response has to be the one your application generated. See the Fastly and Cloudflare setup pages for the requirements. Redirects are less of a problem here than people expect. Let’s Encrypt follows up to 10 of them, to ports 80 and 443, and it doesn’t check the certificate on an https target. That last detail is deliberate: it’s what stops an expired certificate from blocking its own renewal. Forcing HTTPS at the edge is fine, and so is a www to apex redirect, as long as the request still ends up at your application. What breaks is a redirect that ends somewhere else, on a marketing site, a maintenance page, a redirect loop, or a chain longer than 10 hops.

The Fastly snippet somebody cleaned up

There’s a related failure worth separating out, because it’s about the CDN’s own certificate rather than the origin’s. If you’re on Upsun’s managed Fastly integration, the setup adds a VCL snippet that lets Fastly’s certificate renewals complete. It looks like leftover configuration from whoever set the service up. Delete it and nothing breaks, which is the problem: the edge certificate stays valid for weeks. Then it comes up for renewal, the renewal fails, and visitors get a certificate warning on a site nobody deployed to in months. If you’re auditing VCL, leave the ACME snippet alone.

Cloudflare Bot Fight Mode

Cloudflare’s Bot Fight Mode answers suspicious requests with a challenge page. Let’s Encrypt’s validation client is, by any reasonable definition, a bot. It gets the challenge page, doesn’t solve it, and reports that the response didn’t match the token. The fix depends on your plan. On paid plans, add a WAF custom rule that skips bot protection for /.well-known/acme-challenge/, and the renewal completes. On the free plan there’s no rule engine to add that exception to, which leaves turning Bot Fight Mode off. If bot protection is the reason you’re on Cloudflare, that’s not much of a choice, so an uploaded certificate or a paid plan is the practical answer.

Too many domains for one certificate

Let’s Encrypt allows at most 100 names per certificate, and Upsun provisions one certificate per environment covering all of that environment’s domains. The 100 sounds generous until you count the way routes actually work. A project with 50 custom domains that also serves each one under www needs 101 names, and the request is rejected. Fleet setups hit this most, since every site added to the fleet adds two names. If you’re near the limit, the options are to drop the www variants you don’t need, or to split the domains across environments.

Anything else in the request path

The pattern behind all of the above is the same. Let’s Encrypt’s request has to reach your application and come back with an exact string, and every hop in between is a chance for something to answer on your behalf. Multiple CDNs stacked in front of each other, a redirect chain that ends on a maintenance page, a WAF rule blocking paths that start with a dot, an edge rule serving a static response for unknown paths: all of them produce the same unhelpful failure. When you’re debugging, request the challenge path yourself from outside your network:
A 404 from your application is the expected result. Anything else, a cached page, a bot challenge page, a 403, or a redirect that doesn’t end at your application, tells you which layer to look at. The certificate checker is a faster way to see where a domain currently points, and the troubleshooting guide lists the errors alongside the DNS records they map to.

Report the failure somewhere you’ll see it

None of the above helps if the failure only exists in an activity log nobody opens. Renewals emit their own activity type, environment.certificate.renewal, and every activity carries a result field that’s either success or not. That’s all you need to alert on. Point a webhook integration at the event, scoped to completed activities:
Your endpoint gets a POST for every renewal, and you raise an alert when result isn’t success. If you’d rather not host a receiver, an activity script runs the same check on Upsun and posts to Slack or Discord directly:
The activity.log property holds the same provisioning output shown earlier, which means the alert arrives with the error message already in it. Scope the integration to the events you care about rather than all of them, or every push becomes a notification.

Renewals fail early, on purpose

Upsun starts renewing Let’s Encrypt certificates four weeks before they expire, and retries in the background until they succeed. That window exists for exactly this class of problem. Today’s failed challenge doesn’t take your site down, and the retries give you weeks to find the AAAA record or the VCL rule. The window only helps if the failure reaches a human. A certificate that expires in production has usually been failing its renewal for weeks, which is why the webhook is worth the 10 minutes it takes to set up.
Last modified on August 25, 2026