LINESERVE
All tutorials
TutorialAll levels

Nginx Reverse Proxy with HTTPS on Ubuntu 24.04 (Let’s Encrypt)

Put Nginx in front of your app with a real Let’s Encrypt certificate on Ubuntu 24.04, with renewal and a reboot covered.

Stephen NdegwaPublished Last updated 13 min read
Share

Nginx sitting in front of an app and terminating HTTPS for it is two files and four commands once the app itself is running: a server block that proxies to it, and a real certificate from Certbot. Here’s the whole job:

  1. Install Nginx.
  2. Have something listening locally for it to forward to.
  3. Point your domain at the server.
  4. Configure Nginx to proxy to that backend.
  5. Get a real certificate with Certbot, and force HTTPS.
  6. Put a firewall in front of it.
  7. Confirm it survives a reboot.

The output shown beneath each command is what it prints. One exception runs throughout: Certbot’s own confirmation messages will show your actual domain, not example.com.

Before you start

You need:

Nginx isn’t installed yet; curl, nano and ufw are, on a stock Ubuntu 24.04 server — everything below runs at the shell once you’re logged in over SSH (ssh [email protected]), except the one DNS check in step 3, which runs from your own machine instead.

Conventions used below — swap in your own values everywhere these appear:

Placeholder Used below Yours comes from
Your domain example.com Whatever you registered
Your server’s public IP 203.0.113.10 Your VPS provider’s dashboard
Your app’s local address 127.0.0.1:8080 Wherever your app actually listens
Your login user ubuntu Whatever you created in server setup

Step 1: Install Nginx

sudo apt update
sudo apt install -y nginx

Confirm it’s running before doing anything else:

nginx -v
systemctl is-active nginx
systemctl is-enabled nginx

That prints a version string and two status words:

nginx version: nginx/1.24.0 (Ubuntu)
active
enabled

enabled means systemd already starts it on boot — the reboot near the end confirms that rather than asking you to take it on faith.

Step 2: Have something for Nginx to proxy to

Nginx doesn’t care what’s behind the port it forwards to — a Node process, a Python app, a container, anything speaking HTTP on 127.0.0.1. If you already have an app running locally, note its address and skip to step 3.

If you don’t, here’s a small stand-in — Python’s standard library, nothing to install — that answers on port 8080 and echoes back the request it received, which doubles as proof later that requests are genuinely reaching it through the proxy rather than hitting a cached Nginx page:

sudo tee /usr/local/bin/demo-backend.py >/dev/null <<'PY'
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        body = json.dumps({"server": "demo-backend on 127.0.0.1:8080", "path": self.path,
                            "headers": dict(self.headers.items())}, indent=2).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, fmt, *args):
        pass

if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", 8080), Handler).serve_forever()
PY
sudo chmod 755 /usr/local/bin/demo-backend.py

Run it as a service under your own login user (ubuntu below — swap in whichever user you set up the server with) rather than root — it’s a stand-in for an app process, not a system tool:

sudo tee /etc/systemd/system/demo-backend.service >/dev/null <<'UNIT'
[Unit]
Description=Demo backend for the reverse proxy
After=network.target

[Service]
ExecStart=/usr/bin/python3 /usr/local/bin/demo-backend.py
User=ubuntu
Restart=on-failure

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now demo-backend
curl -s http://127.0.0.1:8080/hello

It echoes the request straight back as JSON:

{
  "server": "demo-backend on 127.0.0.1:8080",
  "path": "/hello",
  "headers": {
    "Host": "127.0.0.1:8080",
    "User-Agent": "curl/8.5.0",
    "Accept": "*/*"
  }
}

That confirms the backend itself works before Nginx is anywhere near it — the standard way to isolate which half broke if something doesn’t come back right later.

Step 3: Point your domain at the server

