If certificate renewal fails with a 404 on /.well-known/acme-challenge/, the problem is usually not Let's Encrypt itself. Nginx is not serving the validation file from the webroot Certbot expects.

Operational context

  • When to use this: you run Certbot in webroot mode behind Nginx and renewals fail during HTTP-01 validation.
  • What it fixes: requests to /.well-known/acme-challenge/ returning 404 instead of the challenge token file.
  • Tradeoff: webroot validation depends on Nginx serving the exact challenge path. A misconfigured location block or wrong webroot path will break renewals silently until expiry approaches.

Typical Certbot command

certbot certonly \
  --webroot \
  --webroot-path=/var/www/letsencrypt \
  --email email@domain.com \
  --agree-tos \
  -d domain.com \
  -d www.domain.com

Older guides may use the letsencrypt command name. On current systems the command is usually certbot.

Nginx webroot configuration

Create a dedicated challenge directory:

sudo mkdir -p /var/www/letsencrypt/.well-known/acme-challenge
sudo chown -R www-data:www-data /var/www/letsencrypt

Then add an explicit location block to the HTTP server:

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

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

    location / {
        return 301 https://$host$request_uri;
    }
}

The root directive matters. With the configuration above, a request for:

/.well-known/acme-challenge/test-token

maps to:

/var/www/letsencrypt/.well-known/acme-challenge/test-token

WordPress or application catch-all conflict

WordPress and many PHP applications use a catch-all route such as:

try_files $uri $uri/ /index.php?$args;

If the ACME challenge path reaches that block, the application may return a 404, redirect, or themed error page. The ACME location ^~ /.well-known/acme-challenge/ block must appear before generic application handling so Nginx serves the token file directly.

Local verification before renewal

Create a temporary token:

echo "ok" | sudo tee /var/www/letsencrypt/.well-known/acme-challenge/ping

Reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Then test from outside the server:

curl -i http://domain.com/.well-known/acme-challenge/ping

You should see HTTP/1.1 200 OK and the body ok.

What to verify in production

  1. The webroot path in Certbot matches the directory Nginx serves for the domain.
  2. Nginx has an explicit location for /.well-known/acme-challenge/.
  3. The challenge file is readable by the Nginx worker process.
  4. No catch-all rewrite sends ACME requests to WordPress or another application route.
  5. HTTP traffic on port 80 reaches this server from the public internet.

Renewal dry run

After configuration changes, run:

sudo certbot renew --dry-run

A successful dry run confirms the challenge path and renewal automation work before the real certificate is close to expiry.

When HTTP-01 is not the right choice

Use DNS-01 validation instead when:

  • port 80 is blocked
  • the site sits behind a CDN or WAF rule that interferes with challenge files
  • you need wildcard certificates
  • multiple origin servers serve the same hostname without a shared challenge directory

HTTP-01 is simple when one Nginx origin owns the hostname. DNS-01 is usually better for wildcard and multi-origin setups.

Useful references

Verification

After updating Nginx, request a dry-run renewal or issue a test certificate. A successful validation confirms that Nginx is serving the challenge path correctly and that automated renewals should continue to work.

Related work

This TLS automation pattern supports Golden image and hardening pipeline and secure platform baselines in Selected operational work.