
Your app runs on a VPS on port 3000, and you want it at https://app.example.com with a free certificate that renews itself. Nginx takes the HTTPS connection on port 443, Certbot gets and renews the Let's Encrypt certificate, and your app keeps listening on 127.0.0.1 without handling TLS. The commands are for Ubuntu 24.04 and 26.04 and Debian 12 and 13, checked against Nginx's and Certbot's documentation on 3 November 2026.
Quick answer: To configure an Nginx reverse proxy with SSL from Let's Encrypt, point your domain at the VPS, open ports 80 and 443, install nginx, certbot and python3-certbot-nginx, and create a plain HTTP server block with your server_name and proxy_pass http://127.0.0.1:3000. Then run sudo certbot --nginx -d app.example.com. Certbot proves the domain over port 80, saves the certificate under /etc/letsencrypt/live/, adds listen 443 ssl and the certificate lines to your block and redirects HTTP to HTTPS. A systemd timer renews it; test with sudo certbot renew --dry-run.
How to Configure Nginx as a Reverse Proxy with SSL from Let's Encrypt
The whole job is four steps around one config file. Replace app.example.com with your domain and 3000 with your app's port.
# 1. Install Nginx, Certbot and Certbot's Nginx plugin
sudo apt update
sudo apt install nginx certbot python3-certbot-nginx
# 2. Write the HTTP server block shown further down, enable it, test, reload
sudo nano /etc/nginx/sites-available/myapp
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# 3. Get the certificate; Certbot adds HTTPS and the redirect to your block
sudo certbot --nginx -d app.example.com
# 4. Test renewal
sudo certbot renew --dry-run
On the first run Certbot asks for an email address, your agreement to the ACME Subscriber Agreement and whether to share the address with the EFF; -m you@example.com --agree-tos --no-eff-email answers all three.
Certbot's install page recommends its snap package, which tracks the newest release (5.9.0 on 3 November 2026). For that route, install only nginx with apt in step 1, then on a server with snapd (Certbot's instructions first remove an apt Certbot with sudo apt-get remove certbot):
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
What each distribution ships decides two details later on:
| Ubuntu 24.04 | Ubuntu 26.04 | Debian 12 | Debian 13 | |
|---|---|---|---|---|
| nginx | 1.24.0 | 1.28.3 | 1.22.1 | 1.26.3 |
| Certbot from apt | 2.9.0 | 4.0.0 | 2.1.0 | 4.0.0 |
| HTTP/2 syntax | listen 443 ssl http2; | http2 on; | listen 443 ssl http2; | http2 on; |
| Certbot renews when | under 30 days are left | under a third of the lifetime is left | under 30 days are left | under a third of the lifetime is left |
Before You Run Certbot
Let's Encrypt can only verify the domain when three things are true:
- DNS points at this VPS. Create an A record for
app.example.comwith the VPS's IPv4 address. An AAAA record is optional, but if one exists it must point here too: Let's Encrypt always tries IPv6 first, and falls back to IPv4 only after a connection timeout, never after a wrong answer or on a redirect. Check withdig +short A app.example.comanddig +short AAAA app.example.com. - Port 80 is open. The Nginx plugin uses the HTTP-01 challenge, which Let's Encrypt only runs on port 80. If UFW is active,
sudo ufw allow 'Nginx Full'opens 80 and 443 with the profile the nginx package installs. Check your provider's firewall panel too. - Your app answers locally.
curl -I http://127.0.0.1:3000on the VPS should print a status line. Let the app listen on 127.0.0.1 only, so the internet reaches it only through Nginx.
Write the HTTP Server Block
Start with HTTP only. Nginx loads certificate files when it reads its config, so pointing at certificates Certbot has not created yet makes nginx -t fail with cannot load certificate. Certbot writes the HTTPS part.
# /etc/nginx/sites-available/myapp
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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 Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server_namemust match the domain you give Certbot. That is how Certbot finds the block to edit.proxy_http_version 1.1is the default only since nginx 1.29.7. All four distributions above ship older versions, where the default is still 1.0, so keep the line.Host,X-Forwarded-ForandX-Forwarded-Protopass on the real domain, the visitor's address and the original scheme. Without them your app sees every request as plain HTTP from 127.0.0.1.- The
map,UpgradeandConnectionlines come from nginx's WebSocket guide, so WebSockets work too. Themapsits outsideserverand is needed only once, however many sites you add. listen [::]:80is its own line because nginx's IPv6 wildcard socket accepts only IPv6 by default. With it in place, Certbot also adds an IPv6 HTTPS listener.
Enable it with the ln -s, nginx -t and reload lines from step 2 above. curl -I http://app.example.com from your own computer should then return your app's headers through Nginx.
Run Certbot
sudo certbot --nginx -d app.example.com
To put more names on the same certificate, repeat -d, for example -d example.com -d www.example.com. Every name needs a DNS record and has to appear in a server_name.
Certbot adds a temporary challenge to your Nginx config, Let's Encrypt fetches it over port 80, and Certbot saves the certificate in /etc/letsencrypt/live/app.example.com/ (fullchain.pem for ssl_certificate, privkey.pem for ssl_certificate_key). It then appends lines like these to your server block:
listen 443 ssl; # managed by Certbot
listen [::]:443 ssl ipv6only=on; # managed by Certbot
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
Redirecting is on by default for certbot --nginx, so Certbot also moves your listen 80 lines into a second server block that sends app.example.com to HTTPS with a 301 and answers anything else with a 404.
Add HTTP/2 and Security Headers
Certbot does not switch on HTTP/2. Check your version with nginx -v:
- nginx 1.25.1 and newer (Ubuntu 26.04, Debian 13): add
http2 on;inside the HTTPS server block. The oldhttp2parameter onlistenis deprecated in these versions. - Older nginx (Ubuntu 24.04, Debian 12): add
http2to Certbot's listen lines, so they readlisten 443 ssl http2;andlisten [::]:443 ssl http2 ipv6only=on;.
Then add the headers to the same HTTPS block:
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
alwaysadds the header to error pages too. Without it, nginx only adds headers to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses.Strict-Transport-Securitymakes browsers use only HTTPS for your domain for a year (31536000 seconds). AddincludeSubDomainsonly when every subdomain serves HTTPS, because the policy then covers all of them.nosniffmakes browsers keep to theContent-Typeyour app sends.X-Frame-Options DENYblocks every site, yours included, from framing your pages; useSAMEORIGINif your app frames itself.- An
add_headerinside alocationcancels all server-leveladd_headerlines for that location. nginx 1.29.3 addedadd_header_inheritto change this, but none of the four distribution versions above has it.
sudo nginx -t && sudo systemctl reload nginx
curl -I https://app.example.com
The first line of curl's output should read HTTP/2 followed by your app's status code, with the three headers below it.
Automatic Renewal
You don't need a cron job. On all four distributions the apt package installs a systemd timer, certbot.timer, that runs certbot -q renew twice a day at a random time (its /etc/cron.d/certbot line only runs on servers without systemd). The snap brings its own timer for the same command. With the Nginx plugin, Certbot reloads Nginx itself after each renewal, so no deploy hook is needed.
systemctl list-timers '*certbot*'
sudo certbot renew --dry-run
sudo certbot certificates
--dry-run renews against Let's Encrypt's staging server and saves nothing. certbot certificates shows each expiry date, worth a look now and then because Let's Encrypt stopped sending expiry reminder emails on 4 June 2025.
When a renewal happens depends on the Certbot version (see the table at the top): under 30 days left for Certbot 2.x, under a third of the lifetime for 4.0.0. Certbot 4.1.0 and newer, which includes the snap, also asks Let's Encrypt when to renew through ACME Renewal Information (ARI), which for Let's Encrypt certificates typically means renewing at about two thirds of the lifetime.
Let's Encrypt's default certificates last 90 days today; its published plan cuts that to 64 days on 10 February 2027 and 45 days on 16 February 2028, and it advises a client with ARI. All the versions above still renew in time while the timer runs; the snap is the one with ARI.
Common Certbot Errors and Fixes
| Message | What it means | Fix |
|---|---|---|
Could not automatically find a matching server block for app.example.com | No server block has that server_name | Add the name to server_name, reload, run Certbot again |
Timeout during connect (likely firewall problem) | No answer on port 80 | Open port 80 in UFW and your provider's firewall; check the AAAA record |
Connection refused | Nothing listens on port 80 at that address | Start Nginx; add listen [::]:80 if you have an AAAA record |
Invalid response from http://app.example.com/.well-known/acme-challenge/...: 404 | The request reached a server that does not have the challenge | Point the A and AAAA records at this VPS |
DNS problem: NXDOMAIN looking up A for app.example.com | No A record yet | Create it and wait until dig returns it |
nginx: [emerg] cannot load certificate | The config names certificate files that do not exist yet | Start from the HTTP-only block and let Certbot add them |
ERR_TOO_MANY_REDIRECTS behind Cloudflare | Flexible mode connects over HTTP, and Certbot's redirect sends it back to HTTPS | Set the SSL/TLS mode to Full (strict); our Cloudflare 521 guide explains the modes |
502 Bad Gateway on the HTTPS address | The certificate works, but the app does not answer on 127.0.0.1:3000 | Follow our nginx 502 guide |
Let's Encrypt allows 5 failed validations per domain per account per hour, so after a failure test your fix with sudo certbot certonly --nginx --dry-run -d app.example.com, which uses the staging server, before you try for real.
Several Apps Behind One Nginx
Each app gets its own server block, server_name and local port (shortened here, without the proxy headers):
server { listen 80; listen [::]:80; server_name app1.example.com; location / { proxy_pass http://127.0.0.1:3001; } }
server { listen 80; listen [::]:80; server_name app2.example.com; location / { proxy_pass http://127.0.0.1:3002; } }
sudo certbot --nginx -d app1.example.com -d app2.example.com gets one certificate for both names and installs it in both blocks; run Certbot once per name for separate certificates. Our multiple Node.js apps guide adds PM2, per-app memory limits and a catch-all default server. A Space-Node VPS is a KVM machine with full root access, and the order form offers Ubuntu and Debian images, so you can run this setup on it.
FAQ
Do I need SSL in my app when Nginx handles it?
No. Nginx ends the TLS connection and talks plain HTTP to your app on 127.0.0.1, which never leaves the server. Tell your framework to trust the proxy headers from 127.0.0.1, so it reads X-Forwarded-Proto: https and builds https links.
Can I get a Let's Encrypt certificate without opening port 80?
Not with certbot --nginx, because HTTP-01 only works on port 80. The DNS-01 challenge proves control with a TXT record at _acme-challenge.app.example.com instead, through a Certbot DNS plugin for your DNS provider, and it is the only way to get a wildcard certificate such as *.example.com. If you want no open web ports at all, compare Cloudflare Tunnel and a reverse proxy.
Does Certbot renew the Let's Encrypt certificate automatically?
Yes. The apt package's certbot.timer or the snap's snap.certbot.renew timer runs twice a day, and Certbot reloads Nginx after a renewal. systemctl list-timers '*certbot*' shows the next run.
Should I use the apt package or the snap for Certbot? The apt package is simplest and updates with the system. The snap is what Certbot recommends: always the newest release, with ARI. Pick one; Certbot's snap instructions remove the apt package first.
How long is a Let's Encrypt certificate valid?
90 days with the default profile on 3 November 2026. Certbot 4.0.0 and newer can ask for a 45-day tlsserver or a 160-hour shortlived certificate instead, with --preferred-profile. The default drops to 64 days on 10 February 2027 and to 45 days on 16 February 2028.
Sources
All opened on 3 November 2026:
- Certbot: User Guide (nginx plugin and HTTP-01 on port 80, certificate paths,
--redirectdefault,-m,--agree-tosand--no-eff-email,--preferred-profile, automatic renewal, renewal thresholds,--dry-run, DNS-01 for wildcards), instructions for Nginx with snap, install docs, changelog (5.9.0 as the newest entry, ARI since 4.1.0, the one-third rule and--preferred-profilesince 4.0.0, nginx plugin changes), 5.9.0 on PyPI, snap definition - Certbot 5.9.0 nginx plugin source: configurator.py (added listen and certificate lines, redirect block, server block error, Nginx reload), main.py (reload after renewal)
- Let's Encrypt: challenge types, IPv6 support, rate limits, profiles, Decreasing Certificate Lifetimes to 45 Days (2 December 2025), Expiration notification service has ended
- Let's Encrypt Boulder source: va.go, http.go, bdns/problem.go (error texts)
- nginx docs: proxy module (
proxy_http_versiondefault since 1.29.7,proxy_set_header), WebSocket proxying, HTTP/2 module (http2since 1.25.1), listen (http2parameter deprecated,ipv6onlyon by default), headers module (add_header,always, inheritance,add_header_inherit), CHANGES-1.30 - Packages: nginx on Ubuntu 24.04, Ubuntu 26.04, Debian 12, Debian 13; certbot on Ubuntu 24.04, Ubuntu 26.04, Debian 12, Debian 13; certbot timer files for Ubuntu 24.04, Ubuntu 26.04, Debian 12 and Debian 13, with the Debian 12 cron file that skips systemd servers; Ubuntu's nginx UFW profile
- MDN: Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options
- Cloudflare: Flexible SSL mode (redirect loop with an HTTPS redirect on the origin)