Skip to content
Cloudj

VPS how-to

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

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.

Updated · 5 min read

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, 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.
  • 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

SymptomLikely causeCheck
certbot says the challenge failedDNS points elsewhere or port 80 is closeddig +short, sudo ufw status
502 Bad GatewayApp not running or on another portcurl -I http://127.0.0.1:3000
Redirect loop after HTTPSApp ignores X-Forwarded-ProtoEnable your framework's trusted proxy setting
nginx -t failsTypo or missing semicolonRead the line number in the error
Renewal dry run failsDNS or firewall changed since issueRepeat 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.

Deploy a server or order proxies

Pick a location in the console, see the live price, and pay from your balance.