Nginx as a Reverse Proxy with Let's Encrypt SSL
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.
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.
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com -d www.app.example.comCertbot will ask for an email first (used for expiry notices; make it an inbox someone actually monitors). Then it validates ownership over HTTP on port 80 and obtains the certificate. Run nginx -t and a diff of the config afterward, 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.
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
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;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
location / {
proxy_pass http://app_backend;
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;
}
}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.
systemctl list-timers | grep certbot
sudo systemctl status certbot.timerThe unit certbot ships looks roughly like this:
# /lib/systemd/system/certbot.timer
[Unit]
Description=Run certbot twice daily
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=43200
Persistent=true
[Install]
WantedBy=timers.targetThe one command I run on every server, immediately, before I trust the setup:
sudo certbot renew --dry-run--dry-run exercises the full renewal path (validation, certificate issuance, the Nginx reload hook) against Let's Encrypt's staging environment, without touching your real certificate or its rate limits. If this 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 the renewal into your monitoring instead of trusting cron alone. A certbot renew exit code check, or a weekly TLS expiry probe against the live endpoint, turns "nobody noticed for four days" into "someone got paged before a customer did."
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.
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;
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;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.
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