Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
SaaS and CloudDevOpsTutorial

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.

Cloudsoft Zone

Updated 8 min read

Aisle of black network racks with patch panels and cable managers on a white raised floor in a data centre
Photograph: Robert.Harker / CC BY-SA 3.0, via Wikimedia Commons
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.

Dozens of labelled yellow fibre-optic patch cables plugged into an optical patch panel at the AMS-IX internet exchange in Amsterdam
Traffic to your site crosses shared networks like this internet exchange; TLS keeps it private and tamper-evident on the way.

What you will end up with

PieceRole
Your app on 127.0.0.1:3000Listens only on localhost, so the internet can reach it only through nginx
nginx on ports 80 and 443Terminates 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 timerRuns 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

Shell
sudo apt update
sudo apt install nginx
nginx -v
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Ubuntu 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.

Nginx
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;
    }
}
Shell
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.com

Do 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:

Shell
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.

Bundles of blue network cables in an overhead tray running into labelled patch panels on black data centre racks
A reverse proxy is the patch panel of your app: one tidy entry point that routes traffic to the right place.

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.

Nginx
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 map block sends Connection: upgrade only when the client asked for a WebSocket upgrade, and an empty Connection header otherwise, so ordinary requests can reuse the keep-alive connections to the upstream.
  • proxy_http_version 1.1 is 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-For and X-Forwarded-Proto tell your app the real client IP and that the original request was HTTPS. Configure your framework to trust them only from 127.0.0.1 (for example, Express's trust proxy setting).
  • proxy_read_timeout 60s is 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-Security tells 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

Shell
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 -issuer

If 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:

Shell
sudo certbot renew --dry-run
systemctl list-timers | grep -i certbot
sudo certbot certificates

Renewal 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 AAAA record, Let's Encrypt may validate over IPv6. A stale or wrong AAAA record 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_size is 1m. The example raises it to 10m; set it to what your app actually accepts.
  • Rate limits apply to repeated failures. Use --dry-run or --staging while experimenting so you do not hit Let's Encrypt's production limits.

What to do next

  1. Run an external TLS test against your domain and confirm only TLS 1.2 and 1.3 are offered.
  2. Add the certificate-expiry alert described above.
  3. Put the nginx file in version control with the rest of your infrastructure notes, so a rebuilt server gets the same configuration.
  4. When you add a second app instance, add another server line to the upstream block; that is the first step towards zero-downtime deploys.

Frequently asked questions

  • Should I use certbot --nginx or certbot certonly?

    sudo certbot --nginx obtains the certificate and edits your nginx configuration automatically. certbot certonly only obtains it, leaving your configuration untouched. Use certonly with 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_certificate path can be loaded. Issue the certificate first with only the HTTP block enabled, then add the HTTPS blocks and run sudo 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-run and add an expiry alert.

  • Is http2 on; supported on Ubuntu 24.04?

    Not with the packaged nginx 1.24.0. The http2 directive appeared in nginx 1.25.1. On 1.24 use listen 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 raise proxy_read_timeout for the WebSocket location.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Certbot instructions: nginx on Linux (snap) — Electronic Frontier Foundation
  2. FAQ — Let's Encrypt
  3. Decreasing Certificate Lifetimes to 45 Days — Let's Encrypt, 2025-12-02
  4. OCSP Service Has Reached End of Life — Let's Encrypt, 2025-08-06
  5. Module ngx_http_v2_module — nginx.org
  6. WebSocket proxying — nginx.org
  7. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.