Create an A record for your domain pointing at the server’s public IP, wherever you manage DNS. Confirm it before going any further — Certbot’s certificate request in step 5 looks the domain up and connects to whatever IP it finds, so a record that hasn’t propagated yet, or points somewhere else, fails there instead of here. Run this from your own computer, not the server — what matters is whether the outside world resolves the name. macOS and most Linux distributions have dig already (brew install bind on macOS if not); on Windows, PowerShell’s Resolve-DnsName example.com answers the same question:

dig +short example.com

A propagated record answers with the server’s own IP and nothing else:

203.0.113.10

If that comes back empty or wrong, wait for it — DNS changes typically land within minutes but can take longer depending on your registrar and the record’s previous TTL — and re-run the check before continuing. There’s no fixed wait time worth quoting; the dig output is the actual answer.

Step 4: Configure Nginx as a reverse proxy

Create a server block for the domain:

sudo tee /etc/nginx/sites-available/example.com >/dev/null <<'CONF'
server {
    listen 80;
    listen [::]:80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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;
    }
}
CONF

proxy_pass is the forwarding rule; everything else on the four proxy_set_header lines exists because forwarding a request on its own throws away information the backend usually needs. Without them, your app sees every request as if it came from 127.0.0.1 (Nginx itself), over plain HTTP, for a Host of localhost — which breaks anything that generates absolute URLs, checks the client’s real IP for rate-limiting or geolocation, or redirects based on whether the original request was HTTPS. X-Forwarded-For uses nginx’s $proxy_add_x_forwarded_for rather than $remote_addr so it appends to, rather than overwrites, a chain from any proxy further upstream (a CDN, a load balancer).

Enable the site and remove the default one so there’s exactly one server block active:

sudo ln -sf /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.com
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t

A clean config says so in two lines:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

nginx -t checks syntax without touching the running server — always run it before a reload, because a reload with a broken config leaves the old config running rather than failing loudly (see the syntax-error section below for what that actually looks like). Once it’s clean:

sudo systemctl reload nginx
curl -H "Host: example.com" http://localhost/hello

The request comes back from the backend, not from Nginx itself, carrying the headers Nginx added:

{
  "server": "demo-backend on 127.0.0.1:8080",
  "path": "/hello",
  "headers": {
    "Host": "example.com",
    "X-Real-IP": "::1",
    "X-Forwarded-For": "::1",
    "X-Forwarded-Proto": "http",
    "Connection": "close",
    "User-Agent": "curl/8.5.0",
    "Accept": "*/*"
  }
}

Those four headers arriving at the backend — not just a 200 — is the actual proof the proxy is configured correctly (::1 is IPv6 for “this same machine,” which is all a loopback test can show; the real client IP shows up once requests arrive from outside, in the next step). Once your domain’s A record has propagated (step 3), the same request works from anywhere as curl http://example.com/hello.

Step 5: Get a real certificate with Certbot

Certbot’s own current install guidance recommends the Snap package on every system that supports Snap, over the OS-packaged version — and on Ubuntu 24.04 the gap is real rather than cosmetic: apt carries certbot 2.9.0-1, while the Snap ships 5.7.0, three major versions ahead. Ubuntu 24.04 ships snapd by default, so there’s nothing to install first:

sudo snap install core
sudo snap refresh core
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/bin/certbot
certbot --version

Each snap install prints its own confirmation line as it finishes (certbot 5.7.0 from Certbot Project (certbot-eff**) installed — the ** is Snap’s own marker for a verified publisher) — silence means it’s still working, not that it failed. That’s the version actually running the certificate request below:

certbot 5.7.0

(If your system doesn’t have Snap available for some reason, sudo apt install certbot python3-certbot-nginx is the fallback — older, but it works the same way from here.)

Request the certificate and let Certbot edit the Nginx config for you. Run it interactively and Certbot will prompt for an email address and ask you to accept Let’s Encrypt’s terms before it requests anything; passing them as flags skips both prompts and is easier to follow along with here:

