> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Why Let's Encrypt certificate renewals fail

> Certificate renewals fail for a handful of recurring reasons. Here's how the HTTP-01 challenge works and what breaks it, from IPv6 records to CDN rules.

export const PostMeta = ({data = {}}) => {
  const {author, date} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const toSlug = value => String(value).toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const url = `/posts/authors/${toSlug(slug)}`;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna",
      "github": "jackrabbithanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud",
      "linkedin": "https://www.linkedin.com/in/laurent-arnoud-861b44121/"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "maz-mohammadi": {
      "name": "Maz Mohammadi"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
    </div>;
};

<PostMeta data={{ author: ["ralt"], date: "2026-08-25T08:00:00.000Z" }} />

You attach a domain, you get HTTPS, and 90 days later you get a fresh certificate without touching anything. [The provisioning system behind that](/posts/how-it-works/how-upsun-provisions-tls-certificates-at-scale) 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](https://letsencrypt.org/docs/challenge-types/#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:

```text theme={null}
  E: Error validating domain www.example.com: Couldn't complete challenge [HTTP01: pending | DNS01: pending | TLSALPN01: pending]
  Unable to validate domains www.example.com, will retry in the background.
```

Every domain in your [routes configuration](/docs/routes) 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:

```bash theme={null}
  dig +short A www.example.com
  dig +short AAAA www.example.com
```

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.

```bash theme={null}
  dig +short CAA example.com
```

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](https://letsencrypt.org/docs/caa/) 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:

```text theme={null}
  W: Failed to verify the challenge at the gateway for the domain 'www.example.com'
  E: Error validating domain www.example.com: Couldn't complete challenge [HTTP01: The client lacks sufficient authorization]
```

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](/docs/domains/cdn/fastly) and [Cloudflare](/docs/domains/cdn/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](/docs/domains/cdn/managed-fastly), 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:

```bash theme={null}
  curl -I http://www.example.com/.well-known/acme-challenge/test
```

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](https://certcheck.upsun.com/) is a faster way to see where a domain currently points, and [the troubleshooting guide](/docs/domains/troubleshoot#ownership-verification) 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](/docs/integrations/activity/webhooks) at the event, scoped to completed activities:

```bash theme={null}
  upsun integration:add --type webhook \
    --url https://alerts.example.com/upsun \
    --events environment.certificate.renewal \
    --states complete
```

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](/docs/integrations/activity) runs the same check on Upsun and posts to Slack or Discord directly:

```javascript theme={null}
  if (activity.result !== "success") {
    sendSlackMessage("Certificate renewal failed", activity.log);
  }
```

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.
