How to Set Up Nginx with Let’s Encrypt SSL on Rocky Linux 10 (Certbot, Auto-Renewal & Reverse Proxy)

Every public website, API, and admin panel should use HTTPS, and Let’s Encrypt provides trusted certificates for free through an automated protocol called ACME. On Rocky Linux 10 the usual toolchain is Nginx plus Certbot: Nginx serves the site and Certbot obtains and renews the certificate.

This guide goes from a fresh server to a hardened HTTPS site with automatic renewal, covering firewalld, SELinux, a reverse-proxy example for apps such as n8n, wildcard certificates with DNS validation, and the upcoming shorter certificate lifetimes.

Table of Contents

  1. How the pieces fit together
  2. Prerequisites
  3. Step 1: Install Nginx and open the firewall
  4. Step 2: Create a server block
  5. Step 3: Install Certbot
  6. Step 4: Get the certificate
  7. Step 5: Auto-renewal
  8. Step 6: Harden the TLS configuration
  9. Certificate lifetimes are getting shorter
  10. Reverse proxy to an application
  11. Wildcard certificates with DNS-01
  12. Testing your setup
  13. Troubleshooting
  14. FAQ

1. How the pieces fit together

  Browser                                              Let's Encrypt (ACME CA)
     |                                                          ^
     | 1. https://app.example.com                               | 3. "Prove you control the domain"
     v                                                          |    HTTP-01: file at /.well-known/acme-challenge/
 +---------------+   2. certbot requests cert   +------------+  |    DNS-01: TXT record _acme-challenge
 |  Nginx        | <--------------------------> |  Certbot   |--+
 |  :80  :443    |   4. cert + key saved in     | (EPEL pkg) |
 |  TLS termination  /etc/letsencrypt/live/...   +-----+------+
 +-------+-------+                                     |
         |  5. proxy_pass (optional)                   | 6. timer renews, then
         v                                             |    deploy hook reloads Nginx
 +---------------+                                     v
 | App :5678 etc.|                              certbot-renew.timer
 +---------------+
TermMeaning
ACMEThe protocol Certbot uses to talk to Let’s Encrypt
HTTP-01 challengeProves control by serving a file on port 80
DNS-01 challengeProves control with a DNS TXT record; required for wildcards
Certificate lifetimeHow long a certificate is valid before it must be renewed

2. Prerequisites

RequirementDetail
OSRocky Linux 10 (AlmaLinux 10 and RHEL 10 work the same)
DomainA domain you control, with an A record (and AAAA if you use IPv6) pointing to the server
Ports80 and 443 reachable from the internet
Accesssudo user
EmailA real address for expiry notices and account recovery

Check DNS before you start; a wrong record is the number one reason for failures:

dig +short A app.example.com
dig +short AAAA app.example.com
curl -4 -s ifconfig.me

The A record must match your server’s public IP. If you publish an AAAA record, IPv6 must also work, or Let’s Encrypt may validate over IPv6 and fail.

3. Step 1: Install Nginx and open the firewall

sudo dnf install -y nginx
sudo systemctl enable --now nginx
nginx -v
systemctl status nginx --no-pager

Open HTTP and HTTPS in firewalld (see the firewalld guide for finer control):

sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
sudo firewall-cmd --list-services

Visit http://app.example.com and you should see the default Nginx page.

4. Step 2: Create a server block

Create a document root with the right SELinux label, so Nginx may read it:

sudo mkdir -p /var/www/app.example.com/html
echo '<h1>app.example.com works</h1>' | sudo tee /var/www/app.example.com/html/index.html

# Files created under /var/www inherit httpd_sys_content_t. For a custom location, add a rule:
# sudo semanage fcontext -a -t httpd_sys_content_t "/srv/site(/.*)?" && sudo restorecon -Rv /srv/site
ls -dZ /var/www/app.example.com/html

Create /etc/nginx/conf.d/app.example.com.conf:

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    root /var/www/app.example.com/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
sudo nginx -t
sudo systemctl reload nginx
curl -I http://app.example.com

nginx -t checks the syntax; always run it before reloading.

5. Step 3: Install Certbot

On Rocky Linux 10, Certbot comes from the EPEL repository (the older snap and pip methods are not needed).

sudo dnf install -y epel-release
sudo dnf install -y certbot python3-certbot-nginx
certbot --version

python3-certbot-nginx is the plugin that edits your Nginx configuration automatically. If you prefer to edit Nginx yourself, install only certbot and use the webroot method below.

6. Step 4: Get the certificate

Option A: Nginx plugin (simplest)

sudo certbot --nginx -d app.example.com -d www.app.example.com \
  --agree-tos -m you@example.com --redirect

