Installing Nginx and a Let's Encrypt HTTPS certificate on a VPS

From DNS record to padlock: a clean Nginx virtual host, a Let's Encrypt certificate that renews itself, and the headers that go with it.

Browser window served over HTTPS with a certificate seal

Putting a site online on a VPS takes four pieces: a DNS record pointing at the machine, a web server that answers, a valid TLS certificate, and a renewal that does not depend on your memory. Budget twenty minutes.

This guide assumes an already hardened Ubuntu 24.04 VM, if that is not the case, start with basic hardening, since everything else rests on it.

1. Point DNS, both A and AAAA

At your registrar or DNS host, create two records pointing at the VM's addresses. Publishing only an A record makes the site invisible from IPv6-only mobile networks, which keep growing.

DNS zone
example.com.      300  IN  A     198.51.100.42
example.com.      300  IN  AAAA  2602:xxxx:xxxx::10
www.example.com.  300  IN  CNAME example.com.

Check propagation before going further, Certbot will fail for as long as the name does not resolve to the right machine.

bash
dig +short example.com A
dig +short example.com AAAA

2. Install Nginx

bash
sudo apt update
sudo apt install -y nginx
sudo ufw allow 'Nginx Full'
systemctl status nginx --no-pager

The "Welcome to nginx" page should answer on the IP address. If port 80 stays silent, the firewall is almost always the reason: sudo ufw status settles it in a second.

3. Write a clean server block

One site per file in sites-available, enabled through a symlink. That separation lets you disable one site without touching the others.

nginx
sudo mkdir -p /var/www/example.com
sudo chown -R $USER:$USER /var/www/example.com
echo '<h1>example.com</h1>' > /var/www/example.com/index.html

sudo tee /etc/nginx/sites-available/example.com <<'EOF'
server {
    listen 80;
    listen [::]:80;

    server_name example.com www.example.com;
    root /var/www/example.com;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
EOF

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

The listen [::]:80; line is not decorative: without it Nginx listens on IPv4 only and your AAAA record points at nothing.

4. Get the Let's Encrypt certificate

Certbot's Nginx plugin reads your server blocks, obtains the certificate, and rewrites the configuration to add the TLS block. It is the shortest path and the least error-prone.

bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com \
     --agree-tos -m admin@example.com --redirect --no-eff-email

--redirect adds the permanent HTTP to HTTPS redirect, essential for search visibility, where two reachable versions of the same page dilute the signal.

HTTP-01 validation: Let's Encrypt reaches your server on port 80, over IPv6 too if an AAAA record exists. An AAAA pointing at an address that does not answer fails issuance even when IPv4 works perfectly. Publish the AAAA once the service is reachable, not before.

5. Confirm automatic renewal

The package installs a systemd timer that attempts renewal twice a day and only acts within the last thirty days of validity. Verify it rather than assume it.

bash
systemctl list-timers certbot.timer
sudo certbot renew --dry-run

A successful --dry-run is the only proof that counts. Everything else is an assumption you will find out was wrong ninety days later.

6. Harden the TLS configuration

Certbot produces a sane baseline. Three additions complete it: HTTP/2, HSTS, and the usual security headers. Put them in the server block listening on port 443.

nginx
listen 443 ssl;
listen [::]:443 ssl;
http2 on;

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

gzip on;
gzip_vary on;
gzip_min_length 512;
gzip_types text/css application/javascript image/svg+xml application/json;

HSTS: once the header ships, browsers will refuse plain HTTP on that domain for the whole announced duration. Start at max-age=300 while you validate the chain, then move to a year.

bash
sudo nginx -t && sudo systemctl reload nginx
curl -I https://example.com

7. Verify

  • curl -I https://example.com returns 200 and the HSTS header.
  • curl -6 -I https://example.com answers too, IPv6 is really served.
  • http://example.com redirects with a 301 to HTTPS.
  • sudo certbot certificates shows an expiry roughly 90 days out.
  • An SSL Labs scan comes back at A or better.

From here, replacing the contents of /var/www/example.com is enough to publish a real site. For a dynamic application, add a location block with proxy_pass to your service's local port, the certificate itself does not change.