Set up nginx as an HTTPS reverse proxy with Let's Encrypt
Put your app behind nginx with a free Let's Encrypt certificate on Ubuntu 24.04: tested config, HTTP/2, WebSockets, and renewal ready for shorter lifetimes.
Updated 8 min read

In this article
To put a Node.js, PHP or Python app behind HTTPS on a single VPS, run nginx as a reverse proxy in front of it, get a free certificate from Let's Encrypt with Certbot, and let a systemd timer renew it. The whole job takes about twenty minutes on Ubuntu 24.04, and the configuration below has been syntax-checked with nginx -t.
This tutorial goes step by step: install nginx, serve the ACME challenge over HTTP, issue the certificate, switch on HTTPS with HTTP/2 and WebSocket support, and make sure renewal keeps working as Let's Encrypt shortens certificate lifetimes over the next two years.

What you will end up with
| Piece | Role |
|---|---|
Your app on 127.0.0.1:3000 | Listens only on localhost, so the internet can reach it only through nginx |
| nginx on ports 80 and 443 | Terminates TLS, redirects HTTP to HTTPS, forwards requests and WebSocket upgrades to the app |
| Certbot (snap) | Requests and renews the Let's Encrypt certificate through the HTTP-01 challenge |
| systemd timer | Runs renewal automatically and reloads nginx after a new certificate is issued |
The examples use example.com and www.example.com. Before you start, point both DNS A (and AAAA, if your server has IPv6) records at the VPS, and make sure your app is running on port 3000 and bound to 127.0.0.1, not 0.0.0.0.
Step 1: Install nginx and open the firewall
sudo apt update
sudo apt install nginx
nginx -v
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableUbuntu 24.04 ships nginx 1.24.0. That is fine for this guide, with one syntax difference covered in Step 4. If you want the current release line, nginx.org publishes its own packages for Ubuntu 22.04, 24.04 and 26.04; at the time of writing (October 2026) the stable branch is 1.30.5 and mainline is 1.31.6. The nginx.org packages put site files in /etc/nginx/conf.d/ rather than sites-available, so adjust paths if you go that way.
Allow SSH before enabling the firewall, or you can lock yourself out of the server.
Step 2: Serve the app and the ACME challenge over HTTP
Start with a plain HTTP server block. It does two jobs: it proxies normal traffic to your app, and it serves files from /var/www/letsencrypt for the Let's Encrypt HTTP-01 challenge.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
}
}sudo mkdir -p /var/www/letsencrypt
sudo nano /etc/nginx/sites-available/app.conf # paste the HTTP-only block
sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
curl -I http://example.comDo not add the HTTPS blocks yet. nginx refuses to start if a ssl_certificate path points to a file that does not exist, and the certificate does not exist until Step 3. This chicken-and-egg problem is the most common reason first attempts fail.
Step 3: Issue the certificate with Certbot
The Certbot project recommends the snap package and asks you to remove any OS-packaged certbot first. Then request a certificate with the webroot method, which writes the challenge file into the folder nginx is already serving:
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
sudo certbot certonly --webroot -w /var/www/letsencrypt \
-d example.com -d www.example.com \
--deploy-hook "systemctl reload nginx"Using certonly means Certbot obtains the certificate but does not rewrite your nginx files, so the configuration stays exactly as you wrote it. The --deploy-hook is saved in the renewal settings and reloads nginx each time a new certificate is installed. (If you prefer Certbot to edit nginx for you, sudo certbot --nginx does that instead.)
The files land in /etc/letsencrypt/live/example.com/: fullchain.pem is the certificate plus intermediates, and privkey.pem is the private key. Point nginx at the live paths, which are symlinks that Certbot updates on renewal.

