Skip to content

guide

nginx as a TLS reverse proxy, with Certbot doing the renewals

Your app listens on a local port. nginx terminates TLS on 443, forwards requests, and serves static files without waking the app. Certbot keeps the certificate valid without you ever thinking about it again.

Prerequisites

  • A domain with an A record pointing at your instance's IPv4 address. Check with dig +short app.example.com.
  • Ports 80 and 443 open in the portal firewall and in the guest firewall.
  • An application listening on 127.0.0.1:3000 (Node, Go, Python, anything).

Install nginx and Certbot

sudo apt install -y nginx certbot
# AlmaLinux / Rocky:
sudo dnf install -y epel-release && sudo dnf install -y nginx certbot
sudo systemctl enable --now nginx

Ubuntu and Debian packages include a certbot.timer systemd unit. Confirm it with systemctl list-timers | grep certbot. On RHEL-family systems, enable it yourself: sudo systemctl enable --now certbot-renew.timer.

Step 1: an HTTP-only block for validation

sudo mkdir -p /var/www/acme
sudo tee /etc/nginx/conf.d/app.conf >/dev/null <<'EOF'
server {
    listen 80;
    server_name app.example.com;

    location /.well-known/acme-challenge/ { root /var/www/acme; }
    location / { return 301 https://$host$request_uri; }
}
EOF
sudo nginx -t && sudo systemctl reload nginx

On Debian and Ubuntu, remove the default site so it does not answer for your name: sudo rm /etc/nginx/sites-enabled/default.

Step 2: issue the certificate

sudo certbot certonly --webroot -w /var/www/acme \
  -d app.example.com --email [email protected] --agree-tos --no-eff-email

Success ends with a path under /etc/letsencrypt/live/app.example.com/. If it fails with a timeout, port 80 is blocked somewhere. If it fails with a 404, the root path in nginx does not match -w.

Step 3: the TLS server block

Append this to the same file:

upstream app_backend {
    server 127.0.0.1:3000;
    keepalive 32;
}

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;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:TLS:10m;
    ssl_session_timeout 1d;

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

    client_max_body_size 20m;

    location /static/ {
        alias /srv/app/static/;
        expires 7d;
        access_log off;
    }

    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_read_timeout 60s;
    }
}

The http2 on; directive needs nginx 1.25.1 or newer. Debian 12 ships 1.22, so on that release write listen 443 ssl http2; instead and drop the separate line. Check with nginx -v.

sudo nginx -t && sudo systemctl reload nginx
curl -sI https://app.example.com | head -5

You want HTTP/2 200 (or whatever status your app returns) and the HSTS header in the output.

Step 4: reload nginx after renewal

Certbot replaces the files, but nginx keeps the old certificate in memory until it reloads. Add a deploy hook so it happens automatically:

sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null <<'EOF'
#!/bin/sh
nginx -t -q && systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo certbot renew --dry-run

The dry run talks to the staging environment and should finish with "Congratulations, all simulated renewals succeeded". Deploy hooks do not fire on a dry run, so test the script on its own once: sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh.

Telling your app it is behind a proxy

The app now sees every request coming from 127.0.0.1 over plain HTTP. Tell your framework to trust the forwarded headers from loopback only: app.set('trust proxy', 'loopback') in Express, ProxyFix in Flask, SECURE_PROXY_SSL_HEADER in Django. Without this, redirects point at http:// and rate limiters see one client.

Bind the app to 127.0.0.1, not 0.0.0.0. Otherwise anyone can reach port 3000 directly and skip TLS and every header nginx adds.

Pitfalls

  • Using cert.pem instead of fullchain.pem. Browsers may cope; curl, mobile apps and API clients will reject the missing intermediate.
  • Proxying WebSockets without proxy_set_header Upgrade $http_upgrade; and Connection "upgrade" on that location. Use a separate location for the socket path.
  • Large uploads failing with 413: raise client_max_body_size.
  • Renewal silently failing because you later added a redirect that also catches /.well-known/. Keep the ACME location first in the port 80 block.

Questions

Why use the webroot method instead of certbot --nginx?

The nginx plugin rewrites your server blocks, which is convenient once and confusing later when the config in git no longer matches the config on disk. Webroot only drops a file in a directory, so your config stays exactly as you wrote it.

How often does Certbot renew?

The packaged timer runs twice a day and renews any certificate within 30 days of expiry. Let's Encrypt certificates last 90 days, so in practice each one is replaced about every 60 days.

Can I get a certificate before DNS points at the instance?

Not with HTTP validation; Let's Encrypt must reach the server by name. Either switch DNS first and accept a minute of certificate warnings, or use the DNS-01 challenge with your DNS provider's Certbot plugin.

Should I turn on HSTS straight away?

Start with a short max-age such as 300 seconds, confirm every subdomain serves HTTPS correctly, then raise it. A long HSTS value with a broken subdomain is painful to undo because browsers remember it.