Skip to content

Level 16 — SSL / HTTPS / Certbot ​

Encrypting traffic between your users and your server, for free, automatically, forever.

HTTP, HTTPS, SSL, TLS ​

TermMeaning
HTTPThe web's request/response protocol. Plain text.
SSLThe original encryption layer. Obsolete — all versions are broken.
TLSSSL's successor. TLS 1.2 and 1.3 are current.
HTTPSHTTP carried inside TLS
CertificateA file binding your domain name to a public key, signed by a CA
CACertificate 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 ​

GuaranteeWithout HTTPS
EncryptionAnyone on the path — ISP, café WiFi, a compromised router — reads passwords and session cookies
IntegrityISPs inject ads; attackers inject scripts into your pages
AuthenticationA 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
  • Secure cookies 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 intermediate

Your 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:

bash
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/:

FileContentsUsed by Nginx
privkey.pemPrivate key — never sharessl_certificate_key
fullchain.pemYour cert + intermediatessl_certificate
cert.pemYour cert onlyRarely
chain.pemIntermediate onlyOCSP stapling

Let's Encrypt ​

A free, automated, non-profit CA that has issued billions of certificates.

PropertyValue
CostFree
Validity90 days
RenewalAutomated, at 30 days remaining
ValidationDomain Control Validation only
WildcardsYes, via DNS-01
Rate limit50 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 ​

RequirementDetail
Port 80 openLet's Encrypt connects over plain HTTP. It does not use 443 for this.
DNS correctIt resolves your domain and connects to that IP
No redirect interferenceA 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

nginx
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-01DNS-01
Needs port 80YesNo
Wildcard certificates❌ No✅ Yes
Works before the server is publicNoYes
AutomationTrivialNeeds 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.

bash
# 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 ​

bash
# SERVER
sudo apt install -y certbot python3-certbot-nginx
certbot --version

snap 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:

bash
# 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 nginx

DO 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.

bash
sudo certbot --nginx -d app.example.com --dry-run

The staging environment has far higher limits. Always dry-run first.

bash
# 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
FlagEffect
--nginxUse the Nginx plugin — reads server_name, writes the config, reloads
-dA domain. Repeat for each; all end up in one certificate.
--emailExpiry warnings go here. Use a real, monitored address.
--agree-tosAccept the subscriber agreement
--no-eff-emailSkip the EFF mailing list prompt
--redirectAdd the HTTP → HTTPS redirect automatically
--dry-runTest against staging
certonlyObtain 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:

nginx
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 ​

bash
# SERVER
sudo certbot certificates
Certificate 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
bash
# 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 location

Test 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.

bash
# SERVER
systemctl list-timers | grep certbot
systemctl status certbot.timer
sudo systemctl enable --now certbot.timer
NEXT                        LEFT     UNIT           ACTIVATES
Mon 2026-08-12 03:41:12 UTC 14h left certbot.timer  certbot.service

It 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

bash
sudo certbot renew --dry-run

This 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.

bash
# SERVER
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
bash
#!/usr/bin/env bash
set -e
/usr/sbin/nginx -t && /usr/bin/systemctl reload nginx
bash
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
Hook directoryRuns
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:

bash
sudo cat /etc/letsencrypt/renewal/app.example.com.conf

Adding 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.

bash
# 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
bash
# SERVER
chmod +x /home/deploy/scripts/check-cert.sh
crontab -e
# 0 9 * * * /home/deploy/scripts/check-cert.sh

Or 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:

bash
# SERVER
sudo nano /etc/nginx/snippets/ssl-hardening.conf
nginx
ssl_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;
DirectiveWhy
ssl_protocols TLSv1.2 TLSv1.3TLS 1.0/1.1 are deprecated and fail compliance scans
ssl_prefer_server_ciphers offModern guidance: let the client choose, since clients know their own hardware acceleration
ssl_session_tickets offSession tickets can undermine forward secrecy if the ticket key is not rotated
ssl_stapling onServer fetches the OCSP response, so clients need not contact the CA — faster and more private
resolverRequired 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 ​

nginx
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:

  1. max-age=300 (5 minutes) — verify everything works
  2. max-age=86400 (1 day) after a few days
  3. max-age=31536000 after 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" ​

bash
# 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/test

If the last command does not print test, your Nginx config is intercepting the path — usually a blanket HTTPS redirect.

"Certificate not found" in Nginx ​

bash
sudo ls -la /etc/letsencrypt/live/app.example.com/
sudo nginx -t

The 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 ​

bash
# SERVER
systemctl status certbot.timer
sudo journalctl -u certbot --since "60 days ago" | grep -Ei "error|fail"
sudo certbot renew --dry-run

Common 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 ​

bash
sudo nginx -t
sudo ss -tulpn | grep 443
openssl s_client -connect app.example.com:443 -servername app.example.com

Usually: Nginx is not actually listening on 443, listen 443 is missing the ssl parameter, or the certificate and key do not match:

bash
# 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 md5

Redirect 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.

bash
curl -s https://app.example.com | grep -o 'http://[^"'\'']*' | sort -u | head

Fix 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 dig before running Certbot
  • [ ] --dry-run used first
  • [ ] Certificate obtained for all domains (app, api, apex, www)
  • [ ] Nginx points at fullchain.pem, not cert.pem
  • [ ] Chain verified — 2 certificates served
  • [ ] HTTP redirects to HTTPS, except /.well-known/acme-challenge/
  • [ ] Port 80 remains open permanently
  • [ ] certbot.timer enabled and active
  • [ ] sudo certbot renew --dry-run passes
  • [ ] 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 resolver configured
  • [ ] 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/letsencrypt included in backups (Level 22)

Next: Level 17 — CI/CD Concepts →