sudo certbot --nginx -d example.com --redirect --non-interactive --agree-tos -m [email protected]

Your real domain appears in this output, not example.com — it’s Certbot’s own record of the certificate it just issued:

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/proxy.lab.lineserve.dev/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/proxy.lab.lineserve.dev/privkey.pem
This certificate expires on 2026-10-18.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.

Deploying certificate
Successfully deployed certificate for proxy.lab.lineserve.dev to /etc/nginx/sites-enabled/example.com
Congratulations! You have successfully enabled HTTPS on https://proxy.lab.lineserve.dev

--redirect is what makes Certbot rewrite your server block into two: the original on port 80 now sends a permanent redirect to HTTPS, and a new one on 443 holds the TLS configuration and your original location block untouched:

server {
    server_name example.com;
    location / {
        proxy_pass http://127.0.0.1:8080;
        ... (your proxy_set_header lines, untouched)
    }

    listen 443 ssl; # managed by Certbot
    listen [::]:443 ssl ipv6only=on; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/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
}
server {
    if ($host = example.com) {
        return 301 https://$host$request_uri;
    } # managed by Certbot

    listen 80;
    listen [::]:80;
    server_name example.com;
    return 404; # managed by Certbot
}

Confirm both halves actually work:

curl -s https://example.com/hello
curl -sI http://example.com/hello | grep -iE "^HTTP|^Location"

The HTTPS request reaches the backend with the real headers, and the plain HTTP request gets redirected before it ever does:

{
  "server": "demo-backend on 127.0.0.1:8080",
  "path": "/hello",
  "headers": {
    "Host": "example.com",
    "X-Real-IP": "203.0.113.10",
    "X-Forwarded-For": "203.0.113.10",
    "X-Forwarded-Proto": "https",
    "Connection": "close",
    "User-Agent": "curl/8.5.0",
    "Accept": "*/*"
  }
}
HTTP/1.1 301 Moved Permanently
Location: https://example.com/hello

X-Forwarded-Proto: https reaching the backend is the detail worth checking specifically — it’s what your app should key off if it needs to know whether the original connection was encrypted, since as far as your app’s own socket is concerned, every request from Nginx is plain HTTP on 127.0.0.1.

A 90-day certificate is only useful if it renews itself. Let’s Encrypt’s own recommendation is to renew every 60 days, and Certbot’s default behaviour already matches that: certbot renew only touches certificates with less than a third of their lifetime left — around the 30-day mark on a 90-day cert — and the Snap install adds a timer that checks twice a day, so you don’t have to remember. Confirm it’s actually scheduled and would actually work, without touching the real certificate:

systemctl is-active snap.certbot.renew.timer
systemctl is-enabled snap.certbot.renew.timer
sudo certbot renew --dry-run

The timer is armed, and a simulated renewal against the real certificate succeeds without changing it:

active
enabled
Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/example.com/fullchain.pem (success)

Step 6: Put a firewall in front of it

Nginx registers application profiles with ufw when it installs; use those rather than raw port numbers so the rule stays readable:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable
sudo ufw status

Without --force, ufw enable stops to ask for confirmation first, warning that it may disrupt existing SSH connections — safe to skip that prompt here because OpenSSH was allowed first, on the line above. If you’d rather see the prompt and answer it yourself, drop --force and type y when asked:

Status: active

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere
80,443/tcp (Nginx Full)    ALLOW IN    Anywhere
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)
80,443/tcp (Nginx Full (v6)) ALLOW IN    Anywhere (v6)

Nginx Full opens both 80 and 443 — 80 has to stay open even though it now only redirects, both because Certbot’s renewal re-runs the same HTTP-01 check that got you the certificate in the first place, and because a client that mistypes http:// needs somewhere to land the redirect. Re-run the two curl commands from step 5 once the firewall is active; nothing about them should have changed.

When something goes wrong

Certbot fails with a DNS problem. This is what it looks like when the domain doesn’t resolve to the server yet — running step 5 before step 3 has actually propagated, the single most common way to hit this out of order:

