How to Set Up Nginx with Let’s Encrypt SSL

How to Set Up Nginx with Let's Encrypt SSL

There’s genuinely no excuse anymore for running a production site without HTTPS. Let’s Encrypt made free, automatically renewing SSL certificates the default expectation rather than a paid extra, and combined with Certbot, getting a working certificate onto an Nginx server takes about five minutes for a straightforward site. This guide covers the full process — installation, certificate issuance, the resulting Nginx config, auto-renewal, and the hardening details I always add on top of the bare minimum.

How Let’s Encrypt Works, Briefly

Let’s Encrypt is a free, automated certificate authority. Instead of manually generating a CSR and waiting on a paid CA to verify your domain, Certbot (the standard client) handles domain validation automatically — usually via the HTTP-01 challenge, where Certbot temporarily places a verification file on your server that Let’s Encrypt’s servers fetch over HTTP to confirm you actually control the domain.

Certificates issued this way are valid for 90 days, which is intentionally short to encourage automated renewal rather than long-lived, potentially-forgotten certificates. Certbot sets up that automation for you as part of the installation process.

Prerequisites

  • A registered domain name pointed at your server’s public IP address (an A record, and AAAA if you’re using IPv6).
  • Nginx installed and already serving the site over plain HTTP on port 80.
  • Port 80 and 443 open in your firewall.
  • Root or sudo access.

Confirm DNS is actually pointing where you expect before starting:

dig +short example.com

This should return your server’s IP. If it doesn’t, wait for DNS propagation before continuing — Let’s Encrypt’s validation will fail otherwise.

Step 1: Install Certbot

On Ubuntu/Debian, I install Certbot via snap, which is what the official Certbot documentation recommends now for the most up-to-date version:

sudo apt update
sudo apt install snapd -y
sudo snap install core; sudo snap refresh core
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

Alternatively, the distro package also works fine and is simpler if you don’t want snap involved:

sudo apt install certbot python3-certbot-nginx -y

I use the python3-certbot-nginx plugin specifically because it can edit your Nginx config automatically — detecting server blocks, adding SSL directives, and setting up the HTTP-to-HTTPS redirect without you touching the config by hand.

Step 2: Confirm Your Existing HTTP Server Block

Before running Certbot, make sure you have a working server_name block for the domain:

server {
    listen 80;
    server_name example.com www.example.com;
    root /var/www/example.com/public;
    index index.html index.php;

    location / {
        try_files $uri $uri/ =404;
    }
}
sudo nginx -t
sudo systemctl reload nginx

Certbot’s Nginx plugin parses this file to know which domains map to which server block, so server_name needs to be accurate.

Step 3: Obtain the Certificate

sudo certbot --nginx -d example.com -d www.example.com

Certbot will:

  1. Ask for an email address (used for renewal and security notifications).
  2. Ask you to agree to the Let’s Encrypt terms of service.
  3. Ask whether to redirect all HTTP traffic to HTTPS automatically — I always say yes to this.
  4. Perform the HTTP-01 challenge validation.
  5. Modify your Nginx config to add the SSL certificate paths and the redirect.

If everything succeeds, you’ll see a confirmation message showing where the certificate files were saved — typically /etc/letsencrypt/live/example.com/.

Step 4: Review What Certbot Changed

It’s worth actually looking at what got modified:

sudo cat /etc/nginx/sites-available/example.com

You’ll typically see something like this now:

server {
    server_name example.com www.example.com;
    root /var/www/example.com/public;
    index index.html index.php;

    location / {
        try_files $uri $uri/ =404;
    }

    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}

server {
    if ($host = www.example.com) {
        return 301 https://$host$request_uri;
    }
    if ($host = example.com) {
        return 301 https://$host$request_uri;
    }
    listen 80;
    server_name example.com www.example.com;
    return 404;
}