Certbot validates the domain over HTTP, saves the certificate, and edits the server block to listen on 443 and redirect HTTP to HTTPS. Files appear in:

FilePurpose
/etc/letsencrypt/live/app.example.com/fullchain.pemCertificate plus intermediates (use this in Nginx)
/etc/letsencrypt/live/app.example.com/privkey.pemPrivate key (keep secret)
/etc/letsencrypt/renewal/app.example.com.confRenewal settings

Option B: webroot (you control the Nginx config)

sudo certbot certonly --webroot -w /var/www/app.example.com/html \
  -d app.example.com --agree-tos -m you@example.com

Then add the TLS server block yourself (section 8). The webroot method never restarts Nginx and works well with configuration management.

Try the staging environment first

Let’s Encrypt enforces rate limits (for example on repeated failed validations and on the number of certificates per domain per week). While experimenting, use the staging CA, which issues untrusted test certificates:

sudo certbot --nginx -d app.example.com --staging --dry-run

Switch to the real CA only when staging succeeds.

7. Step 5: Auto-renewal

The Certbot package installs a systemd timer that checks for renewals twice a day. Enable it and test:

sudo systemctl enable --now certbot-renew.timer
systemctl list-timers | grep -i certbot

# Simulate a renewal without changing anything
sudo certbot renew --dry-run

If the timer name differs on your build, list available units with systemctl list-unit-files | grep -i certbot.

Reload Nginx after renewal so it picks up the new certificate. The Nginx plugin usually handles this; for webroot setups add a deploy hook:

sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Hooks in that directory run only when a certificate was actually renewed.

8. Step 6: Harden the TLS configuration

Certbot’s defaults are reasonable, but a hand-written block gives you control. Example for a static site:

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

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name app.example.com;

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

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;

    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;

    root /var/www/app.example.com/html;
    index index.html;
}

Notes:

  • HSTS tells browsers to use HTTPS only. Start with a short max-age while testing, because a wrong setup becomes sticky. Add includeSubDomains only when every subdomain supports HTTPS.
  • OCSP stapling (ssl_stapling on) used to be a standard recommendation. Let’s Encrypt has retired its OCSP service in favor of CRLs, so stapling gains you nothing with its certificates; leave it off and check the current Let’s Encrypt announcements.
  • Use ssl_protocols TLSv1.2 TLSv1.3 and let Nginx’s defaults choose ciphers unless a compliance policy requires otherwise.

Apply:

sudo nginx -t && sudo systemctl reload nginx

9. Certificate lifetimes are getting shorter

Let’s Encrypt announced a phased reduction of certificate validity from 90 days to 45 days. The schedule published at the time of writing:

DateChange
Opt-in (2026)Short-lived and 45-day profiles available to those who ask for them
10 Feb 2027Default “classic” profile drops from 90 to 64 days
16 Feb 2028Default drops to 45 days

Also announced: the TLS client authentication option is being removed from default certificates, which matters only if you use Let’s Encrypt certificates for mutual TLS. And the “Generation Y” root and intermediate hierarchy is being rolled out.

What to do now:

  1. Automate renewal and monitor it. Shorter lifetimes make manual renewal impractical.
  2. Do not hard-code certificate expiry assumptions anywhere.
  3. Alert when a certificate has fewer than, say, 14 days left, which will become a large share of its life. See Prometheus and Grafana on Rocky Linux 10 for a blackbox exporter approach.
  4. Recent Certbot releases can request a specific ACME profile; check certbot --help all for --preferred-profile on your version.

Always confirm the current dates at letsencrypt.org, since schedules can change.

10. Reverse proxy to an application

Many apps (n8n, Grafana, Gitea, Node.js services) listen on a local port, and Nginx terminates TLS in front of them.

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name n8n.example.com;

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

    client_max_body_size 50m;

    location / {
        proxy_pass http://127.0.0.1:5678;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300s;
    }
}

On Rocky Linux the proxy will return 502 until you allow it in SELinux. By default the web server domain may not connect to arbitrary network ports:

sudo setsebool -P httpd_can_network_connect 1

A narrower alternative is labeling only the upstream port as an HTTP port (semanage port -a -t http_port_t -p tcp 5678). The reasoning is in the SELinux troubleshooting guide. Keep the application bound to 127.0.0.1 so it is reachable only through Nginx. A complete example with a queue-mode backend is in n8n Queue Mode; for Kubernetes edge routing see Gateway API vs Ingress.

11. Wildcard certificates with DNS-01

