Let's Encrypt Without Certbot Touching Your Nginx Config

Certbot's Nginx plugin is convenient — it finds your server blocks, edits them, and wires up SSL in one command. It's also a liability if you maintain your Nginx configuration by hand. The plugin can reorder directives, strip comments, reformat your carefully indented blocks, and sometimes leave behind config debris when you remove a domain. The alternative is simpler than it sounds: tell Certbot to fetch the certificate and do nothing else. You keep full control of your Nginx configuration.

Install Certbot

Certbot recommends their official installation instructions for your specific OS, but the short version for most modern systems:

# Debian/Ubuntu
sudo apt install certbot

# RHEL/Rocky/Alma (EPEL required)
sudo dnf install certbot

You don't need python3-certbot-nginx or certbot-nginx — those are the Nginx plugin packages. You only need the base certbot package, which ships with the certonly subcommand.


The shared webroot — one path for all domains

Certbot validates domain ownership by placing a temporary file in your webroot and having the Let's Encrypt servers fetch it over HTTP. The default approach ties the webroot to each domain's document root, but there's a cleaner way: use a single directory shared across all your server blocks.

Create a webroot that Nginx can serve regardless of which domain is being validated:

sudo mkdir -p /usr/share/nginx/html/.well-known/acme-challenge
sudo chown -R www-data:www-data /usr/share/nginx/html

The user and group www-data:www-data is correct for Debian, Ubuntu, and most APT-based distributions. On RHEL, Fedora, or Rocky Linux, the web user is typically nginx:nginx. Check the user directive at the top of your nginx.conf if you're unsure — it's the first line and it tells you exactly who Nginx runs as.

Then in every server block — both HTTP and HTTPS — add:

location ^~ /.well-known/acme-challenge/ {
    root /usr/share/nginx/html;
    allow all;
}

The ^~ prefix ensures this location takes priority over regex matches. A few things worth understanding about why both ports need it:

  • First issuance: No certificate exists yet, so HTTPS isn't available. Let's Encrypt connects to port 80, and the challenge file must be served there directly.
  • Renewal: Certbot attempts renewal 30 days before expiry — with Let's Encrypt's 90-day certs, that means renewals start at day 60. If the challenge file is served directly from the port 80 catch-all (as shown above), Let's Encrypt gets its token on port 80 and never follows the redirect. If the ACME location only exists on the HTTPS server block, Let's Encrypt follows the port 80 redirect to 443 — which works, but relies on the redirect hop. The port 80 catch-all avoids it entirely.
  • Both ports, always: The simplest approach is to include the ACME location in every server block, HTTP and HTTPS, via a shared snippet. The Securing Nginx and PHP guide covers the listen.conf snippet that handles this alongside SSL and HSTS in one include.

The port 80 catch-all approach: A single port 80 catch-all serves the ACME challenge file for every domain at once, removing the need to add it to individual HTTP server blocks. On the HTTPS side, the ACME location still lives in each server block — either inline or via a shared snippet like listen.conf — as a fallback in case Let's Encrypt probes port 443 directly. The catch-all handles the port 80 path; the HTTPS side is insurance:

server {
    listen 80;
    listen [::]:80;
    server_name _;

    location /.well-known/acme-challenge/ {
        root /usr/share/nginx/html;
        allow all;
    }

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

No ^~ modifier is needed here — /.well-known/acme-challenge/ is a longer prefix than /, so Nginx matches it first naturally. There are no regex locations in a simple catch-all to compete with. On port 443, where regex handlers like location ~ \.php$ are active, ^~ becomes necessary to guarantee the ACME path takes priority. The Securing Nginx and PHP guide covers the listen.conf snippet that handles the HTTPS side alongside SSL and HSTS in one include.

Now any domain pointed at your server can complete HTTP validation through the same directory — no per-site configuration needed.


Issue a certificate

Use certonly (not run) to fetch a certificate without Certbot touching your Nginx config:

sudo certbot certonly --webroot \
  --webroot-path /usr/share/nginx/html \
  -d mysite.com \
  -d www.mysite.com \
  --cert-name mysite.com
  • certonly — Fetch the certificate only. Don't install or modify any server configuration.
  • --webroot — Use HTTP validation by placing a temporary file in your webroot for Let's Encrypt to fetch.
  • --webroot-path — The directory Nginx serves .well-known/acme-challenge from. This is the shared path you set up earlier.
  • -d — A domain to include in the certificate. Repeat for each subdomain or additional domain you want covered.
  • --cert-name — A human-readable name for this certificate. Defaults to the first -d domain if omitted, but being explicit avoids Certbot appending -0001 if you ever reissue.

Your certificate lands in /etc/letsencrypt/live/mysite.com/ — the name you gave it, not an auto-generated label:

ls /etc/letsencrypt/live/
# mysite.com/  anothersite.com/  

Naming certificates by domain makes them easy to identify at a glance. You can list them, check expiry dates, or remove old ones without decoding cryptic directory names:

sudo certbot certificates

Wire it into Nginx

Now you reference the certificate paths manually in your server block — no Certbot-managed includes, no auto-generated symlink names:

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name mysite.com;

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

    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # ... rest of your configuration ...
}