Certbot’s use of if for the redirect logic isn’t my favorite pattern stylistically (I generally avoid if in Nginx), but it’s what the plugin generates automatically, it’s well-tested, and it works reliably in practice — I don’t usually rewrite it unless I have a specific reason to.

Step 5: Test HTTPS Is Working

curl -I https://example.com

Should return HTTP/2 200 (or HTTP/1.1 200 depending on whether HTTP/2 is enabled). Check the certificate details directly:

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates -issuer

This confirms the issuer is Let’s Encrypt and shows the validity window.

Setting Up Auto-Renewal

Certbot installs a systemd timer (or a cron job, depending on install method) automatically, but I always verify it’s actually active:

sudo systemctl list-timers | grep certbot

Test the renewal process without actually renewing (a dry run):

sudo certbot renew --dry-run

If that completes without errors, auto-renewal is properly configured, and you genuinely don’t need to think about certificate expiry again — Certbot will renew certificates roughly 30 days before they expire and reload Nginx automatically.

If for some reason the timer isn’t present, I add a cron job manually as a fallback:

sudo crontab -e
0 3 * * * certbot renew --quiet --deploy-hook "systemctl reload nginx"

A Complete, Hardened SSL Configuration

Certbot’s default output is functional but minimal. Here’s what I add on top for a properly hardened production setup:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com www.example.com;
    root /var/www/example.com/public;
    index index.html index.php;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;

    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;

    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        try_files $uri $uri/ =404;
    }
}

Let me walk through the additions beyond Certbot’s defaults:

  • ssl_protocols TLSv1.2 TLSv1.3; — disables older, insecure protocol versions (SSLv3, TLS 1.0, TLS 1.1) that shouldn’t be offered anymore.
  • ssl_stapling on; — enables OCSP stapling, which lets the server proactively provide certificate revocation status to clients, slightly speeding up the TLS handshake and improving privacy versus the client querying the CA directly.
  • Strict-Transport-Security (HSTS) — tells browsers to only ever connect via HTTPS for this domain, for the specified duration, even if a user types http:// or clicks an old HTTP link. I set this cautiously — see the security section below on why you shouldn’t blindly copy preload onto a brand-new domain.
  • ssl_session_cache / ssl_session_timeout — allows TLS session resumption, meaningfully speeding up repeat connections from the same client without a full handshake.

Handling Multiple Domains and Wildcard Certificates

For a single certificate covering multiple subdomains:

sudo certbot --nginx -d example.com -d www.example.com -d blog.example.com

For a wildcard certificate (*.example.com), the HTTP-01 challenge isn’t sufficient — you need the DNS-01 challenge instead, which requires either manual DNS record creation or a DNS provider plugin (Cloudflare, Route53, etc.):

sudo apt install python3-certbot-dns-cloudflare -y
sudo certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/.secrets/cloudflare.ini -d "*.example.com" -d example.com

This requires setting up an API token in the credentials file first, scoped narrowly to DNS editing permissions for just that zone.

Testing SSL Configuration Quality

Beyond just confirming HTTPS works, I always check the actual configuration quality using SSL Labs:

https://www.ssllabs.com/ssltest/analyze.html?d=example.com

This grades your setup (A+ being the target) and flags weak ciphers, missing HSTS, certificate chain issues, or protocol problems that a basic curl test wouldn’t catch.

Locally, I also verify the full chain is being served correctly (a common misconfiguration is serving only the leaf certificate without the intermediate, which breaks some older clients):

openssl s_client -connect example.com:443 -servername example.com -showcerts

Troubleshooting Common Issues

Certbot fails with “Connection refused” during the challenge. Port 80 needs to be open and Nginx needs to be actively serving the domain over HTTP before running Certbot — check your firewall (sudo ufw status) and confirm nginx -t passes.

“DNS problem: NXDOMAIN looking up A record” error. DNS hasn’t propagated yet, or the record doesn’t exist. Recheck with dig +short example.com from an external network, not just locally, since local DNS caching can mask the actual public state.

