When Certbot's nginx Plugin Mangles Your Server Block, Fix It by Hand
You ran certbot --nginx to add HTTPS to a site. It said congratulations, your certificate was issued. Then the site started redirecting in a loop, or nginx refused to reload, or plain HTTP stopped working entirely. So you did the natural thing and ran certbot --nginx again, hoping it would notice the mess and clean it up.
It did not clean it up. It added another server block. Now you have two server blocks listening on 443 for the same hostname, nginx is complaining about a conflict, and you genuinely cannot tell which directives certbot wrote and which were yours to begin with.
The certbot nginx plugin is convenient right up until your config is anything other than the pristine default it expects. On a customised config it does not edit surgically, it appends, and running it repeatedly compounds the damage. The reliable fix is to stop letting it edit your config at all: obtain the certificate with certonly and wire the TLS directives in by hand, where you can see every one of them.
Do not re-run certbot --nginx hoping it self-corrects. Instead:
- Get the cert without letting the plugin touch your config:
certbot certonly --nginx -d example.com(or--webroot). - Add
ssl_certificateandssl_certificate_keyto your server block yourself, pointing at/etc/letsencrypt/live/example.com/. - Delete any duplicate
listen 443blocks the plugin already created for the sameserver_name. - Run
nginx -tbefore every reload, and keep the config in git so certbot's edits show up as a reviewable diff.
The symptoms: conflicts, loops, and a config you no longer recognise
When the plugin has mangled things, you usually see one of these when you test the config:
nginx: [warn] conflicting server name "example.com" on 0.0.0.0:443, ignored
nginx: [emerg] duplicate listen options for [::]:443
Or the site loads over HTTPS but every plain-HTTP request bounces forever, because certbot added a return 301 https://... to a block that was already inside the HTTPS server, so HTTPS redirects to HTTPS. The common thread is that certbot wrote something valid enough to load, or nearly load, but wrong.
Why the plugin does this on customised configs
The nginx plugin works by parsing your config, finding a server block that matches the domain you asked for, and rewriting it to add TLS: a listen 443 ssl line, the two ssl_certificate directives, an include of the recommended options file, and optionally an HTTP-to-HTTPS redirect. On the stock single-server-block config from a fresh install, this is a clean, well-defined edit.
Your production config is rarely that. You might have split HTTP and HTTPS into separate server blocks, or have multiple server_name entries, or an existing redirect, or includes that pull in more server blocks from another file. The plugin's matching heuristics were tuned for the simple case. When it cannot confidently find the one right block to edit, it does not stop, it makes a reasonable guess, and a reasonable guess against a config it does not fully understand is how you get a second 443 block appended instead of the existing one modified.
Run it again and the situation is worse, not better, because now there are two candidate blocks and the plugin's guess has more ways to go wrong. This is the trap: the tool that broke it is not the tool that will fix it, because it has no concept of "undo the last thing I did". It only knows how to add.
The fix: certonly, then wire it in yourself
Step 1: get the certificate without editing the config
The certonly subcommand obtains and renews the certificate but never rewrites your server blocks. You can still use the nginx plugin purely to answer the challenge:
sudo certbot certonly --nginx -d example.com -d www.example.com
If the plugin's temporary edits during validation also worry you, use webroot instead, which only writes a challenge file into a directory you specify and touches nothing else:
sudo certbot certonly --webroot -w /var/www/example -d example.com -d www.example.com
Either way you should see the certificate saved:
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/example.com/privkey.pem
Step 2: remove any duplicate 443 blocks certbot already made
Before adding anything, find what is already there. If a previous --nginx run left a stray block, delete it now so you are editing exactly one server block per hostname.
sudo grep -rn "listen 443" /etc/nginx/sites-enabled/ /etc/nginx/conf.d/
If two blocks list the same server_name on 443, keep the one that is yours and delete certbot's addition, which is usually the one with the # managed by Certbot comments on every line it touched.
Step 3: add the TLS directives by hand
In your single HTTPS server block, add the certificate paths. These are the only two lines that must point at Let's Encrypt output:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# your existing location blocks, roots, proxy_pass, etc.
}
# separate plain-HTTP block that redirects, kept distinct from the 443 block
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Keeping the redirect in its own port-80 block is what prevents the HTTPS-to-HTTPS loop: the redirect only ever fires for traffic that arrived on 80. For a fuller hardened baseline, including modern ciphers and rate limiting, see Nginx Reverse Proxy, TLS and Rate Limiting.
Step 4: test, then reload
sudo nginx -t && sudo systemctl reload nginx
Expected output from the test, and only then reload:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
How to verify TLS and renewal both work
- Confirm the certificate served is the Let's Encrypt one and the chain is complete:
You want an issuer from Let's Encrypt and aecho | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -issuer -datesnotAfterdate about 90 days out. - Dry-run the renewal so you know it will not silently fail in 60 days:
Because you usedsudo certbot renew --dry-runcertonly, renewal reissues the files and does not touch your config, which is exactly what you want. If renewals worry you generally, wire up alerting as in Certbot renewal fails silently.
What people get wrong
Re-running certbot --nginx to fix a certbot --nginx mistake. This is the central error. The plugin has no rollback. Every run adds, so running it against an already-broken config layers a new guess on top of the old damage and produces the duplicate-block conflicts. Once the plugin has confused itself, take the config away from it with certonly.
Reloading without nginx -t. The plugin frequently emits output that is syntactically odd but valid enough to alarm you only at reload time, or worse, valid but semantically wrong like a redirect loop. Testing first is free and catches the hard failures before they take the site down.
Editing the config with no version control. If /etc/nginx is not in git, certbot's edits are an invisible mutation and you will never be sure what it changed. Put the config under version control so that after any certbot run, git diff shows you the exact directives it added and you can revert them individually.
When it is still broken
- Still getting "conflicting server name". There is another server block for the same name somewhere you have not looked, often an
includepulling in a file fromconf.d. Grep the whole tree, not justsites-enabled. - Browser shows the wrong certificate or a default page. nginx falls back to the first server block when nothing matches
server_name. Make sure the block with your certificate actually matches the hostname the browser requested, including www versus apex. - Chain errors on some clients only. You may be serving the leaf certificate instead of
fullchain.pem. Pointssl_certificateatfullchain.pem, nevercert.pem. The full walk-through is in fix the chain, not the client. - Renewal reloads nginx but the new cert is not served. Add a deploy hook so nginx reloads after renewal:
certbot renew --deploy-hook "systemctl reload nginx".
The plugin is fine for a first-time cert on a clean box. The moment your config has any real shape to it, take the certificate and leave the editing to yourself, where every directive is one you can read.
Frequently asked questions
- How do I get a Let's Encrypt certificate without letting certbot edit my nginx config?
- Use certbot certonly. With certonly, certbot obtains and installs the certificate files but does not touch your server blocks, so you add the ssl_certificate and ssl_certificate_key directives yourself. You can validate with --nginx (or --webroot) while still keeping certonly's hands off your config.
- Why does certbot --nginx create duplicate 443 server blocks?
- Each time you run certbot --nginx it tries to add TLS to a matching server block. If your config has changed, or a previous run already added a block, certbot can append a second server block listening on 443 for the same server_name instead of editing the existing one, which nginx then flags as a conflict.
- How do I see exactly what certbot changed in my nginx config?
- Keep your nginx configuration in version control. After any certbot run, git diff shows the precise directives and blocks certbot added or modified, so you can review and revert individual changes rather than guessing. Without version control, certbot's edits are a silent mutation you cannot audit.
- Should I run nginx -t before reloading after certbot edits the config?
- Always. The certbot nginx plugin can produce output that is syntactically valid but semantically wrong, such as a duplicate server block or a redirect loop. nginx -t catches hard syntax errors before you reload, and a manual read of the diff catches the valid-but-wrong ones.