The ssl_trusted_certificate directive is absent — Let's Encrypt phased out OCSP stapling in 2025, and the chain certificate is already bundled in fullchain.pem. Only ssl_certificate and ssl_certificate_key are needed. The options-ssl-nginx.conf and ssl-dhparams.pem files are generated the first time you run Certbot (even with certonly). They contain Mozilla-recommended TLS settings and a 4096-bit DH parameters file — safe to share across all your server blocks.


Set up automatic renewal

Certbot certificates are valid for 90 days. A cron job handles renewal before expiry:

# /etc/cron.d/certbot — or add to root's crontab
45 6 * * * root certbot renew \
  --webroot --webroot-path /usr/share/nginx/html \
  --quiet \
  --renew-hook "systemctl reload nginx"

A few things about this crontab entry:

  • 45 6 — Runs at 6:45 AM daily. Certbot only actually attempts renewal when certificates are within 30 days of expiry; the rest of the time it's a no-op that completes in under a second.
  • --webroot — Tells renew to use the webroot authenticator for all certificates. Certbot remembers which plugin issued each cert; this ensures it uses the right one.
  • --webroot-path — The same shared directory. This applies to every certificate being renewed, so it only works if all your certs were issued with the same webroot path.
  • --quiet — Suppresses output on success. Cron will still email you on failure if your system is configured for it.
  • --renew-hook — Runs only if at least one certificate was actually renewed. A simple Nginx reload picks up the new files without dropping connections.

If you have certificates issued through different methods (some webroot, some DNS), you can still use certbot renew without the --webroot flag — Certbot will use whatever authenticator each certificate was originally issued with.


Adding domains to an existing certificate

You added a new subdomain and want it covered by the same certificate:

sudo certbot certonly --webroot \
  --webroot-path /usr/share/nginx/html \
  --cert-name mysite.com \
  -d mysite.com -d www.mysite.com -d blog.mysite.com

You must list all domains you want on the certificate, including the original ones — Certbot replaces the certificate, it doesn't append. If you forget an existing domain, it's dropped. After reissue, reload Nginx.


Removing a certificate

When you retire a site, clean up its certificate:

sudo certbot delete --cert-name mysite.com

This removes the certificate and its renewal configuration. Your Nginx server block still references the old paths — Certbot won't clean those up for you, and you'll want to remove or archive the server block yourself.


Checking certificate status

# List all certificates and their expiry dates
sudo certbot certificates

# Dry-run renewal to check for problems
sudo certbot renew --dry-run

Why this approach?

Your Nginx configuration is yours. Certbot never touches it. You can use includes, split configurations across files, version them in git, and deploy them with Ansible — Certbot's plugin doesn't know about any of that. When you remove a domain, you delete its server block and its certificate independently. When you add one, you write the configuration once and issue the certificate once. No round-trips through Certbot's auto-generated edits, no leftover server { } blocks from certificates you removed, no comments silently stripped from your hand-tuned config.

For a deeper dive into the webroot authenticator: Certbot Webroot Documentation

Technical Audit Summary

This guide is maintained as part of a modular, SSL-first framework. Each configuration is audited for production stability and modern security standards.

Last Audit:June 2026
Environment:Debian Trixie (13)
Nginx:1.31.1
PHP-FPM:8.5.6

Compatibility: Tested against current stable releases. While optimized for the stack above, core logic remains relevant for Nginx 1.26+ and PHP 8.2+ environments.

2026-06-07: Removed ssl_trusted_certificate from the Nginx server block — Let's Encrypt phased out OCSP stapling in 2025, and fullchain.pem already bundles the chain. Added the port 80 catch-all pattern as the recommended approach for serving ACME challenges — a single server block with a plain prefix location handles every domain without ^~ (no regex competitors on port 80). Clarified that renewal via port 80 catch-all avoids the redirect entirely; the HTTPS-side ACME location is insurance, not a requirement. Bumped Nginx to 1.31.1.