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 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 first.
If you have not picked a location yet, the VPS locations guide explains how to match a city to your users, and choosing a VPS location by 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). 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:
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
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:
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 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:
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:
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:
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:
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:
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, not0.0.0.0, so the only public path is through Nginx. With Docker, publish the port as127.0.0.1:3000:3000; see installing Docker on a 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 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.
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, and the pricing page shows live prices for every size.