Certbot failed to authenticate some domains (authenticator: nginx). The Certificate Authority reported these problems:
  Identifier: example.com
  Type:   dns
  Detail: DNS problem: NXDOMAIN looking up A for example.com - check that a DNS record exists for this domain

The certificate authority looks the domain up and connects to it directly to check for a token Certbot placed there — there’s no DNS lookup to fall back on if the record isn’t there yet. Re-run the dig check from step 3; once it returns the right IP, re-run the certbot --nginx command.

A config edit breaks the site. It doesn’t, if you run nginx -t first — that’s the entire reason step 4 has you run it before every reload. Here’s a real syntax error (a missing semicolon on proxy_pass) caught before it went anywhere near the running server:

nginx: [emerg] 1367#1367: invalid number of arguments in "proxy_pass" directive in /etc/nginx/sites-enabled/example.com:6
nginx: configuration file /etc/nginx/nginx.conf test failed

Because that file was only tested, never reloaded, the site kept serving the last good configuration the entire time — curl against it during this exact error still returned 200. Fix the typo, run nginx -t again, and only then systemctl reload nginx. A syntax error caught this way costs nothing; one that reaches a reload on a config with no listener at all costs you the site until you notice.

If your app needs WebSockets

Chat, live-reload dev servers and anything using Socket.IO need the connection upgraded rather than just proxied. Open /etc/nginx/sites-available/example.com (sudo nano /etc/nginx/sites-available/example.com) and add two more proxy_set_header lines to the same location / block from step 4 — they don’t affect requests that aren’t asking for an upgrade, so there’s nothing to route separately:

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        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_http_version 1.1 was already there from step 4; Upgrade and Connection: upgrade are what tell Nginx to hand the raw connection through rather than treating it as a normal request/response, and only fire when a client’s own request actually asks for an upgrade. Save the file, then test and reload exactly as in step 4: sudo nginx -t && sudo systemctl reload nginx.

More than one site behind the same Nginx

Each domain gets its own file in /etc/nginx/sites-available, symlinked into sites-enabled the same way as step 4 — Nginx picks the right one per request by matching server_name against the Host header, so there’s no conflict as long as each file names a different domain. Run sudo certbot --nginx -d newdomain.com --redirect again for each one; Certbot adds certificates without touching the ones it already issued.

Confirm it survives a reboot

enabled means Nginx should start itself on boot — reboot and confirm that rather than taking it on faith:

sudo reboot

Your SSH session drops. Wait about a minute, reconnect, and check:

systemctl is-active nginx demo-backend
systemctl is-enabled nginx demo-backend
sudo ufw status | head -1
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/hello
curl -s -o /dev/null -w "%{http_code}\n" http://example.com/hello

Everything comes back on its own, with no manual restart of anything:

active
active
enabled
enabled
Status: active
200
301

Nginx, the backend, and the firewall all came back with no manual step, and the site answered correctly over both HTTP and HTTPS — the redirect included.

Sizing the server

Nginx terminating TLS in front of one small backend process is a light workload — the proxy itself does very little work per request, and the certificate renewal timer runs a few times a day, not continuously. On Lineserve’s Cloud Servers configurator (or /tz, /ng), 1 vCPU, 2 GB RAM, 40 GB SSD and one dedicated IPv4 comes to $21.95 / KES 2,195 / TZS 43,900 / NGN 21,950 a month — build it to that exact footprint, or take the identical specification pre-packaged as Linux VPS S2 for the same price if you’d rather pick a plan than move sliders. Either way you get a dedicated IPv4, and billing in KES, TZS or NGN by M-Pesa, mobile money, bank transfer or card, depending on your market.

If the app behind the proxy needs more than that — several backend processes, a database on the same box, anything that actually uses the CPU — size the server to the app, not to Nginx; the proxy itself won’t be what runs out of room first.