Certificate obtained but browser still shows “Not Secure.” Usually means the redirect from HTTP to HTTPS didn’t get set up, or there’s a mixed-content issue (the page itself loads over HTTPS but references some resources — images, scripts — over plain HTTP). Check browser DevTools console for mixed-content warnings specifically.

Renewal fails with rate limit errors. Let’s Encrypt limits how many certificates you can request for the same domain set within a rolling window (currently 5 per week for the same exact domain combination). This usually happens during testing/troubleshooting when Certbot gets re-run repeatedly. Use the --dry-run flag for testing instead of requesting real certificates repeatedly, and use the Let’s Encrypt staging environment (--staging) for any repeated testing.

nginx -t fails after Certbot runs. This is rare but can happen if Certbot modifies a config with unusual existing structure. Check the specific error output from nginx -t — it’ll point to the exact line, and you can typically fix it by comparing against the expected structure shown above.

Security Considerations

  • Don’t enable HSTS preload casually. Submitting your domain to the HSTS preload list is essentially permanent (removal is slow and painful) and forces HTTPS for every subdomain too — make sure every subdomain you have or ever plan to have actually supports HTTPS before adding preload.
  • Keep private keys readable only by root/Nginx. Certbot sets this up correctly by default (/etc/letsencrypt/live/ is root-only), but double-check if you’ve done anything custom with permissions.
  • Disable old TLS versions. TLS 1.0 and 1.1 are deprecated across all major browsers already; there’s no compatibility reason to keep them enabled in 2026.
  • Rotate DNS API credentials used for DNS-01 challenges periodically, and scope them as narrowly as your DNS provider allows (zone-specific, DNS-edit-only tokens, not full account access).
  • Monitor certificate expiry independently of Certbot’s own renewal, as a safety net — a monitoring service like UptimeRobot can alert you if a cert is somehow within a few days of expiring, catching a silently broken renewal before users see a warning.

Performance Tips

  • Enable HTTP/2 (shown in the listen 443 ssl http2; line above) — it multiplexes multiple requests over a single connection, which noticeably speeds up pages with many small assets.
  • Use session resumption (ssl_session_cache, ssl_session_tickets) to avoid a full TLS handshake on every repeat visit from the same client.
  • Enable OCSP stapling to shave time off the handshake by avoiding a separate client-side revocation check.
  • Consider TLS 1.3 as the default where client support allows — it reduces handshake round trips compared to TLS 1.2.

Real-World Use Cases

  • A migration from a paid SSL certificate to Let’s Encrypt for a client who was paying annually for a certificate that provided no meaningful security benefit over the free alternative — same encryption strength, just automated renewal instead of a manual yearly task.
  • A multi-subdomain SaaS product where a wildcard certificate via DNS-01 challenge (Cloudflare plugin) covered dozens of customer subdomains under one certificate, avoiding the need to issue and renew a separate cert per subdomain.
  • A staging environment where I deliberately used --staging during initial setup and testing to avoid burning through Let’s Encrypt’s production rate limits while iterating on the Nginx config.

Best Practices Summary

  • Confirm DNS is correctly pointed before running Certbot.
  • Let the Certbot Nginx plugin handle the initial redirect and cert paths, then layer hardening on top.
  • Always run certbot renew --dry-run to confirm auto-renewal actually works.
  • Disable outdated TLS protocols and weak ciphers.
  • Add HSTS carefully, and think twice before preload.
  • Use DNS-01 challenges for wildcard certificates.
  • Monitor certificate expiry independently as a safety net, even with auto-renewal configured.

Once this is set up, HTTPS becomes something you genuinely stop thinking about — Certbot renews certificates quietly in the background, and the only maintenance left is the occasional periodic review of your SSL configuration quality as protocols and cipher recommendations evolve over time.

Standalone Mode for Servers Without a Web Server Yet

