How to Set Up Nginx as a Reverse Proxy With Let's Encrypt (Ubuntu 24.04)

Oct 31, 2026 · 14 min · Ajay Kumar

This guide is for anyone putting a web app (Node, Python, Go, a container) behind nginx on an Ubuntu 24.04 server and wanting HTTPS that renews itself. At the end you will have nginx terminating TLS with a Let's Encrypt certificate, redirecting HTTP to HTTPS, passing the right proxy headers, carrying WebSockets, serving HTTP/2 with an HSTS header, and a renewal timer you have actually tested. Versions as of 31 October 2026: Ubuntu's nginx 1.24.0 package (with nginx.org's 1.30.6 stable as the alternative) and certbot 5.9.0 from the snap. I checked the final config with nginx -t and a local run on nginx 1.27.5, with self-signed certificates standing in for Let's Encrypt. The nginx warnings, headers and WebSocket handshake shown in Steps 5 and 6 come from that run; package and certbot outputs are what those tools print on a real server, not captured by me. You need an Ubuntu 24.04 server with sudo, a DNS record for your hostname (I use app.example.com) pointing at it, ports 80 and 443 reachable from the internet, and an app listening on 127.0.0.1:3000. If you are still deciding between nginx, Caddy and Traefik, read Nginx vs Caddy vs Traefik in 2026 first.

Request path Browser http:// or https:// nginx :80 301 to https:// serves ACME HTTP-01 nginx :443 TLS, HTTP/2, HSTS sets proxy headers App 127.0.0.1:3000 plain HTTP/1.1 WebSocket upgrade Renewal path Renewal timer snap.certbot.renew.timer runs certbot renew certbot renew renews with 1/3 left or earlier if ARI says Let's Encrypt fetches the token via :80 then issues the cert Deploy /etc/letsencrypt/live nginx -s reload HTTP-01 check reload Default Let's Encrypt lifetime: 90 days now, 64 from 10 Feb 2027, 45 in 2028. OCSP ended Aug 2025. Certbot's nginx installer reloads nginx after each renewal, so no deploy hook is needed.
What this guide builds: the request path through nginx to the app, and the renewal loop that keeps the certificate fresh. Lifetimes and dates are from Let's Encrypt's announcements; renewal timing is from the certbot changelog.

Step 1: Install Nginx on Ubuntu 24.04 (and pick a package source)

The simplest path is Ubuntu's own package:

sudo apt update
sudo apt install -y nginx

nginx -v
# nginx version: nginx/1.24.0 (Ubuntu)

apt policy nginx | head -3
# Installed: 1.24.0-2ubuntu7.<n>   (7.19 was the build in USN-8955-1, 26 Oct 2026)

systemctl is-active nginx
# active

Ubuntu 24.04 ships nginx 1.24.0 and backports security fixes into it rather than moving to new upstream versions; USN-8563-5 on 14 September 2026, for example, shipped 1.24.0-2ubuntu7.18 with three fixes. The trade-off is speed. nginx.org released fixes for five new CVEs on 14 October; Ubuntu's CVE tracker still showed them as "Needs evaluation" for 24.04 ten days later, and the backport (USN-8955-1, 1.24.0-2ubuntu7.19) landed on 26 October, twelve days after upstream.

If you want same-day fixes and newer features, use nginx.org's stable repository instead. Decide before you install, because the two packages lay files out differently. These commands are from the nginx.org Linux packages page:

# Optional: nginx.org stable (1.30.x) instead of Ubuntu's 1.24.0
sudo apt install -y curl gnupg2 ca-certificates lsb-release ubuntu-keyring
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
    | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
gpg --dry-run --quiet --no-keyring --import --import-options import-show \
    /usr/share/keyrings/nginx-archive-keyring.gpg
# expect the fingerprint 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62

echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
https://nginx.org/packages/ubuntu `lsb_release -cs` nginx" \
    | sudo tee /etc/apt/sources.list.d/nginx.list
echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \
    | sudo tee /etc/apt/preferences.d/99nginx

sudo apt update && sudo apt install -y nginx
sudo systemctl enable --now nginx
nginx -v
# nginx version: nginx/1.30.6

What changes if you take the nginx.org route:

  • Site configs go in /etc/nginx/conf.d/*.conf; there is no sites-available or sites-enabled. Replace conf.d/default.conf with your own file.
  • HTTP/2 is enabled with the http2 on; directive, which arrived in 1.25.1. On Ubuntu's 1.24 you must use listen 443 ssl http2; instead.
  • Since 1.29.7, proxy_http_version defaults to 1.1 with upstream keepalive, per the proxy module docs. On 1.24 the default is 1.0, which breaks WebSockets unless you set it.
  • The packages include the HTTP/3 module and optional dynamic modules such as nginx-module-acme.

The rest of this guide uses Ubuntu's package and notes the nginx.org differences inline.

Step 2: Open ports 80 and 443 and check DNS

Let's Encrypt's HTTP-01 challenge needs port 80 open even if you only ever want HTTPS.

sudo ufw allow OpenSSH      # first, so enabling ufw cannot cut off your session
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

dig +short A app.example.com      # must print this server's public IPv4 address
dig +short AAAA app.example.com   # if this prints anything, it must reach this server too

If your provider has its own firewall or security group, open 80 and 443 there as well; ufw cannot fix a cloud-level block. A stale AAAA record pointing somewhere else is a quiet cause of failed validation, because Let's Encrypt will try the IPv6 address you publish.

Step 3: Write the reverse proxy server block

First, a small file that the WebSocket headers depend on. It must live in the http context, and Ubuntu's nginx.conf includes conf.d/*.conf there, so put it in its own file and it is loaded once:

# /etc/nginx/conf.d/websocket-upgrade.conf
# Send "Connection: upgrade" upstream only when the client asked for an upgrade.
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;   # use '' ""; instead if you add upstream keepalive
}

Now the site itself, plain HTTP for the moment. Certbot will add the TLS parts in Step 4:

# /etc/nginx/sites-available/app.example.com
upstream app_backend {
    server 127.0.0.1:3000;
}

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;          # certbot matches on this

    client_max_body_size 25m;             # default 1m; larger uploads get a 413

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;           # the default only since nginx 1.29.7

        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_set_header X-Forwarded-Host  $host;

        # WebSockets: Upgrade and Connection are hop-by-hop, so pass them explicitly
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_read_timeout 300s;          # default 60s closes quiet WebSockets
    }
}

Enable it, remove the default site, test and reload:

sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default    # removes the symlink only
sudo nginx -t
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful
sudo systemctl reload nginx

curl -sI http://app.example.com | head -1   # should be your app's status line, not a 502

Why each header is there. By default nginx sends Host: $proxy_host (here, app_backend) and Connection: close, according to the proxy module docs, so the app would otherwise see the wrong hostname. X-Forwarded-Proto lets the app build https:// links and set secure cookies once TLS is on. X-Forwarded-For appends the client address to any existing chain. If a load balancer or CDN sits in front of nginx, configure the realip module (set_real_ip_from) so $remote_addr is the client rather than the balancer.

One inheritance trap is worth knowing now. proxy_set_header directives are inherited from the enclosing level "if and only if" the current level defines none, so a single proxy_set_header in a nested location silently drops every header above. The same rule applies to add_header, which matters for HSTS in Step 5.

Step 4: Get a Let's Encrypt certificate with certbot

Certbot's own instructions for Ubuntu use the snap, and it is the version I would use: snapcraft shows 5.9.0 on latest/stable. Ubuntu 24.04's archive has certbot 2.9.0, which predates ACME profiles (added in 4.0.0) and ACME Renewal Information (4.1.0), per the certbot changelog. It still works with today's certificates, but it renews on a fixed 30-days-before-expiry rule and cannot react to ARI.

# Remove any apt copy first so only one certbot and one timer exist
sudo apt remove -y certbot python3-certbot-nginx
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
certbot --version
# certbot 5.9.0

# Rehearse against the staging CA: catches DNS and firewall problems
# without spending production rate limits (--dry-run works with certonly and renew)
sudo certbot certonly --nginx --dry-run -d app.example.com

# The real certificate, installed into the server block, with an HTTP->HTTPS redirect
sudo certbot --nginx -d app.example.com --redirect

Typical output on success (abridged, not from my local run):

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/app.example.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/app.example.com/privkey.pem
This certificate expires on 2027-01-29.
Deploying certificate
Successfully deployed certificate for app.example.com to /etc/nginx/sites-enabled/app.example.com
Congratulations! You have successfully enabled HTTPS on https://app.example.com

Certbot asks for an email address and the terms of service on first run. Since certbot 3.3.0 you can press Enter to register without an email, and Let's Encrypt stopped sending expiry emails on 4 June 2025 anyway, so the address is not your monitoring.

If you want to rehearse the shorter lifetimes now, add --preferred-profile tlsserver to the command. That profile has issued 45-day certificates since 13 May 2026, per Let's Encrypt's 90-to-45 announcement, and certbot stores the choice in the renewal config so renewals keep it.

Step 5: Enable HTTP/2 and HSTS

Certbot added a listen 443 ssl block, the certificate paths, its TLS settings file and a redirect server. Edit the file to add HTTP/2 and HSTS. This is the complete result I tested:

# /etc/nginx/sites-available/app.example.com (final)
upstream app_backend {
    server 127.0.0.1:3000;
}

server {
    server_name app.example.com;

    listen 443 ssl http2;          # Ubuntu's 1.24 syntax
    listen [::]:443 ssl http2;     # on 1.25.1+: "listen 443 ssl;" plus "http2 on;"

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;   # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf;                         # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;                           # managed by Certbot

    # HSTS: start at 5 minutes, raise once you are sure HTTPS works everywhere
    add_header Strict-Transport-Security "max-age=300" always;

    client_max_body_size 25m;

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;

        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_set_header X-Forwarded-Host  $host;

        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_read_timeout 300s;
    }
}

server {
    if ($host = app.example.com) {
        return 301 https://$host$request_uri;
    } # managed by Certbot

    listen 80;
    listen [::]:80;
    server_name app.example.com;
    return 404; # managed by Certbot
}
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://app.example.com | grep -i -E '^HTTP|strict-transport'
# HTTP/2 200
# strict-transport-security: max-age=300

Certbot's options-ssl-nginx.conf is based on Mozilla's guidelines and allows only TLS 1.2 and 1.3; leave it alone, because certbot stops updating a hand-edited copy. On nginx 1.25.1 or newer, the listen ... http2 form still works but nginx -t prints this warning (from my test, paths adjusted):

nginx: [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead in /etc/nginx/conf.d/app.example.com.conf:9

For HSTS, raise max-age in steps (300, then 86400, then a year or two) once every page works over HTTPS. The always flag makes nginx send the header on error responses too. Add includeSubDomains only when every subdomain serves HTTPS. Preloading needs a max-age of at least 31536000, includeSubDomains and preload, and hstspreload.org itself warns that removal is slow and that preloading is not recommended for most sites. Remember the inheritance rule from Step 3: a location with its own add_header loses the server-level HSTS header, unless you repeat it there or use add_header_inherit merge; on nginx 1.29.3 or newer, per the headers module docs.

Do not add ssl_stapling on. Let's Encrypt removed OCSP URLs from its certificates on 7 May 2025 and turned the responders off on 6 August 2025, so there is nothing to staple. With a CRL-only test certificate, nginx -t printed (paths adjusted):

nginx: [warn] "ssl_stapling" ignored, no OCSP responder URL in the certificate "/etc/letsencrypt/live/app.example.com/fullchain.pem"

Step 6: Check WebSockets through the proxy

A WebSocket starts as an HTTP/1.1 request with Upgrade: websocket, and nginx only tunnels it if it forwards those headers, as the WebSocket proxying docs explain. You can test the handshake with curl; force HTTP/1.1, because over HTTPS curl would otherwise negotiate HTTP/2, where this handshake does not exist. Use a path your app actually serves WebSockets on:

curl -s --http1.1 -o /dev/null -D - --max-time 3 https://app.example.com/ws \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=="
# HTTP/1.1 101 Switching Protocols
# Connection: upgrade
# Upgrade: websocket
# Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

A 101 means the tunnel is up. Anything else (a 400, a 200 with your HTML, a 502) means the headers are not reaching the app or the app is not serving WebSockets on that path. Connections that open and then die after about a minute are the 60-second proxy_read_timeout default; raise it or have the app send ping frames.

Step 7: Confirm automatic renewal

The snap installs a systemd timer. Check it, then rehearse a renewal:

systemctl list-timers 'snap.certbot*'
# shows snap.certbot.renew.timer with its next and last run
# (an apt install uses certbot.timer instead)

sudo certbot renew --dry-run
# Congratulations, all simulated renewals succeeded:
#   /etc/letsencrypt/live/app.example.com/fullchain.pem (success)

sudo certbot certificates            # names, expiry dates and paths
journalctl -u snap.certbot.renew.service --since "-7 days" --no-pager | tail -20

How renewal timing works now: since certbot 4.0.0 a certificate renews when a third of its lifetime is left (half, for lifetimes under ten days), and since 4.1.0 certbot renew also checks ARI and may renew earlier, typically around two thirds of the way through for Let's Encrypt. That matters because the default lifetime is shrinking: Let's Encrypt moves to 64-day certificates on 10 February 2027 (staging has issued them since 14 October 2026) and to 45 days in 2028. Its rate limits page also exempts ARI renewals from all rate limits. A fixed "renew 30 days before expiry" cron job still technically works with 64-day certificates, but anything hardcoded to 60 or 80 days does not, and the Let's Encrypt post tells you to search runbooks for exactly those numbers.

You do not need a deploy hook. The nginx installer reloads nginx itself after it installs a renewed certificate. You would only need --deploy-hook "systemctl reload nginx" if you had used certonly --webroot or --standalone.

Finally, watch the certificate from outside, because a silent renewal failure is how most certificate outages start:

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

Put that check, or a blackbox probe, into your existing alerting with a threshold well above the renewal point.

Troubleshooting

Start with sudo nginx -t, then sudo tail -f /var/log/nginx/error.log while you reproduce the problem, and /var/log/letsencrypt/letsencrypt.log for certbot. For a deeper treatment of upstream errors, see How to Fix 502 Bad Gateway and 504 Gateway Timeout Errors.

Symptom Likely cause Fix
502 Bad Gateway; error log shows connect() failed (111: Connection refused) while connecting to upstream App not running, wrong port, or listening on a different address (for example only ::1) ss -ltnp 'sport = :3000'; make the upstream address match what the app binds to
502; log shows connect() to unix:/run/app.sock failed (13: Permission denied) nginx workers run as www-data and cannot open the socket Make the socket group www-data with mode 660, or add www-data to the app's group
504 Gateway Time-out; upstream timed out (110: Connection timed out) Response took longer than proxy_read_timeout (60s default) Fix the slow endpoint, or raise the timeout for that location only
Certbot: Could not automatically find a matching server block for app.example.com server_name missing or different, or the site is not enabled Set server_name app.example.com;, symlink into sites-enabled, nginx -t, reload
Certbot: Timeout during connect (likely firewall problem) Port 80 blocked by ufw or a cloud firewall, or DNS points elsewhere Open 80 in both firewalls; compare dig +short with the server's address
Let's Encrypt error containing too many certificates and retry after Repeated issuance for the same names (5 per 7 days) Practise with --dry-run; wait for the refill; ARI renewals are exempt
nginx: [emerg] unknown directive "http2" http2 on; on Ubuntu's 1.24, which predates the directive Use listen 443 ssl http2;, or move to nginx.org's 1.30 packages
[warn] "ssl_stapling" ignored, no OCSP responder URL in the certificate Let's Encrypt certificates have had no OCSP URL since May 2025 Delete the ssl_stapling lines
WebSocket fails to connect, or drops after about 60 seconds Missing Upgrade/Connection headers, HTTP/1.0 upstream, or the read timeout Add the map and headers from Step 3, proxy_http_version 1.1, raise proxy_read_timeout
HSTS or other headers missing on some paths A location with its own add_header stops inheriting server-level headers Repeat the header in that location, or add_header_inherit merge; on 1.29.3+

FAQ

Do I still need OCSP stapling with Let's Encrypt?

No. Let's Encrypt removed OCSP URLs from new certificates on 7 May 2025 and shut down its OCSP responders on 6 August 2025, relying on CRLs instead. nginx just logs a warning and skips stapling, so remove the directives to keep nginx -t clean.

Should I install certbot with snap or apt on Ubuntu 24.04?

Use the snap unless policy forbids it. It tracks current certbot (5.9.0 today), which supports ACME profiles and ARI-driven renewal; the apt package is 2.9.0 and has neither. Do not run both, or you get two renewal timers.

How often do Let's Encrypt certificates renew now?

Certbot 4.0 and later renews when about a third of the lifetime remains, or earlier when the CA's ARI response says so. With today's 90-day certificates that is roughly every 60 days; with 64-day certificates from February 2027 it will be more often, which is fine as long as the timer runs and nobody hardcoded a number.

Can Nginx get Let's Encrypt certificates without certbot?

Yes, with the official ngx_http_acme_module, a preview since August 2025 that is available as the nginx-module-acme package from nginx.org. It handles HTTP-01 and TLS-ALPN-01 inside nginx. Dynamic modules must match the nginx build that loads them, so with Ubuntu's own 1.24 package certbot is the simpler path.

When something breaks on one of these boxes, I check in the same order every time: nginx -t, the last twenty lines of the error log, ss -ltnp to confirm the app is listening where nginx thinks it is, and curl -H "Host: app.example.com" http://127.0.0.1:3000/ to take nginx out of the picture. In production I pin where nginx comes from, run certbot renew --dry-run after any change to DNS, firewalls or server blocks, and alert on certificate expiry from outside the machine rather than trusting the timer. None of that is new, which is the point: the certificates got shorter and the patch cadence got faster, and the boring checks are what keep up. The official references are the nginx proxy module, WebSocket proxying and Linux packages pages, certbot's user guide, and Let's Encrypt's rate limits documentation. For the bigger picture on certificate lifetimes, read TLS Certificate Lifetimes in 2026.

Related: TLS Certificate Lifetimes in 2026: 200 Days Now, 47 Days by 2029, How to Fix 502 Bad Gateway and 504 Gateway Timeout Errors (2026) and How to Set Up cert-manager with Let's Encrypt and Fix Errors (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related