Step 4: The full HTTPS reverse proxy configuration
Replace the contents of /etc/nginx/sites-available/app.conf with the configuration below. It redirects all HTTP traffic to the canonical https://example.com, redirects www over HTTPS too, enables HTTP/2, and proxies to the app with keep-alive connections and WebSocket support.
map $http_upgrade $connection_upgrade {
default upgrade;
'' '';
}
upstream app_backend {
server 127.0.0.1:3000;
keepalive 16;
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
location / {
return 301 https://example.com$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000" always;
client_max_body_size 10m;
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 Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 60s;
}
}The http2 line on Ubuntu's nginx 1.24
The http2 on; directive appeared in nginx 1.25.1. On Ubuntu 24.04's packaged 1.24.0 it is an unknown directive, so delete both http2 on; lines and write the listen lines as listen 443 ssl http2; and listen [::]:443 ssl http2; instead. On nginx 1.25.1 and later, use the directive as shown; the old listen parameter still works but is deprecated.
Why each part is there
- The
mapblock sendsConnection: upgradeonly when the client asked for a WebSocket upgrade, and an emptyConnectionheader otherwise, so ordinary requests can reuse the keep-alive connections to the upstream. proxy_http_version 1.1is required for WebSockets and upstream keep-alive. nginx has defaulted to 1.1 only since 1.29.7; older versions, including Ubuntu's 1.24, default to 1.0, so keep the line.X-Forwarded-ForandX-Forwarded-Prototell your app the real client IP and that the original request was HTTPS. Configure your framework to trust them only from127.0.0.1(for example, Express'strust proxysetting).proxy_read_timeout 60sis the nginx default, written out so you remember it exists. WebSocket connections that send nothing for 60 seconds are closed, so either send pings from the app or raise this value for the WebSocket location.Strict-Transport-Securitytells browsers to use HTTPS only for a year. Add it only once HTTPS works for every subdomain you serve, because browsers remember it.
No OCSP stapling lines are included on purpose. Let's Encrypt switched off its OCSP service in August 2025 and its certificates no longer contain an OCSP URL, so ssl_stapling has nothing to fetch.
Step 5: Test, reload and verify
sudo nginx -t && sudo systemctl reload nginx
curl -I http://example.com # expect 301 to https://example.com/
curl -I https://www.example.com # expect 301 to https://example.com/
curl -I https://example.com # expect your app's response
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -dates -issuerIf nginx -t fails, nginx keeps serving the old configuration; a reload only applies a configuration that passes the check. Fix the reported line and try again.
Step 6: Make sure renewal keeps working
The snap package installs a systemd timer that renews certificates before they expire. Check it and run a rehearsal against the staging environment:
sudo certbot renew --dry-run
systemctl list-timers | grep -i certbot
sudo certbot certificatesRenewal matters more each year. Let's Encrypt certificates are valid for 90 days today, and the CA has published a schedule to shorten them: an opt-in 45-day profile from 13 May 2026, 64-day certificates by default from 10 February 2027, and 45-day certificates by default from 16 February 2028. Let's Encrypt says renewing on a hard-coded 60-day interval "will no longer be sufficient" and recommends clients that support ACME Renewal Information (ARI), or renewing at about two-thirds of the certificate's lifetime. Certbot's timer-based renewal handles this for you, but only if the timer is running and the deploy hook succeeds.
Add an external check as well: an uptime monitor that alerts when the certificate has fewer than 14 days left will catch a broken renewal long before your visitors do.
Gotchas that cost people hours
- Port 80 must stay open. The HTTP-01 challenge always starts on port 80, even when every page redirects to HTTPS. Keep the
/.well-known/acme-challenge/location in the port 80 block. - A CDN or proxy in front can intercept the challenge. If DNS points at Cloudflare's proxy, confirm that requests to
/.well-known/acme-challenge/reach your server, or use the DNS-01 challenge with a DNS plugin instead. - IPv6 records must work too. If you publish an
AAAArecord, Let's Encrypt may validate over IPv6. A stale or wrongAAAArecord is a classic cause of failed validation. - The app must not also listen publicly. Bind it to
127.0.0.1; otherwise anyone can bypass TLS and your headers on port 3000 if the firewall is ever relaxed. - Uploads larger than 1 MB fail with 413. nginx's default
client_max_body_sizeis 1m. The example raises it to 10m; set it to what your app actually accepts. - Rate limits apply to repeated failures. Use
--dry-runor--stagingwhile experimenting so you do not hit Let's Encrypt's production limits.
What to do next
- Run an external TLS test against your domain and confirm only TLS 1.2 and 1.3 are offered.
- Add the certificate-expiry alert described above.
- Put the nginx file in version control with the rest of your infrastructure notes, so a rebuilt server gets the same configuration.
- When you add a second app instance, add another
serverline to theupstreamblock; that is the first step towards zero-downtime deploys.
Frequently asked questions
Should I use certbot --nginx or certbot certonly?
sudo certbot --nginxobtains the certificate and edits your nginx configuration automatically.certbot certonlyonly obtains it, leaving your configuration untouched. Usecertonlywith a deploy hook if you keep nginx files in version control and want them to stay exactly as written.Why does nginx fail to start after I add the HTTPS server block?
Usually because the certificate files do not exist yet. nginx checks that every
ssl_certificatepath can be loaded. Issue the certificate first with only the HTTP block enabled, then add the HTTPS blocks and runsudo nginx -t.Do I need to change anything for Let's Encrypt's 45-day certificates?
Not if renewal is automatic. Let's Encrypt plans 64-day default certificates from 10 February 2027 and 45-day defaults from 16 February 2028, and warns that fixed 60-day renewal intervals will not be enough. Certbot's scheduled task renews certificates before they expire; confirm it with
sudo certbot renew --dry-runand add an expiry alert.Is http2 on; supported on Ubuntu 24.04?
Not with the packaged nginx 1.24.0. The
http2directive appeared in nginx 1.25.1. On 1.24 uselisten 443 ssl http2;instead, or install a current version from the nginx.org repository.Should I enable OCSP stapling?
Not for Let's Encrypt certificates. Let's Encrypt shut down its OCSP service on 6 August 2025 and now publishes revocation information only through certificate revocation lists, so its certificates have no OCSP URL to staple.
Why do my WebSocket connections drop after a minute?
nginx closes a proxied connection if the upstream sends nothing for
proxy_read_timeout, which defaults to 60 seconds. Send WebSocket ping frames from your server more often than that, or raiseproxy_read_timeoutfor the WebSocket location.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Certbot instructions: nginx on Linux (snap) — Electronic Frontier Foundation
- FAQ — Let's Encrypt
- Decreasing Certificate Lifetimes to 45 Days — Let's Encrypt, 2025-12-02
- OCSP Service Has Reached End of Life — Let's Encrypt, 2025-08-06
- Module ngx_http_v2_module — nginx.org
- WebSocket proxying — nginx.org
- Module ngx_http_proxy_module — nginx.org
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Interview preparationInterview questions
Docker interview questions and answers for Docker Engine 29
18 Docker interview questions from images and volumes to build cache, Compose, networking, signals and the containerd image store, with short model answers.
By Cloudsoft Zone

Resource guideResources
Cron syntax cheat sheet: crontab, GitHub Actions, Kubernetes
Cron syntax with tested expressions, the day-of-week trap, and how time zones and limits differ in crontab, GitHub Actions, Kubernetes and Laravel in 2026.
By Cloudsoft Zone

Architecture guideAI and Automation
How to build a safe AI content pipeline with human fact review
An architecture for AI-assisted publishing where people stay responsible: untrusted source snapshots, claim checking with citations, a review queue AI cannot skip, labels and rollback.
By Cloudsoft Zone
