Level 16 — SSL / HTTPS / Certbot
Encrypting traffic between your users and your server, for free, automatically, forever.
HTTP, HTTPS, SSL, TLS
| Term | Meaning |
|---|---|
| HTTP | The web's request/response protocol. Plain text. |
| SSL | The original encryption layer. Obsolete — all versions are broken. |
| TLS | SSL's successor. TLS 1.2 and 1.3 are current. |
| HTTPS | HTTP carried inside TLS |
| Certificate | A file binding your domain name to a public key, signed by a CA |
| CA | Certificate Authority — an entity browsers trust to make that binding |
The industry says "SSL certificate" out of habit; the protocol in use is always TLS. SSL 2.0, SSL 3.0, TLS 1.0, and TLS 1.1 are all deprecated and disabled in modern browsers.
What TLS provides
| Guarantee | Without HTTPS |
|---|---|
| Encryption | Anyone on the path — ISP, café WiFi, a compromised router — reads passwords and session cookies |
| Integrity | ISPs inject ads; attackers inject scripts into your pages |
| Authentication | A DNS hijack sends your users to a convincing fake, and they cannot tell |
HTTPS IS MANDATORY, NOT OPTIONAL
- Browsers label HTTP pages "Not secure" in the address bar
- Service workers, geolocation, camera/mic, clipboard, and HTTP/2 all require a secure context
Securecookies do not work, so session security is fundamentally weaker- Search engines rank HTTP sites lower
- Any payment integration will reject you
It costs nothing and takes ten minutes.
The TLS handshake
Two points worth understanding:
SNI (Server Name Indication) — the browser sends the requested hostname in the ClientHello, before encryption starts. That is how Nginx knows which certificate to present when several sites share one IP. Without SNI, one IP could serve only one HTTPS site.
Forward secrecy — ECDHE generates a fresh session key per connection. Even if your private key is stolen later, previously recorded traffic cannot be decrypted. This is why modern configurations require ECDHE cipher suites.
Certificates and chains
Root CA (ISRG Root X1) ← preinstalled in every browser/OS
└── Intermediate (R11) ← signed by the root
└── Your certificate (app.example.com) ← signed by the intermediateYour server must send your certificate plus the intermediate — that is what fullchain.pem contains. The root is already on the client.
SENDING ONLY cert.pem BREAKS SOME CLIENTS
Nginx must point at fullchain.pem, not cert.pem. With only the leaf certificate, browsers that happen to have the intermediate cached still work — so it appears fine to you — while curl, mobile apps, and other clients fail with "unable to get local issuer certificate".
This is a classic "works on my machine" TLS bug. Verify with:
openssl s_client -connect app.example.com:443 -servername app.example.com < /dev/null 2>/dev/null | grep -c "^ [0-9] s:"You should see 2 certificates in the chain.
Files Certbot creates in /etc/letsencrypt/live/app.example.com/:
| File | Contents | Used by Nginx |
|---|---|---|
privkey.pem | Private key — never share | ssl_certificate_key |
fullchain.pem | Your cert + intermediate | ssl_certificate |
cert.pem | Your cert only | Rarely |
chain.pem | Intermediate only | OCSP stapling |
Let's Encrypt
A free, automated, non-profit CA that has issued billions of certificates.
| Property | Value |
|---|---|
| Cost | Free |
| Validity | 90 days |
| Renewal | Automated, at 30 days remaining |
| Validation | Domain Control Validation only |
| Wildcards | Yes, via DNS-01 |
| Rate limit | 50 certificates per registered domain per week |
WHY ONLY 90 DAYS?
Short lifetimes limit the damage from a stolen key and force automation. If renewal is automatic, 90 days is no more work than 365. If it is manual, you will forget — which is exactly the failure the design discourages.
The industry is moving further in this direction; certificate lifetimes are scheduled to shorten substantially over the coming years. Automation is not optional.
PAID CERTIFICATES ARE NOT MORE SECURE
A €200 DV certificate provides identical cryptography to a free one. You are paying for a warranty (practically worthless), support, and — with OV/EV — organisation validation that browsers no longer display prominently. For a web application, Let's Encrypt is the correct choice.
The ACME challenges
Let's Encrypt must verify you control the domain.
HTTP-01
| Requirement | Detail |
|---|---|
| Port 80 open | Let's Encrypt connects over plain HTTP. It does not use 443 for this. |
| DNS correct | It resolves your domain and connects to that IP |
| No redirect interference | A blanket return 301 https://... on port 80 will break it unless the challenge path is excluded |
PORT 80 MUST STAY OPEN — FOREVER
People close port 80 "because everything is HTTPS". Then in 60 days renewal fails silently, and 30 days after that the certificate expires and the site shows a full-page browser security warning.
Port 80 serves only a redirect plus the ACME challenge path. Keep it open.
EXCLUDE THE CHALLENGE PATH FROM YOUR HTTPS REDIRECT
server {
listen 80;
server_name app.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot; # ✅ served over HTTP
}
location / {
return 301 https://$host$request_uri;
}
}Location matching means the specific prefix wins over /, so the challenge is served while everything else redirects. Certbot's --nginx plugin inserts this automatically; if you hand-write configs, you must include it.
DNS-01
Prove control by creating a TXT record at _acme-challenge.app.example.com.
| HTTP-01 | DNS-01 | |
|---|---|---|
| Needs port 80 | Yes | No |
| Wildcard certificates | ❌ No | ✅ Yes |
| Works before the server is public | No | Yes |
| Automation | Trivial | Needs a DNS provider API plugin |
Use DNS-01 when you need *.example.com, when port 80 is genuinely unavailable, or when the server sits behind a proxy that intercepts port 80.
# SERVER — Cloudflare example
sudo apt install -y python3-certbot-dns-cloudflare
sudo mkdir -p /root/.secrets
echo "dns_cloudflare_api_token = your-scoped-token" | sudo tee /root/.secrets/cloudflare.ini
sudo chmod 600 /root/.secrets/cloudflare.ini
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
-d example.com -d '*.example.com'USE A SCOPED API TOKEN, NOT A GLOBAL API KEY
Cloudflare's global key can do anything to your account. Create a token limited to Zone → DNS → Edit for the specific zone. If the server is compromised, the attacker can edit DNS for one zone rather than take over your account.
Installing Certbot
# SERVER
sudo apt install -y certbot python3-certbot-nginx
certbot --versionsnap vs apt
Let's Encrypt officially recommends the snap package for the newest features. The apt package on Ubuntu 24.04 is recent enough and avoids adding snapd to a minimal server. Either works; do not install both — two renewal timers fighting over the same certificates causes rate-limit failures.
Obtaining a certificate
Prerequisites, verify all three first:
# LOCAL — DNS resolves to this server
dig app.example.com +short
dig api.example.com +short
# LOCAL — port 80 reachable
curl -I http://app.example.com
# SERVER — Nginx running with a server_name for these domains
sudo nginx -t && sudo systemctl status nginxDO A DRY RUN FIRST
Let's Encrypt rate limits: 5 failed validations per hostname per hour and 50 certificates per registered domain per week. A misconfiguration you retry five times locks you out for an hour, in the middle of a launch.
sudo certbot --nginx -d app.example.com --dry-runThe staging environment has far higher limits. Always dry-run first.
# SERVER
sudo certbot --nginx \
-d app.example.com \
-d api.example.com \
-d example.com \
-d www.example.com \
--email you@example.com \
--agree-tos \
--no-eff-email \
--redirect| Flag | Effect |
|---|---|
--nginx | Use the Nginx plugin — reads server_name, writes the config, reloads |
-d | A domain. Repeat for each; all end up in one certificate. |
--email | Expiry warnings go here. Use a real, monitored address. |
--agree-tos | Accept the subscriber agreement |
--no-eff-email | Skip the EFF mailing list prompt |
--redirect | Add the HTTP → HTTPS redirect automatically |
--dry-run | Test against staging |
certonly | Obtain the certificate but do not touch Nginx config |
THE EMAIL ADDRESS IS YOUR SAFETY NET
Let's Encrypt emails you at 20 days, 10 days, and 1 day before expiry if renewal has not happened. That email is often the only warning that your automation broke. Use an address a human reads — not noreply@, not a personal address you might lose access to.
Certbot modifies your Nginx config in place:
server {
server_name app.example.com;
# ...
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}ONE CERTIFICATE FOR MANY DOMAINS, OR ONE EACH?
Listing several -d flags produces one certificate with multiple SANs. Simpler to manage, but:
- If validation fails for any one domain, the whole renewal fails
- All domains are visible in Certificate Transparency logs and in the certificate itself
Separate certificates per hostname isolate failures. For app and api on the same server, one shared certificate is fine and simpler. Use separate ones if a domain might move to a different server.
Verifying
# SERVER
sudo certbot certificatesCertificate Name: app.example.com
Domains: app.example.com api.example.com example.com www.example.com
Expiry Date: 2026-11-09 14:23:11+00:00 (VALID: 89 days)
Certificate Path: /etc/letsencrypt/live/app.example.com/fullchain.pem
Private Key Path: /etc/letsencrypt/live/app.example.com/privkey.pem# LOCAL
curl -I https://app.example.com
openssl s_client -connect app.example.com:443 -servername app.example.com < /dev/null 2>/dev/null | openssl x509 -noout -dates -subject -issuer
# Check the full chain is served
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null | grep -E "^ [0-9] s:"
# Verify HTTP redirects to HTTPS
curl -I http://app.example.com | grep -i locationTest externally at ssllabs.com/ssltest. Aim for A or A+. Anything below A means a configuration problem worth fixing.
Automatic renewal
The Certbot package installs a systemd timer.
# SERVER
systemctl list-timers | grep certbot
systemctl status certbot.timer
sudo systemctl enable --now certbot.timerNEXT LEFT UNIT ACTIVATES
Mon 2026-08-12 03:41:12 UTC 14h left certbot.timer certbot.serviceIt runs twice daily at randomised times and renews only certificates within 30 days of expiry, so running it often is harmless.
TEST RENEWAL — DO NOT ASSUME IT WORKS
sudo certbot renew --dry-runThis performs a full renewal against the staging server, including the Nginx reload hook. If it succeeds, real renewal will too.
Run this now, and again any time you change Nginx configuration or firewall rules. The most common production TLS incident is a renewal that has been silently failing for two months, discovered when the certificate expires and the site shows a browser security wall.
The reload hook
A renewed certificate does not take effect until Nginx reloads.
# SERVER
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh#!/usr/bin/env bash
set -e
/usr/sbin/nginx -t && /usr/bin/systemctl reload nginxsudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh| Hook directory | Runs |
|---|---|
renewal-hooks/pre/ | Before each renewal attempt |
renewal-hooks/deploy/ | Only when a certificate was actually renewed |
renewal-hooks/post/ | After each attempt, renewed or not |
Use deploy/ — it avoids reloading Nginx twice a day for nothing.
THE --nginx PLUGIN USUALLY HANDLES THIS
If you used --nginx, the renewal config already contains renew_hook. Check:
sudo cat /etc/letsencrypt/renewal/app.example.com.confAdding a duplicate hook is harmless (a second reload), but a missing one means renewed certificates are never served — Nginx keeps the old one in memory until something else reloads it. Symptom: certbot certificates shows 89 days but browsers see an expired certificate.
Monitoring expiry independently
Do not rely solely on the timer working.
# SERVER — /home/deploy/scripts/check-cert.sh
#!/usr/bin/env bash
DOMAIN="app.example.com"
DAYS=$(( ( $(date -d "$(openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} < /dev/null 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)" +%s) - $(date +%s) ) / 86400 ))
echo "${DOMAIN}: ${DAYS} days remaining"
if [ "$DAYS" -lt 20 ]; then
echo "WARNING: certificate for ${DOMAIN} expires in ${DAYS} days" | \
mail -s "TLS expiry warning: ${DOMAIN}" you@example.com
fi# SERVER
chmod +x /home/deploy/scripts/check-cert.sh
crontab -e
# 0 9 * * * /home/deploy/scripts/check-cert.shOr use an external monitor (UptimeRobot, Better Stack) which also alerts if the whole server is down — something a cron job on that server cannot do.
Hardening TLS
Certbot's options-ssl-nginx.conf is a reasonable baseline. To go further:
# SERVER
sudo nano /etc/nginx/snippets/ssl-hardening.confssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
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_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/app.example.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;| Directive | Why |
|---|---|
ssl_protocols TLSv1.2 TLSv1.3 | TLS 1.0/1.1 are deprecated and fail compliance scans |
ssl_prefer_server_ciphers off | Modern guidance: let the client choose, since clients know their own hardware acceleration |
ssl_session_tickets off | Session tickets can undermine forward secrecy if the ticket key is not rotated |
ssl_stapling on | Server fetches the OCSP response, so clients need not contact the CA — faster and more private |
resolver | Required for OCSP stapling to resolve the CA's hostname |
DROPPING TLS 1.0/1.1 EXCLUDES VERY OLD CLIENTS
Android < 5, IE on Windows XP, and some old embedded devices. In 2026 this is a negligible share of web traffic, and PCI-DSS forbids TLS 1.0/1.1 anyway. Keep them disabled.
HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;Tells browsers to use HTTPS for this domain for the next year, without even trying HTTP.
HSTS CANNOT BE UNDONE QUICKLY
Once a browser has cached the header, it refuses HTTP for max-age seconds. If your certificate later breaks, users see an error page with no way to proceed, and removing the header does not help — the instruction is already cached.
Roll it out gradually:
max-age=300(5 minutes) — verify everything worksmax-age=86400(1 day) after a few daysmax-age=31536000after a week of stability
includeSubDomains applies to every subdomain — make sure every one has valid HTTPS first. Do not add preload unless you are certain; removal from the browser preload list takes months.
Troubleshooting
"Challenge failed" / "Invalid response"
# LOCAL — does DNS point here?
dig app.example.com +short
# LOCAL — is port 80 reachable?
curl -I http://app.example.com
# SERVER — can the challenge path be served?
sudo mkdir -p /var/www/certbot/.well-known/acme-challenge
echo "test" | sudo tee /var/www/certbot/.well-known/acme-challenge/test
curl http://app.example.com/.well-known/acme-challenge/testIf the last command does not print test, your Nginx config is intercepting the path — usually a blanket HTTPS redirect.
"Certificate not found" in Nginx
sudo ls -la /etc/letsencrypt/live/app.example.com/
sudo nginx -tThe path in ssl_certificate must match exactly. Certbot sometimes appends a suffix (app.example.com-0001) when a certificate with that name already exists — a very common cause of this error after re-running Certbot with different domains.
"Too many failed authorizations"
You hit the rate limit: 5 failures per hostname per hour. Wait an hour. Use --dry-run while debugging.
Certificate expired despite the timer
# SERVER
systemctl status certbot.timer
sudo journalctl -u certbot --since "60 days ago" | grep -Ei "error|fail"
sudo certbot renew --dry-runCommon causes: port 80 was closed after the initial issuance; DNS changed; the Nginx config was rewritten and lost the challenge location; the reload hook is missing so Nginx serves a stale certificate from memory.
ERR_SSL_PROTOCOL_ERROR in the browser
sudo nginx -t
sudo ss -tulpn | grep 443
openssl s_client -connect app.example.com:443 -servername app.example.comUsually: Nginx is not actually listening on 443, listen 443 is missing the ssl parameter, or the certificate and key do not match:
# These two hashes must be identical
sudo openssl x509 -noout -modulus -in /etc/letsencrypt/live/app.example.com/fullchain.pem | openssl md5
sudo openssl rsa -noout -modulus -in /etc/letsencrypt/live/app.example.com/privkey.pem | openssl md5Redirect loop
Almost always Cloudflare SSL mode set to Flexible (Level 15): Cloudflare sends HTTP to your origin, Nginx redirects to HTTPS, Cloudflare sends HTTP again, forever. Set it to Full (Strict).
The other cause: your app also forces HTTPS but does not see X-Forwarded-Proto. Set trust proxy in NestJS (Level 12).
Mixed content warnings
The page loaded over HTTPS but references http:// resources. Browsers block them.
curl -s https://app.example.com | grep -o 'http://[^"'\'']*' | sort -u | headFix in the application — use protocol-relative or absolute HTTPS URLs. Make sure NUXT_PUBLIC_API_BASE uses https://.
Renewal timeline
You have a 30-day window in which renewal can retry twice daily. Even a week of failures is recoverable if you notice. Monitoring is what turns a 30-day buffer into actual safety.
Production Checklist — Level 16
- [ ] DNS verified with
digbefore running Certbot - [ ]
--dry-runused first - [ ] Certificate obtained for all domains (
app,api, apex,www) - [ ] Nginx points at
fullchain.pem, notcert.pem - [ ] Chain verified — 2 certificates served
- [ ] HTTP redirects to HTTPS, except
/.well-known/acme-challenge/ - [ ] Port 80 remains open permanently
- [ ]
certbot.timerenabled and active - [ ]
sudo certbot renew --dry-runpasses - [ ] Deploy hook reloads Nginx after renewal, and is verified present
- [ ] A real, monitored email address registered with Let's Encrypt
- [ ] Independent expiry monitoring (cron script or external monitor)
- [ ] TLS 1.2 + 1.3 only; TLS 1.0/1.1 disabled
- [ ] OCSP stapling enabled with a
resolverconfigured - [ ] SSL Labs score of A or better
- [ ] HSTS rolled out gradually, starting low
- [ ] CAA record allows
letsencrypt.org(Level 15) - [ ] No mixed content warnings
- [ ] Cloudflare (if used) set to Full (Strict)
- [ ]
/etc/letsencryptincluded in backups (Level 22)