How to Configure Nginx Reverse Proxy with Let's Encrypt SSL

Published on | Updated

Configure Nginx as a reverse proxy with SSL from Let's Encrypt on a VPS: the Certbot commands first, then the server block, HTTP/2, renewal and common errors.

Data center aisle with server racks and network cabling, a technician holding a laptop

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.04Ubuntu 26.04Debian 12Debian 13
nginx1.24.01.28.31.22.11.26.3
Certbot from apt2.9.04.0.02.1.04.0.0
HTTP/2 syntaxlisten 443 ssl http2;http2 on;listen 443 ssl http2;http2 on;
Certbot renews whenunder 30 days are leftunder a third of the lifetime is leftunder 30 days are leftunder 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.com with 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 with dig +short A app.example.com and dig +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:3000 on 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_name must match the domain you give Certbot. That is how Certbot finds the block to edit.
  • proxy_http_version 1.1 is 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-For and X-Forwarded-Proto pass 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, Upgrade and Connection lines come from nginx's WebSocket guide, so WebSockets work too. The map sits outside server and is needed only once, however many sites you add.
  • listen [::]:80 is 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 old http2 parameter on listen is deprecated in these versions.
  • Older nginx (Ubuntu 24.04, Debian 12): add http2 to Certbot's listen lines, so they read listen 443 ssl http2; and listen [::]: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;
  • always adds 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-Security makes browsers use only HTTPS for your domain for a year (31536000 seconds). Add includeSubDomains only when every subdomain serves HTTPS, because the policy then covers all of them.
  • nosniff makes browsers keep to the Content-Type your app sends. X-Frame-Options DENY blocks every site, yours included, from framing your pages; use SAMEORIGIN if your app frames itself.
  • An add_header inside a location cancels all server-level add_header lines for that location. nginx 1.29.3 added add_header_inherit to 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

MessageWhat it meansFix
Could not automatically find a matching server block for app.example.comNo server block has that server_nameAdd the name to server_name, reload, run Certbot again
Timeout during connect (likely firewall problem)No answer on port 80Open port 80 in UFW and your provider's firewall; check the AAAA record
Connection refusedNothing listens on port 80 at that addressStart Nginx; add listen [::]:80 if you have an AAAA record
Invalid response from http://app.example.com/.well-known/acme-challenge/...: 404The request reached a server that does not have the challengePoint the A and AAAA records at this VPS
DNS problem: NXDOMAIN looking up A for app.example.comNo A record yetCreate it and wait until dig returns it
nginx: [emerg] cannot load certificateThe config names certificate files that do not exist yetStart from the HTTP-only block and let Certbot add them
ERR_TOO_MANY_REDIRECTS behind CloudflareFlexible mode connects over HTTP, and Certbot's redirect sends it back to HTTPSSet the SSL/TLS mode to Full (strict); our Cloudflare 521 guide explains the modes
502 Bad Gateway on the HTTPS addressThe certificate works, but the app does not answer on 127.0.0.1:3000Follow 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: