How to Configure SSL/TLS on Nginx: A Complete HTTPS Guide for VPS

How to Configure SSL/TLS on Nginx: A Complete HTTPS Guide for VPS

HTTPS is no longer optional. Search engines penalize HTTP sites, browsers show security warnings, and users expect the padlock. But there’s a significant gap between a basic SSL certificate installation and a properly hardened TLS configuration that earns an A+ rating from security scanners.

This guide covers everything from installing a free Let’s Encrypt certificate to configuring TLS 1.3, strong cipher suites, HSTS, OCSP stapling, and security headers — a complete SSL/TLS hardening guide for Nginx on Ubuntu VPS.

SSL vs TLS: Terminology Note

SSL (Secure Sockets Layer) is the older protocol — technically deprecated and insecure. TLS (Transport Layer Security) is its modern replacement. Despite this, “SSL certificate” remains the common term for the certificate file itself, while the connection protocol should always be TLS 1.2 or 1.3 in 2025.


Part 1: Install a Free Let’s Encrypt Certificate

Prerequisites

  • Ubuntu VPS with Nginx installed
  • Domain name with DNS A record pointing to your VPS IP
  • Port 80 open in your firewall

Install Certbot

sudo apt update
sudo apt install certbot python3-certbot-nginx -y

Issue a certificate for your domain

# Single domain
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

# Multiple separate domains at once
sudo certbot --nginx \
  -d yourdomain.com -d www.yourdomain.com \
  -d api.yourdomain.com \
  -d admin.yourdomain.com

Certbot automatically:

  • Verifies domain ownership via HTTP challenge
  • Downloads and installs the certificate
  • Updates your Nginx config with HTTPS settings
  • Sets up HTTP → HTTPS redirect
  • Configures automatic renewal via systemd timer

Test auto-renewal

sudo certbot renew --dry-run

# Check renewal timer is active
sudo systemctl status certbot.timer

Wildcard certificates (covers all subdomains)

# Requires DNS challenge — add TXT record to your DNS
sudo certbot certonly \
  --manual \
  --preferred-challenges dns \
  -d "*.yourdomain.com" \
  -d yourdomain.com

Part 2: Understanding the Certificate Files

After Certbot runs, certificates are stored in /etc/letsencrypt/live/yourdomain.com/:

File Contents Use in Nginx
fullchain.pem Your cert + intermediate CA chain ssl_certificate
privkey.pem Your private key ssl_certificate_key
cert.pem Your certificate only OCSP stapling
chain.pem Intermediate CA chain only OCSP stapling

Always use fullchain.pem for ssl_certificate — never just cert.pem.


Part 3: Production Nginx HTTPS Configuration

Certbot’s auto-generated config is basic. Here’s a hardened, production-ready configuration:

sudo nano /etc/nginx/sites-available/yourdomain.com
# ── HTTP → HTTPS redirect ─────────────────────────────────
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    # ACME challenge for certificate renewal
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Redirect everything else to HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

# ── HTTPS server ─────────────────────────────────────────
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;                    # Enable HTTP/2

    server_name yourdomain.com www.yourdomain.com;
    root /var/www/yourdomain.com;
    index index.php index.html;

    # ── SSL Certificate ───────────────────────────────────
    ssl_certificate     /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # ── TLS Protocol Versions ─────────────────────────────
    # Only allow TLS 1.2 and 1.3 (disable 1.0 and 1.1)
    ssl_protocols TLSv1.2 TLSv1.3;

    # ── Cipher Suites ─────────────────────────────────────
    # Strong ciphers only — forward secrecy for all
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256';
    ssl_prefer_server_ciphers off;  # Let client choose (better for TLS 1.3)

    # ── DH Parameters (for DHE cipher suites) ────────────
    ssl_dhparam /etc/nginx/ssl/dhparam.pem;

    # ── Session Cache (performance) ───────────────────────
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;     # Disable for better forward secrecy

    # ── OCSP Stapling (faster certificate validation) ─────
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.com/chain.pem;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    # ── Security Headers ──────────────────────────────────
    # HSTS — tell browsers to always use HTTPS (6 months)
    add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always;

    # Prevent clickjacking
    add_header X-Frame-Options "SAMEORIGIN" always;

    # Prevent MIME type sniffing
    add_header X-Content-Type-Options "nosniff" always;

    # XSS protection (legacy browsers)
    add_header X-XSS-Protection "1; mode=block" always;

    # Control referrer information
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Content Security Policy (customize for your app)
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;" always;

    # Hide Nginx version
    server_tokens off;

    # ── Your application config ───────────────────────────
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
    }

    location ~ /\.(ht|git|env) { deny all; }
}

Part 4: Generate Strong DH Parameters

sudo mkdir -p /etc/nginx/ssl
sudo openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048