A wildcard certificate (*.example.com) covers all subdomains, but Let’s Encrypt only issues it after a DNS-01 challenge. Certbot has plugins for common DNS providers; Cloudflare is a typical example.

sudo dnf install -y python3-certbot-dns-cloudflare

sudo mkdir -p /root/.secrets
sudo tee /root/.secrets/cloudflare.ini > /dev/null <<'EOF'
dns_cloudflare_api_token = PASTE_A_SCOPED_API_TOKEN
EOF
sudo chmod 600 /root/.secrets/cloudflare.ini

sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
  -d example.com -d '*.example.com' \
  --agree-tos -m you@example.com

Create the API token with only the permission to edit DNS records for that zone. If your DNS provider has no plugin, you can use a manual hook script or the acme-dns approach; avoid manual challenges for anything that must auto-renew. Remember that a wildcard does not cover the bare domain, so list both names, as above.

12. Testing your setup

# Syntax and certificate details
sudo nginx -t
sudo certbot certificates

# What the server presents
openssl s_client -connect app.example.com:443 -servername app.example.com </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates

# Headers and redirect
curl -I http://app.example.com
curl -I https://app.example.com

# Renewal logic
sudo certbot renew --dry-run

An external scanner such as SSL Labs gives a detailed grade, including protocol and cipher support.

13. Troubleshooting

SymptomLikely causeFix
Challenge failed ... Connection refused or timeoutPort 80 blocked by firewalld or a cloud firewallOpen http; check provider security groups; test from outside
Challenge failed ... 404Nginx not serving /.well-known/acme-challenge/ from the right rootUse the correct -w webroot, or the --nginx plugin; check server_name
DNS problem: NXDOMAIN or wrong IPDNS record missing or pointing elsewhereFix A/AAAA records; wait for propagation; verify with dig
Fails only for IPv6AAAA record set but IPv6 not reachableFix IPv6 routing and firewall, or remove the AAAA record
too many failed authorizations / rate limitRepeated failuresWait, fix the cause, and use --staging or --dry-run
nginx: [emerg] cannot load certificateWrong path or permissionsUse the live/ symlink path; check sudo ls -l /etc/letsencrypt/live/
502 Bad Gateway on a proxied appSELinux blocks Nginx from connectingsetsebool -P httpd_can_network_connect 1 (or label the port); confirm the app is listening
403 Forbidden on static filesWrong SELinux label or file permissionsrestorecon -Rv /var/www; check ls -Z; ensure the nginx user can traverse the path
Certificate renews but site shows the old oneNginx not reloadedAdd the deploy hook; systemctl reload nginx
certbot renew --dry-run failsServer block changed, port 80 closed, or plugin errorRead /var/log/letsencrypt/letsencrypt.log
Mixed content warningsPage loads HTTP resourcesChange links to HTTPS or use protocol-relative paths
Browser stuck on HTTPS after a mistakeHSTS cachedKeep HSTS max-age short until tested
Wildcard failsUsed HTTP-01Wildcards require DNS-01
DNS provider orange-cloud proxy interferes (Cloudflare)Proxy hides the origin during HTTP validationUse DNS-01, or set records to DNS-only during issuance
Certificate mismatch for a new subdomainSubdomain not in the certificateExpand with certbot --expand -d ... or request a new one

Useful logs: /var/log/nginx/error.log, /var/log/letsencrypt/letsencrypt.log, and sudo ausearch -m AVC -ts recent for SELinux.

14. FAQ

Is Let’s Encrypt really free?
Yes. Certificates are issued at no cost by a non-profit CA, and automation through Certbot is free too.

How often do I need to renew?
Certbot renews automatically when a certificate is within roughly a third of its lifetime from expiry. With today’s 90-day certificates that means about every 60 days, and lifetimes will shorten over the coming years.

Do I need Certbot, or can I use another ACME client?
Any ACME client works. Alternatives such as acme.sh and Caddy’s built-in ACME exist. Certbot is the easiest on the RHEL family because it is packaged in EPEL.

Can I get a certificate for an internal server with no public access?
Yes, using DNS-01 validation, which only needs the ability to create DNS records. The hostname must be a real public domain you control.

Should I use the Nginx plugin or webroot?
The plugin is the fastest way to start. Webroot or certonly fits better when configuration is managed by Ansible or you want hand-written server blocks.

Does this work on AlmaLinux and RHEL?
Yes. Package names and SELinux behavior are the same across the RHEL family; on RHEL you must enable the EPEL source explicitly.

How do I monitor expiry?
Alert when a certificate has too few days left using a blackbox probe in Prometheus, or a simple check script. Do not rely only on the email notifications.

(Visited 1 times, 1 visits today)

You may also like