SSL + Nginx Setup
Configure Nginx as a production-grade reverse proxy with automated SSL, security headers, and performance tuning. Works with any app, any domain, any VPS provider running Ubuntu/Debian.
When to Use
- User wants SSL/HTTPS on a VPS-hosted application
- User needs Nginx configured as a reverse proxy
- User says "set up SSL", "add HTTPS", "configure Nginx", "secure my domain"
- User has an app running on a port and needs it accessible via a domain with TLS
- User needs to add or fix security headers
- User needs wildcard certificates for subdomains
When NOT to Use
- Managed platforms (Vercel, Netlify, Cloudflare Pages) -- they handle SSL automatically
- Cloudflare-proxied domains where SSL terminates at Cloudflare (orange cloud)
- Kubernetes ingress -- use an ingress controller instead
- The user only needs a self-signed cert for local development
Prerequisites
- SSH access to a VPS running Ubuntu 20.04+ or Debian 11+
- A domain with DNS A record pointing to the VPS IP
- An application running on a local port (e.g., 3000, 8000, 8080)
- Ports 80 and 443 open in the firewall
Phase 1: Gather Information
Collect these before doing anything. Detect what you can, ask for the rest.
| Parameter | How to detect | Fallback |
|-----------|--------------|----------|
| Domain | Ask the user | Required |
| App port | Check Dockerfile EXPOSE, docker-compose ports, or running processes | Ask |
| VPS IP | curl -4 ifconfig.me on the server | Ask |
| Email | Needed for Let's Encrypt registration | Ask |
| Wildcard? | If user mentions subdomains or *.domain.com | Default: single domain |
| Extra domains | www.domain.com, other subdomains | Ask if relevant |
Phase 2: Verify DNS Before Anything Else
CRITICAL: Always verify DNS first. Certbot will fail if the domain does not resolve to this server.
# Get the server's public IP
SERVER_IP=$(curl -4 -s ifconfig.me)
# Check what the domain resolves to
DOMAIN_IP=$(dig +short <DOMAIN> A | head -1)
echo "Server IP: $SERVER_IP"
echo "Domain IP: $DOMAIN_IP"
if [ "$SERVER_IP" = "$DOMAIN_IP" ]; then
echo "DNS is correctly pointed."
else
echo "ERROR: Domain does not point to this server."
echo "Set an A record for <DOMAIN> -> $SERVER_IP at your DNS provider."
echo "DNS propagation can take up to 48 hours (usually 5-15 minutes)."
exit 1
fi
If DNS is not pointed, stop here. Tell the user exactly what A record to create and where. Do not proceed to Certbot -- it will fail and may trigger rate limits.
For wildcard certs, also verify the base domain resolves:
dig +short <BASE_DOMAIN> A
Phase 3: Install Dependencies
apt update
apt install -y nginx certbot python3-certbot-nginx
Verify Nginx is running:
systemctl enable nginx
systemctl start nginx
nginx -v
Phase 4: Configure Nginx Reverse Proxy
4a: Create the site configuration
SAFETY: Always back up existing configs before overwriting.
# Back up any existing config
[ -f /etc/nginx/sites-available/<DOMAIN> ] && cp /etc/nginx/sites-available/<DOMAIN> /etc/nginx/sites-available/<DOMAIN>.bak.$(date +%s)
Write the initial HTTP-only config (Certbot will upgrade it to HTTPS):
# /etc/nginx/sites-available/<DOMAIN>
server {
listen 80;
listen [::]:80;
server_name <DOMAIN> <EXTRA_DOMAINS>;
location / {
proxy_pass http://127.0.0.1:<APP_PORT>;
proxy_http_version 1.1;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Preserve client information
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 X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 86400s; # Long timeout for WebSockets
# Buffering
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_cache_bypass $http_upgrade;
}
}
4b: Enable the site
ln -sf /etc/nginx/sites-available/<DOMAIN> /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
4c: Test and reload
SAFETY: ALWAYS run nginx -t before reloading. Never skip this.
nginx -t
if [ $? -eq 0 ]; then
systemctl reload nginx
echo "Nginx reloaded successfully."
else
echo "ERROR: Nginx config test failed. Fix the config before proceeding."
exit 1
fi
4d: Verify HTTP is working
curl -sI http://<DOMAIN> | head -10
If this returns a non-200 response or connection refused, debug before proceeding to SSL. Common issues:
- App not running on the expected port:
ss -tlnp | grep <APP_PORT> - Firewall blocking port 80:
ufw status - Nginx not running:
systemctl status nginx
Phase 5: SSL Certificate via Let's Encrypt
5a: Single Domain Certificate (default)
certbot --nginx \
-d <DOMAIN> \
-d www.<DOMAIN> \
--non-interactive \
--agree-tos \
-m <EMAIL> \
--redirect
The --redirect flag automatically adds HTTP-to-HTTPS redirection.
5b: Wildcard Certificate (subdomains)
Wildcard certs require DNS-01 challenge. This requires manual DNS TXT record creation (or a DNS plugin for automated providers).
Manual method:
certbot certonly \
--manual \
--preferred-challenges dns \
-d <BASE_DOMAIN> \
-d *.<BASE_DOMAIN> \
--agree-tos \
-m <EMAIL>
Certbot will prompt to create a TXT record at _acme-challenge.<BASE_DOMAIN>. Tell the user to:
- Add the TXT record at their DNS provider
- Wait for propagation:
dig TXT _acme-challenge.<BASE_DOMAIN> - Press Enter in Certbot once the record propagates
Automated method (Cloudflare DNS plugin example):
apt install -y python3-certbot-dns-cloudflare
# Create credentials file
cat > /etc/letsencrypt/cloudflare.ini << 'EOF'
dns_cloudflare_api_token = <CLOUDFLARE_API_TOKEN>
EOF
chmod 600 /etc/letsencrypt/cloudflare.ini
certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d <BASE_DOMAIN> \
-d *.<BASE_DOMAIN> \
--agree-tos \
-m <EMAIL>
Other DNS plugins: python3-certbot-dns-digitalocean, python3-certbot-dns-route53, python3-certbot-dns-google.
After obtaining a wildcard cert, you must manually configure Nginx to use it (Certbot's --nginx plugin does not handle wildcard certs):
ssl_certificate /etc/letsencrypt/live/<BASE_DOMAIN>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<BASE_DOMAIN>/privkey.pem;
5c: Verify SSL
# Check certificate details
echo | openssl s_client -servername <DOMAIN> -connect <DOMAIN>:443 2>/dev/null | openssl x509 -noout -dates -subject
# Check HTTPS response
curl -sI https://<DOMAIN> | head -10
# Check HTTP redirects to HTTPS
curl -sI http://<DOMAIN> | grep -i location
Phase 6: Harden the Nginx Configuration
After Certbot has modified the config, enhance it with security headers, TLS hardening, HTTP/2, and gzip.
Replace the Certbot-generated server block with this production config:
# /etc/nginx/sites-available/<DOMAIN>
# HTTP -> HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name <DOMAIN> <EXTRA_DOMAINS>;
return 301 https://$host$request_uri;
}
# HTTPS server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name <DOMAIN> <EXTRA_DOMAINS>;
# --- SSL/TLS Configuration ---
ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
# TLS 1.2 + 1.3 only (no legacy protocols)
ssl_protocols TLSv1.2 TLSv1.3;
# Strong ciphers -- TLS 1.3 ciphers are managed by OpenSSL automatically
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';
ssl_prefer_server_ciphers off; # Let client choose (TLS 1.3 best practice)
# Session caching for performance
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# NOTE: OCSP stapling is NOT configured because Let's Encrypt
# discontinued OCSP support in 2025. Their certificates no longer
# include OCSP URLs. The ssl_stapling directives would have no effect.
# --- Security Headers ---
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header X-XSS-Protection "0" always; # Disabled per modern best practice (CSP replaces it)
# Content Security Policy -- CUSTOMIZE per application
# Start restrictive, loosen as needed. This is a reasonable default.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# --- Gzip Compression ---
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml
application/xml+rss
application/xhtml+xml
application/atom+xml
image/svg+xml
font/woff2;
# --- Reverse Proxy ---
location / {
proxy_pass http://127.0.0.1:<APP_PORT>;
proxy_http_version 1.1;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Preserve client information
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 X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 86400s;
# Buffering
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_cache_bypass $http_upgrade;
}
# Block dotfiles (except .well-known for ACME challenges)
location ~ /\.(?!well-known) {
deny all;
}
# Favicon and robots.txt -- suppress logging
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
}
After writing the hardened config:
nginx -t && systemctl reload nginx
Phase 7: Auto-Renewal
Certbot installs a systemd timer or cron job automatically. Verify it exists and add a deploy hook to reload Nginx after renewal:
# Check the renewal timer
systemctl list-timers | grep certbot
# Add deploy hook so Nginx picks up new certs
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
#!/bin/bash
nginx -t && systemctl reload nginx
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
# Test renewal (dry run)
certbot renew --dry-run
If the timer does not exist, create a cron job:
echo "0 3 * * * root certbot renew --quiet --deploy-hook 'nginx -t && systemctl reload nginx'" > /etc/cron.d/certbot-renew
Phase 8: Verification Checklist
Run all of these and report results to the user:
echo "=== SSL Certificate ==="
echo | openssl s_client -servername <DOMAIN> -connect <DOMAIN>:443 2>/dev/null | openssl x509 -noout -dates -subject
echo ""
echo "=== HTTPS Response ==="
curl -sI https://<DOMAIN> | head -15
echo ""
echo "=== HTTP Redirect ==="
curl -sI http://<DOMAIN> | grep -i "location\|HTTP"
echo ""
echo "=== Security Headers ==="
curl -sI https://<DOMAIN> | grep -iE "strict-transport|x-frame|x-content-type|referrer-policy|permissions-policy|content-security"
echo ""
echo "=== TLS Version ==="
echo | openssl s_client -servername <DOMAIN> -connect <DOMAIN>:443 2>/dev/null | grep "Protocol"
echo ""
echo "=== Nginx Status ==="
systemctl is-active nginx
echo ""
echo "=== Certbot Renewal Timer ==="
systemctl list-timers | grep certbot
Report to the user:
- Whether HTTPS is working with the certificate details
- Whether HTTP correctly redirects to HTTPS
- Which security headers are present
- TLS version in use
- Whether auto-renewal is configured
- The live URL
Safety Rules
- ALWAYS verify DNS points to the server before running Certbot. Failed attempts count against Let's Encrypt rate limits (5 failures per hostname per hour).
- ALWAYS run
nginx -tbeforesystemctl reload nginx. A bad config takes down all sites on the server. - ALWAYS back up existing Nginx configs before overwriting them.
- NEVER delete
/etc/letsencrypt/contents. Certificates, keys, and renewal configs live there. - NEVER use
systemctl restart nginxwhenreloadwill do. Restart causes downtime; reload is zero-downtime. - NEVER set HSTS preload without explicit user consent. Preload is irreversible -- once submitted to the HSTS preload list, the domain must serve HTTPS forever.
- ALWAYS check if port 80/443 are free before installing Nginx:
ss -tlnp | grep -E ':80|:443'. Apache or another Nginx instance may already be bound. - ALWAYS verify the app is actually running on the expected port before configuring the proxy:
curl -s http://127.0.0.1:<APP_PORT>/. - CSP headers will break apps if too restrictive. Start with a permissive policy and tighten. When in doubt, set CSP in report-only mode first:
Content-Security-Policy-Report-Only. - If anything fails, stop and diagnose. Do not chain workarounds.
Troubleshooting
DNS not pointing to server
dig +short <DOMAIN> A
# Compare with: curl -4 -s ifconfig.me
Fix: Add/update the A record at the DNS provider. Wait for propagation (check with dig).
Port conflict on 80 or 443
ss -tlnp | grep -E ':80|:443'
# or
lsof -i :80
Fix: Stop the conflicting service (systemctl stop apache2) or change its port.
Certbot fails with "too many failed authorizations"
Let's Encrypt rate limits: 5 failed validations per hostname per hour. Fix: Wait an hour. Verify DNS is correct before retrying.
Certbot fails with "connection refused" or "timeout"
Fix: Ensure port 80 is open in firewall (ufw allow 80/tcp) and Nginx is running on port 80.
Certificate renewal fails
certbot renew --dry-run
journalctl -u certbot.service --no-pager -n 50
Fix: Usually DNS changed, port 80 is blocked, or Nginx is down. Check all three.
502 Bad Gateway
The app is not responding on the proxy_pass port.
curl -s http://127.0.0.1:<APP_PORT>/
systemctl status <APP_SERVICE>
docker ps # if dockerized
docker logs <CONTAINER>
Fix: Start or restart the app. Verify the port matches the Nginx config.
504 Gateway Timeout
The app is too slow to respond.
Fix: Increase proxy_read_timeout in the Nginx config, or investigate app performance.
Mixed content warnings in browser
The app generates HTTP URLs instead of HTTPS.
Fix: Ensure the app reads X-Forwarded-Proto header and generates HTTPS URLs. Many frameworks have a "trust proxy" setting (Express: app.set('trust proxy', true)).
Security headers breaking the app
Overly restrictive CSP or X-Frame-Options can break embedded content, iframes, or third-party scripts. Fix: Temporarily switch CSP to report-only mode, check the browser console for violations, and adjust the policy:
add_header Content-Security-Policy-Report-Only "..." always;
Wildcard cert renewal fails (manual DNS challenge)
Manual DNS challenges cannot auto-renew. You must use a DNS plugin for automation.
Fix: Install the appropriate DNS plugin (python3-certbot-dns-cloudflare, etc.) and reconfigure with certbot certonly --dns-<provider>.
Multiple Domains on One Server
To add another domain to the same server, repeat Phases 2-7 for the new domain. Each domain gets its own file in /etc/nginx/sites-available/. Certbot manages certificates independently per domain.
# List all active sites
ls -la /etc/nginx/sites-enabled/
# List all certificates
certbot certificates
CSP Reference (Customize Per App)
The default CSP in this skill is restrictive. Common adjustments:
| App type | CSP additions needed |
|----------|---------------------|
| Next.js | script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; |
| React SPA | script-src 'self' 'unsafe-inline'; |
| WordPress | script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; |
| Uses Google Fonts | font-src 'self' fonts.gstatic.com; style-src 'self' 'unsafe-inline' fonts.googleapis.com; |
| Uses analytics | script-src 'self' www.googletagmanager.com; connect-src 'self' www.google-analytics.com; |
| Embeds YouTube | frame-src 'self' www.youtube.com; |
When unsure, deploy with Content-Security-Policy-Report-Only first and monitor the browser console.
微信扫一扫