# Nginx reverse proxy with SSL on a VPS using Let's Encrypt

URL: https://cloudj.net/blog/nginx-reverse-proxy-ssl-vps
Published: 2026-11-11 · Updated: 2026-11-11

> Set up an Nginx reverse proxy with SSL on an Ubuntu or Debian VPS: DNS record, server block, free Let's Encrypt certificate and automatic renewal.

An Nginx reverse proxy with SSL is the standard front door for a web app on a VPS. Your app listens on a local port such as 3000 or 8000, Nginx accepts public traffic on ports 80 and 443, terminates HTTPS with a free Let's Encrypt certificate, and forwards requests to the app. This guide walks through the whole path on Ubuntu or Debian: the DNS record, the server block, the certificate, and the renewal check that people forget until a certificate expires.

## What you need before you start

- A Linux server with a public IPv4 address. Any Ubuntu or Debian image on a [Cloudj cloud server](https://cloudj.net/cloud-servers) works; the smallest size is enough for a proxy in front of a small app.
- A domain or subdomain you control, for example `app.example.com`.
- An app already running on the server and listening on `127.0.0.1` (this guide uses port 3000).
- SSH access with a sudo user. If you have not hardened the server yet, work through the [secure new VPS checklist](https://cloudj.net/blog/secure-new-vps-checklist) first.

If you have not picked a location yet, the [VPS locations guide](https://cloudj.net/blog/vps-locations-guide) explains how to match a city to your users, and [choosing a VPS location by latency](https://cloudj.net/blog/choose-vps-location-latency) shows how to measure it.

## Step 1: point DNS at the server

Let's Encrypt checks that your domain resolves to the server asking for the certificate, so DNS comes first. At your DNS provider, create an `A` record for `app.example.com` with the server's public IPv4 address (shown on the server page in the [Cloudj console](https://app.cloudj.net)). If your server also has IPv6 and you want to serve it, add an `AAAA` record; otherwise leave it out, because a wrong `AAAA` record is a common cause of failed validation.

Check the record from the server before you continue:

```bash
dig +short app.example.com
```

On Debian, `dig` comes from the `bind9-dnsutils` package. The output should be your server's IP. If it shows nothing or an old address, wait for the record to propagate.

## Step 2: install Nginx and open the firewall

```bash
sudo apt update
sudo apt install -y nginx
sudo systemctl enable --now nginx
```

If you use UFW, allow HTTP and HTTPS. The `Nginx Full` profile opens ports 80 and 443:

```bash
sudo ufw allow 'Nginx Full'
sudo ufw status
```

Port 80 must stay reachable even after you switch to HTTPS, because the HTTP challenge used by certbot runs over it. Our [UFW firewall guide](https://cloudj.net/blog/ufw-firewall-vps) covers the rest of the rule set.

## Step 3: write the Nginx reverse proxy server block (SSL comes next)

First add a small helper so WebSocket upgrades work without breaking normal requests. This `map` must live in the `http` context, and files in `conf.d` are included there:

```bash
sudo tee /etc/nginx/conf.d/connection-upgrade.conf > /dev/null <<'EOF'
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
EOF
```

Then create the site file `/etc/nginx/sites-available/app.example.com`:

```nginx
server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    client_max_body_size 20m;

    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;
        proxy_read_timeout 60s;
    }
}
```

A few lines deserve a note. `Host` passes the original domain so the app builds correct links. `X-Forwarded-Proto` tells the app whether the visitor used HTTPS, which frameworks need to avoid redirect loops. `client_max_body_size` raises the default 1 MB upload limit; set it to what your app actually accepts.

Enable the site, test the syntax and reload:

```bash
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```

Visit `http://app.example.com`. You should see your app over plain HTTP. If you get a 502 Bad Gateway, Nginx cannot reach the app: confirm it is running with `curl -I http://127.0.0.1:3000`.

## Step 4: get a free SSL certificate with certbot

Install certbot and its Nginx plugin from the distribution packages:

```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com --redirect
```

The first run asks for an email address and agreement to the terms. Certbot then proves control of the domain over port 80, saves the certificate under `/etc/letsencrypt/live/app.example.com/`, adds a `listen 443 ssl` block to your server file, and with `--redirect` sends all HTTP traffic to HTTPS. Reload is automatic.

To cover both the bare domain and `www`, pass two `-d` flags: `-d example.com -d www.example.com`. Both names need DNS records pointing at the server.

## Step 5: confirm renewal works

Let's Encrypt certificates are short-lived, so renewal has to be automatic. The Debian and Ubuntu certbot package installs a systemd timer that runs the renewal check twice a day. Confirm it and run a dry run:

```bash
systemctl list-timers | grep certbot
sudo certbot renew --dry-run
```

If the dry run succeeds, real renewals will too, as long as DNS and port 80 stay as they are. Certbot reloads Nginx after a successful renewal when the certificate was issued with the Nginx plugin.

## Hardening worth adding

- **Keep the app private.** Bind the app to `127.0.0.1`, not `0.0.0.0`, so the only public path is through Nginx. With Docker, publish the port as `127.0.0.1:3000:3000`; see [installing Docker on a VPS](https://cloudj.net/blog/install-docker-vps).
- **HSTS.** Once HTTPS works everywhere, add `add_header Strict-Transport-Security "max-age=31536000" always;` inside the 443 server block. Do not add it before you are sure, because browsers remember it.
- **Hide the version.** Set `server_tokens off;` in `/etc/nginx/nginx.conf`.
- **Several apps, one server.** Repeat steps 3 and 4 with a new server file and domain for each app. Nginx picks the block by `server_name`.

The [official Nginx proxy module documentation](https://nginx.org/en/docs/http/ngx_http_proxy_module.html) lists every directive used here if you need to tune buffering or timeouts.

## Troubleshooting

| Symptom | Likely cause | Check |
|---|---|---|
| certbot says the challenge failed | DNS points elsewhere or port 80 is closed | `dig +short`, `sudo ufw status` |
| 502 Bad Gateway | App not running or on another port | `curl -I http://127.0.0.1:3000` |
| Redirect loop after HTTPS | App ignores `X-Forwarded-Proto` | Enable your framework's trusted proxy setting |
| `nginx -t` fails | Typo or missing semicolon | Read the line number in the error |
| Renewal dry run fails | DNS or firewall changed since issue | Repeat the DNS and port checks |

## FAQ

### Do I need a separate IP address for each domain?

No. Nginx and modern browsers use SNI, so many HTTPS sites can share one IPv4 address. Each domain gets its own server block and certificate.

### Can I get a wildcard certificate this way?

Not with the HTTP challenge. Wildcards such as `*.example.com` require the DNS challenge, which needs a certbot DNS plugin for your DNS provider.

### Should I use the snap version of certbot instead?

Either works. The apt package is simpler on Ubuntu and Debian and includes the renewal timer. The certbot project recommends snap for the newest version; pick one and do not install both.

### Does the location of the VPS matter for SSL?

Not for the certificate itself. It matters for latency to your visitors, which you can measure with the steps in [testing VPS latency with ping and mtr](https://cloudj.net/blog/test-vps-latency-ping-mtr).

## Put it on a server

A reverse proxy with SSL takes about fifteen minutes once DNS has propagated, and the same steps work in any of Cloudj's locations. You can deploy an Ubuntu or Debian server in the [Cloudj console](https://app.cloudj.net), and the [pricing page](https://cloudj.net/pricing) shows live prices for every size.
