Nginx Let's Encrypt SSL: Certbot, Auto-Renewal, and TLS Setup
To set up Nginx with a free Let's Encrypt SSL certificate, install Nginx, point your DNS at the server, run certbot with the Nginx plugin, and confirm the renewal timer works. The whole process takes about ten minutes on a clean Linux server, and the rest of this guide covers the parts people usually get wrong: the HTTP-to-HTTPS redirect, renewal hooks, and a TLS configuration that holds up to modern scanners.
The examples use Ubuntu 22.04/24.04 and Debian-style paths. AlmaLinux and Rocky Linux differences are noted where they matter. We use example.com and the documentation address 203.0.113.10 throughout. Replace them with your own values.
Prerequisites
Before you request a certificate, check these four things. Most failed issuance attempts come from one of them.
- DNS:
example.comandwww.example.comhave A records (and AAAA records if you use IPv6) pointing to your server, for example203.0.113.10and2001:db8::10. - Port 80 reachable from the internet: the HTTP-01 challenge connects to port 80. Port 443 should be open too.
- Root or sudo access: a VDS or dedicated server gives you this. Shared hosting usually does not.
- No other service on 80/443: stop Apache or any container binding those ports first.
Verify DNS from your own machine:
dig +short A example.com
dig +short AAAA example.com
If the AAAA record points somewhere that does not serve your site, Let's Encrypt may try IPv6 and fail. Remove the record or fix it.
Step 1: Install Nginx
On Ubuntu or Debian:
sudo apt update
sudo apt install -y nginx
sudo systemctl enable --now nginx
On AlmaLinux or Rocky Linux:
sudo dnf install -y nginx
sudo systemctl enable --now nginx
Open the firewall. With UFW:
sudo ufw allow 'Nginx Full'
sudo ufw status
With firewalld:
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload
Step 2: Create a Server Block
Create /etc/nginx/sites-available/example.com (on RHEL-family systems use /etc/nginx/conf.d/example.com.conf instead):
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Create the web root, enable the site, test, and reload:
sudo mkdir -p /var/www/example.com/html
echo "<h1>It works</h1>" | sudo tee /var/www/example.com/html/index.html
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Make sure curl -I http://example.com returns 200 OK before moving on. The server_name line matters: certbot's Nginx plugin uses it to find which block to edit.
Step 3: Install Certbot
The Certbot project recommends the snap package because it stays current:
sudo apt install -y snapd
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
If you prefer distribution packages, sudo apt install certbot python3-certbot-nginx works, but the version may lag behind. On RHEL-family systems, certbot is typically installed via snap or from EPEL.
Step 4: Get the Certificate
Run certbot with the Nginx plugin:
sudo certbot --nginx -d example.com -d www.example.com
Certbot will ask for an email address (used for expiry warnings) and ask you to accept the terms of service. It then validates both names, downloads the certificate, edits your server block, and reloads Nginx.
Files are stored under /etc/letsencrypt/live/example.com/:
| File | Purpose |
|---|---|
fullchain.pem |
Your certificate plus intermediates. Use this for ssl_certificate. |
privkey.pem |
Private key. Never share it or commit it to a repo. |
chain.pem |
Intermediates only. |
Webroot or certonly mode
If you do not want certbot touching your config, get the certificate only and edit Nginx yourself:
sudo certbot certonly --webroot -w /var/www/example.com/html \
-d example.com -d www.example.com
This is the better choice for configuration-managed servers (Ansible, Salt) where automatic edits would be overwritten.
Step 5: Redirect HTTP to HTTPS
Certbot normally adds a redirect when you choose it. If you manage the config manually, write two separate server blocks. A clean, explicit layout looks like this:
# HTTP: redirect everything except ACME challenges to HTTPS
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/example.com/html;
}
location / {
return 301 https://example.com$request_uri;
}
}
# HTTPS
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
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;
root /var/www/example.com/html;
index index.html;
}
Notes:
- Use
return 301, notrewrite. It is faster and clearer. http2 on;is the syntax for Nginx 1.25.1 and later. On older versions, uselisten 443 ssl http2;instead.- To force a single canonical hostname, add a separate HTTPS block that redirects
wwwto the bare domain (or the reverse). Do not leave both versions serving content.
Step 6: Secure TLS Settings
Certbot installs a baseline in /etc/letsencrypt/options-ssl-nginx.conf. If you want to control it yourself, put these directives in the HTTPS server block or a shared snippet:
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header X-Content-Type-Options nosniff always;
add_header Strict-Transport-Security "max-age=31536000" always;
What each part does:
- TLS 1.2 and 1.3 only. TLS 1.0 and 1.1 are deprecated. Dropping them breaks only very old clients.
- AEAD ciphers with forward secrecy. This follows the widely used "intermediate" profile. TLS 1.3 ciphers are not configured with
ssl_ciphers; they are handled automatically. - Session cache, tickets off. Resumption stays fast, and you avoid long-lived ticket keys that weaken forward secrecy.
- HSTS. This tells browsers to use HTTPS only. Start with a short
max-age(for example300) while testing, because a wrong HSTS header on a broken certificate locks visitors out. AddincludeSubDomainsonly if every subdomain supports HTTPS. Avoidpreloadunless you understand that it is hard to undo.
A note on OCSP stapling: Let's Encrypt has been moving away from OCSP toward CRLs. Check the current Let's Encrypt announcements before adding ssl_stapling on; for their certificates. It is not required for a secure setup.
Always validate and reload:
sudo nginx -t && sudo systemctl reload nginx
Step 7: Automatic Renewal
Let's Encrypt certificates are valid for 90 days. The certbot package installs a systemd timer that checks twice a day and renews certificates that are close to expiry. Confirm it exists:
systemctl list-timers | grep certbot
Then test the full renewal flow without issuing a real certificate:
sudo certbot renew --dry-run
If the dry run passes, renewal will work unattended. To make sure Nginx picks up the new certificate, add a deploy hook:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
The plugin-based flow usually reloads Nginx on its own, but the hook protects you in certonly and webroot setups. Hook scripts must be executable, or they are silently skipped.
Wildcard Certificates
Wildcards (*.example.com) require the DNS-01 challenge, because HTTP-01 cannot prove control of every subdomain. You need either a certbot DNS plugin for your DNS provider or a manual TXT record:
sudo certbot certonly --manual --preferred-challenges dns \
-d example.com -d '*.example.com'
Manual mode does not renew automatically. For production, use a DNS plugin with API credentials limited to the zone.
Verify the Result
curl -I http://example.com # expect 301 to https
curl -I https://example.com # expect 200
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -dates -issuer
sudo certbot certificates
Also run the domain through an external TLS scanner such as SSL Labs to confirm the chain and protocol list from the outside.
Common Mistakes and Fixes
- "Timeout during connect" or "Connection refused." Port 80 is blocked by a host firewall, a security group, or an upstream filter. Test from outside with
curl -I http://example.com. - Wrong site validated. A stale AAAA record or a default server block answers the challenge instead of your site. Check
digoutput and yourserver_nameentries. - Redirect loop. This happens behind a CDN or reverse proxy that talks to Nginx over HTTP while Nginx redirects to HTTPS. Pass and check
X-Forwarded-Proto, or set the proxy's origin mode to HTTPS. - Rate limit errors. Let's Encrypt limits repeated failed validations and duplicate certificates. Use
--dry-runor the--stagingflag while testing. - Renewal works manually but not by timer. Check
journalctl -u certbot.service. Common causes are a stopped Nginx, changed paths, or a deploy hook that is not executable. - Mixed content warnings. Your pages still load assets over
http://. Update the application's base URL and use relative or HTTPS links. - Certificate for the wrong name. Every hostname you serve must be listed with
-d. Adding a hostname later requirescertbot --nginx -d example.com -d www.example.com -d app.example.com --expand.
Where Your Server Fits
Certificates only protect traffic in transit. A public web server also needs a sensible network layer: restricted management ports, rate limiting, and a plan for volumetric attacks. If you are hosting on a VDS with full root access, you can run this exact stack and control every Nginx and firewall setting yourself. For public sites that attract hostile traffic, consider adding DDoS protection and a managed firewall in front of the origin, so TLS terminates on your server but junk traffic is filtered before it arrives.
Quick Checklist
- DNS A/AAAA records point to the server.
- Ports 80 and 443 are open.
certbot --nginxsucceeded for all hostnames.- HTTP returns a 301 to the canonical HTTPS URL.
- TLS 1.2 and 1.3 only, session tickets off, HSTS added carefully.
certbot renew --dry-runpasses and a reload hook exists.- External scan shows a valid chain.
Frequently asked questions
Is Let's Encrypt really free for commercial use?
Yes. Let's Encrypt issues domain-validated certificates at no charge, including for commercial sites. It is run by a nonprofit, and you only pay for your own server and domain.
How often does certbot renew certificates?
Certificates last 90 days. The certbot systemd timer (or cron job) runs twice daily and renews any certificate that is within about 30 days of expiry. Run certbot renew --dry-run to confirm it works.
Can I get a Let's Encrypt certificate for an IP address or internal hostname?
Standard Let's Encrypt issuance requires a publicly resolvable domain name that you control. Internal hostnames can still get certificates through the DNS-01 challenge if the domain is public, but private-only names cannot be validated.
Why does certbot fail even though my site loads in a browser?
The usual causes are port 80 blocked from outside, an AAAA record pointing to the wrong host, or a CDN or proxy intercepting the challenge request. Test with curl from another network and check dig output for both A and AAAA records.