# This takes 1–5 minutes
# For maximum security, use 4096 bits (takes 10–30 minutes)
sudo nginx -t && sudo systemctl reload nginx

Part 5: Test Your SSL Configuration

SSL Labs test (comprehensive)

Visit ssllabs.com/ssltest and enter your domain. With the configuration above, you should score A+.

Command-line tests

# Check which TLS versions are supported
openssl s_client -connect yourdomain.com:443 -tls1   # Should fail (TLS 1.0 disabled)
openssl s_client -connect yourdomain.com:443 -tls1_2 # Should succeed
openssl s_client -connect yourdomain.com:443 -tls1_3 # Should succeed

# Check cipher suite
nmap --script ssl-enum-ciphers -p 443 yourdomain.com

# Check security headers
curl -I https://yourdomain.com | grep -i "strict-transport\|x-frame\|x-content"

Part 6: HSTS Preloading

HSTS (HTTP Strict Transport Security) tells browsers to never connect via HTTP — even before the first request. HSTS Preloading goes further: it submits your domain to a browser preload list, so users are protected even on their very first visit.

# Current HSTS header (already in our config):
add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always;

To add your domain to the browser preload list:

  1. Ensure HTTPS is working perfectly for all subdomains
  2. Visit hstspreload.org
  3. Submit your domain
  4. Wait for inclusion in Chrome/Firefox preload lists (weeks to months)

⚠️ Warning: HSTS preloading is difficult to undo. Only submit if you’re committed to HTTPS permanently for all subdomains.


Part 7: SSL for Multiple Domains (SNI)

Server Name Indication (SNI) allows one IP address to serve SSL certificates for multiple domains:

# Each domain gets its own server block with its own certificate
server {
    listen 443 ssl http2;
    server_name site1.com www.site1.com;
    ssl_certificate /etc/letsencrypt/live/site1.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/site1.com/privkey.pem;
    # ... include ssl params from a shared file
}

server {
    listen 443 ssl http2;
    server_name site2.com www.site2.com;
    ssl_certificate /etc/letsencrypt/live/site2.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/site2.com/privkey.pem;
    # ...
}

Shared SSL parameters snippet

sudo nano /etc/nginx/snippets/ssl-params.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
add_header Strict-Transport-Security "max-age=15768000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;

Include in each server block:

include snippets/ssl-params.conf;

Part 8: Certificate Monitoring and Renewal Alerts

nano ~/check-ssl-expiry.sh
#!/bin/bash
DOMAINS=("yourdomain.com" "api.yourdomain.com")
ALERT_DAYS=14
EMAIL="you@youremail.com"

for DOMAIN in "${DOMAINS[@]}"; do
    EXPIRY=$(echo | openssl s_client -servername $DOMAIN \
        -connect $DOMAIN:443 2>/dev/null | \
        openssl x509 -noout -dates 2>/dev/null | \
        grep "notAfter" | cut -d= -f2)

    EXPIRY_DATE=$(date -d "$EXPIRY" +%s)
    TODAY=$(date +%s)
    DAYS_LEFT=$(( ($EXPIRY_DATE - $TODAY) / 86400 ))

    if [ $DAYS_LEFT -lt $ALERT_DAYS ]; then
        echo "⚠️  $DOMAIN certificate expires in $DAYS_LEFT days!" | \
            mail -s "SSL Expiry Alert: $DOMAIN" $EMAIL
    else
        echo "✅ $DOMAIN: $DAYS_LEFT days remaining"
    fi
done
chmod +x ~/check-ssl-expiry.sh
crontab -e
# 0 8 * * * /bin/bash /root/check-ssl-expiry.sh

Common SSL Issues and Fixes

Mixed content warnings

HTTP resources (images, scripts) on an HTTPS page trigger browser warnings. Fix by searching and replacing http://yourdomain.com with https:// in your database (use Better Search Replace plugin for WordPress) or adding this to Nginx:

add_header Content-Security-Policy "upgrade-insecure-requests;" always;

Certificate renewal fails

Certbot needs port 80 open for HTTP challenge. If you’ve blocked port 80, use DNS challenge instead or temporarily open the port during renewal:

sudo ufw allow 80/tcp
sudo certbot renew
sudo ufw delete allow 80/tcp

SSL Labs score is A, not A+

Common reasons for missing the A+:

  • HSTS header missing or max-age too short (needs 6+ months)
  • TLS 1.0 or 1.1 still enabled
  • Weak DH parameters (use 2048-bit minimum)

Final Thoughts

A properly configured Nginx SSL setup isn’t just about the padlock — it’s about forward secrecy, modern cipher suites, OCSP stapling, and security headers that protect your users at every level. The configuration in this guide consistently earns A+ scores on SSL Labs and passes strict security header checks.

Fast • Reliable • Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!