Everything covered so far assumes Nginx is already running and serving the domain. Occasionally I need a certificate before Nginx is configured at all — for example, provisioning a brand-new server where I want the certificate ready before writing the final server block. Certbot’s standalone mode handles this by temporarily running its own minimal web server to complete the challenge:

sudo systemctl stop nginx
sudo certbot certonly --standalone -d example.com -d www.example.com
sudo systemctl start nginx

This obtains the certificate files without touching any Nginx configuration at all — you then reference the resulting certificate paths manually in your server block, exactly as shown in the earlier sections. I use certonly here specifically because the --nginx plugin behavior isn’t relevant when Nginx isn’t running yet; certonly just fetches the certificate and leaves configuration entirely to you.

Automating Renewal Reload Correctly

One detail that catches people out: certificate renewal alone doesn’t do anything until Nginx actually reloads and picks up the new certificate files. Certbot’s snap and package installs typically include a deploy hook that handles this automatically, but it’s worth confirming explicitly rather than assuming:

sudo cat /etc/letsencrypt/renewal/example.com.conf

Look for a line referencing a reload or deploy hook. If it’s missing, add one explicitly:

sudo certbot renew --deploy-hook "systemctl reload nginx" --dry-run

And to make this permanent for all future automatic renewals, create a hook script:

sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
systemctl reload nginx
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Any script placed in renewal-hooks/deploy/ runs automatically after a successful renewal, regardless of which specific certbot renew invocation triggers it — this is the most reliable place to put this logic rather than relying on flags passed to a manually-run command.

Certificate Transparency and Monitoring

Every certificate Let’s Encrypt issues gets logged publicly in Certificate Transparency logs — a good thing for the ecosystem overall (it makes fraudulently issued certificates detectable), but worth knowing about, since it means the fact that admin.example.com or staging.example.com exists as a subdomain becomes publicly discoverable the moment you issue a certificate for it, even if the subdomain itself isn’t linked from anywhere. I’ve had clients surprised to learn that a “hidden” staging subdomain was easily found by a security scanner simply by querying CT log search tools like crt.sh.

This isn’t a reason to avoid HTTPS on internal or staging subdomains — the alternative (no encryption) is worse — but it does mean “security through obscurity” for a subdomain name isn’t real security once a certificate has been issued for it. Pair any genuinely sensitive internal subdomain with the IP whitelisting or authentication approaches covered elsewhere, rather than relying on the subdomain name being hard to guess.

Frequently Asked Questions

What happens if my server is offline when a renewal is scheduled? Certbot’s renewal timer runs the check periodically (typically twice daily), so a brief outage isn’t usually a problem — it’ll simply renew successfully the next time the timer fires, as long as that happens before the existing certificate actually expires. I’d only worry about extended downtime approaching the 90-day expiry window.

Can I use Let’s Encrypt for internal, non-public-facing servers? Only if you can complete a domain validation challenge, which typically requires either the server being reachable over HTTP (for HTTP-01) or DNS record access (for DNS-01). An internal server with a real, publicly resolvable domain name can still get a Let’s Encrypt certificate via DNS-01 even without being reachable over the internet directly.

Is there a cost difference between a wildcard and multiple individual certificates? No — Let’s Encrypt certificates are free regardless of type. The tradeoff is operational: a wildcard simplifies management for many subdomains under one certificate, but requires DNS-01 (more setup complexity), while individual certificates via HTTP-01 are simpler to obtain per-domain but mean more certificates to track.

Do I need to renew manually if I change my Nginx configuration? No — configuration changes and certificate renewal are independent. As long as the certificate file paths referenced in your config remain correct, you can freely edit the rest of the server block without affecting the certificate or its renewal schedule at all.

Total
1
Shares

Leave a Reply

Previous Post
How to Whitelist IP Addresses in Nginx

How to Whitelist IP Addresses in Nginx

Next Post
How to Configure Nginx for WordPress

How to Configure Nginx for WordPress

Related Posts