NginxSecurityIntermediateLinuxSystem AdminWeb Hosting

Nginx as a Reverse Proxy with Let's Encrypt SSL

Jerry RajaonarivoUpdated Sep 28, 20267 min read

This sets up an Nginx reverse proxy that correctly forwards the original request to the backend, plus Let's Encrypt TLS with auto-renewal that's actually verified to work.

The reverse proxy server block

The proxy's job is to terminate the connection from the client, open a second one to your backend, and tell it the truth about the first.

nginx
upstream app_backend {
    server 127.0.0.1:3000;
    keepalive 32;
}
 
server {
    listen 80;
    server_name app.example.com;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
 
        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_connect_timeout 5s;
        proxy_read_timeout 60s;
    }
}

X-Forwarded-For gives the backend the real client IP chain. Use $proxy_add_x_forwarded_for, not $remote_addr, so it appends rather than clobbers when there's already a proxy upstream of this one. X-Forwarded-Proto is what most frameworks check to decide whether the original request was HTTPS. Without it, an app behind a TLS-terminating proxy thinks it's always plain HTTP, and any "redirect to https" middleware will loop. Host matters too: plenty of backends route or validate based on it directly.

If you drop proxy_set_header Host $host and the backend uses the default, Django's ALLOWED_HOSTS, Rails' host authorization, and Next.js' absolute URL generation will all break in ways that look like application bugs.

Getting a certificate with certbot

I use the certbot Nginx plugin because it edits your existing server blocks in place (adding the listen 443 ssl directive, the certificate paths, and the redirect) instead of a separate config to reconcile by hand.

text
sudo apt install certbot python3-certbot-nginx
 
sudo certbot --nginx -d app.example.com

Every -d name must be one the server_name serves and whose DNS points at this host. If you also want www.app.example.com, add it to server_name first and pass a second -d. Certbot asks for an account email (used for account recovery and important notices), then validates ownership over HTTP on port 80 and obtains the certificate. Don't count on that email to warn you about expiry. Let's Encrypt stopped sending expiration reminder emails in 2025, so monitoring expiry is up to you (more on that below). Afterward, run nginx -t and diff the config so you know exactly what changed.

HTTP to HTTPS redirect and HSTS

Certbot's redirect option gets you most of the way, but I always add HSTS explicitly rather than relying on defaults, it's the header that stops a downgrade attack from getting a foothold.

Check your Nginx version first, because the HTTP/2 syntax depends on it:

text
nginx -v
# nginx version: nginx/1.24.0   <- Ubuntu 24.04 apt; Debian 12 apt ships 1.22

The standalone http2 on; directive only exists from Nginx 1.25.1. On anything older, nginx -t rejects it, so use the listen 443 ssl http2; form shown in the comment instead (newer versions still accept it, with a deprecation warning). If you'd rather run a current release, the nginx.org repositories package the mainline and stable branches for both distributions.

nginx
server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}
 
server {
    listen 443 ssl;
    http2 on;                  # Nginx >= 1.25.1
    # listen 443 ssl http2;    # Nginx < 1.25.1: use this line instead of the two above
    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;
 
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
 
    location / {
        proxy_pass http://app_backend;
        # Required for the upstream "keepalive 32" to actually reuse connections
        proxy_http_version 1.1;
        proxy_set_header Connection "";
 
        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;
    }
}

includeSubDomains applies to every subdomain. Once a browser has seen the header, it refuses plain HTTP to *.app.example.com for the whole max-age (two years here). If any subdomain still serves HTTP only, drop includeSubDomains, or start with a short max-age such as 300 and raise it once you've confirmed everything works over HTTPS.

Don't add preload to the HSTS header until every subdomain you own can serve HTTPS indefinitely. Getting into the browser preload list is easy, getting removed takes months, and in the meantime any subdomain that can't do TLS becomes unreachable.

Auto-renewal that you actually verify

Let's Encrypt certificates last 90 days. Certbot's systemd timer renews anything within 30 days of expiry, twice a day, until an OS upgrade changes a path, a firewall rule blocks port 80 validation, or a config edit breaks the ACME challenge location block.

text
systemctl list-timers | grep certbot
 
sudo systemctl status certbot.timer

The unit certbot ships looks roughly like this:

ini
# /lib/systemd/system/certbot.timer
[Unit]
Description=Run certbot twice daily
 
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=43200
Persistent=true
 
[Install]
WantedBy=timers.target

The one command I run on every server, immediately, before I trust the setup:

text
sudo certbot renew --dry-run --run-deploy-hooks

--dry-run exercises the renewal path (ACME validation and certificate issuance) against Let's Encrypt's staging environment, without touching your real certificate or its rate limits. By default it skips deploy hooks, so a broken --deploy-hook (copying certs somewhere, reloading another service) would go unnoticed. Add --run-deploy-hooks to run them after a successful dry run. That flag needs certbot 2.7 or later. Ubuntu 24.04's package has it, Debian 12's older apt package doesn't, and the certbot snap is current. If the dry run fails, your real renewal will fail too, just later. I run it once at setup time and again after any change to the server's firewall, Nginx config or DNS.

Wire certificate expiry into your monitoring instead of relying on the timer alone, especially now that Let's Encrypt no longer emails expiry warnings. Set up an external probe that checks the certificate's expiry date on the live endpoint (Blackbox exporter's probe_ssl_earliest_cert_expiry, or your uptime monitor's TLS check) and alerts at around 14 days left. That way someone gets paged before a customer runs into an expired certificate.

Basic hardening: protocols, ciphers, and security headers

Once TLS termination and renewal are solid, the last pass is restricting what the proxy accepts and adding a few security headers.

nginx
# TLS settings: fine in the http {} block (shared by all servers) or in the 443 server block
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
 
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
 
# Security headers: put these in the 443 server block, next to the HSTS add_header
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;

Where the add_header lines go matters. Nginx only inherits add_header from an outer level if the inner level defines none of its own. If you put these three in http {}, the 443 server block (which has its own HSTS add_header) drops all of them without any warning. Keep every add_header in the same server block, and remember that a location with its own add_header resets them again. Nginx 1.29.3 added add_header_inherit merge; to change this behavior, but most distribution packages are far older, so don't rely on it yet.

Dropping TLS 1.0 and 1.1 is safe for a general audience in 2026, the clients still stuck on them are a security liability regardless. ssl_prefer_server_ciphers off lets modern clients pick their own preferred cipher, current best practice now that client-side cipher choices are generally sound. That used to be the opposite advice, a reminder to re-check hardening guides every couple of years instead of set-and-forget.

Run the result through Qualys' SSL Labs test or testssl.sh after any change to this block: it catches directives that don't do what you think, or an old config fragment elsewhere in the file silently overriding them.

These are the same eight or nine directives worth writing into almost every proxy config. Having them is the difference between a TLS setup you forget exists and one that comes back a year later as an incident report.

GO FURTHER

Want to actually run this in production?

This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.

This tutorial

  • Core architecture & key concepts
  • Illustrative code snippets
  • The reasoning behind each decision

1:1 mentoring

  • Working sessions on your own environment
  • Direct answers to the edge cases you're hitting
  • Feedback on your actual implementation
  • Ongoing support as you build it out
Get in TouchReply within 1 business day.

Related Tutorials