02 · TLS/SSL Certificates (Let's Encrypt / certbot)¶
TLS is what turns http:// into https://: it encrypts traffic between
client and server and proves (via a certificate signed by a trusted CA)
that the server is who it claims to be. Let's Encrypt provides free,
short-lived (90-day) certificates issued automatically via the ACME
protocol, and certbot is the standard client for getting and renewing
them.
Prerequisites¶
- A domain name with an A/AAAA record pointing at the server's public IP.
- Port 80 (and ideally 443) reachable from the internet — Let's Encrypt's HTTP-01 challenge fetches a token over plain HTTP to prove you control the domain.
- nginx already serving that
server_name(see module 1).
Installing certbot and getting a cert (nginx plugin)¶
sudo apt update
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
The nginx plugin does three things for you: proves domain ownership by
temporarily serving the ACME challenge through your existing nginx vhost,
downloads the certificate to /etc/letsencrypt/live/example.com/, and
edits your nginx config in place to add a listen 443 ssl block plus an
HTTP→HTTPS redirect. Always run sudo nginx -t and read the diff of what it
changed if you want to know exactly what happened:
Resulting shape (roughly what certbot writes):
server {
listen 443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri; # certbot adds this redirect
}
fullchain.pem contains your certificate plus the intermediate CA
certificate (clients need the whole chain to verify trust); privkey.pem is
the private key — never copy it off the box or commit it anywhere.
Auto-renewal¶
Let's Encrypt certs expire every 90 days by design (to force automation and limit blast radius of a leaked key). The certbot package installs a systemd timer that handles this without you doing anything:
systemctl list-timers | grep certbot
sudo certbot renew --dry-run # simulate a renewal without changing anything
renew only actually replaces certs that are within 30 days of expiry, so
running it manually anytime is safe — it's a no-op for certs that aren't
close to expiring. If nginx's config changed after the cert was issued
(e.g. you added a new server block), certbot's renewal hook reloads nginx
automatically so the new cert is picked up without manual intervention.
Checking a cert from the outside¶
curl -vI https://example.com 2>&1 | grep -A2 "expire date\|subject:"
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates -subject -issuer
-servername matters for SNI (Server Name Indication) — without it, a
server hosting multiple TLS vhosts on one IP may return the wrong
certificate (usually a default/self-signed one), and you'll be debugging
the wrong problem.
Strong TLS defaults¶
certbot's options-ssl-nginx.conf already sets sane modern defaults, but
know what's in there:
ssl_protocols TLSv1.2 TLSv1.3; # never allow TLSv1.0/1.1 or SSLv3
ssl_prefer_server_ciphers off; # let the (modern) client pick, per current best practice
ssl_session_cache shared:le_nginx_SSL:10m;
Add HSTS once you're confident every subdomain is served over HTTPS (it tells browsers to never attempt plain HTTP for this domain again, so adding it before HTTPS is fully working everywhere can lock you out):
Worked example: cert issuance and renewal dry run¶
sudo certbot --nginx -d demo.example.com --non-interactive --agree-tos -m ops@example.com
sudo certbot certificates # lists all managed certs + expiry
sudo certbot renew --dry-run --cert-name demo.example.com
sudo nginx -t && sudo systemctl reload nginx
--non-interactive --agree-tos -m <email> is the scripted/unattended form
you'd use from a provisioning script rather than answering prompts by hand.
How It Actually Works¶
The TLS handshake: asymmetric setup, symmetric bulk transfer. A TLS
connection begins with the client and server negotiating a cipher suite and
protocol version (ClientHello/ServerHello), after which the server
presents its certificate chain. In TLS 1.3, key exchange happens via
ephemeral Diffie-Hellman (each side sending a public key share derived from
a random private value discarded after the handshake) — the two sides derive
a shared secret without transmitting it, and that secret seeds symmetric
session keys used for the rest of the connection. Ephemeral (rather than
static) key exchange is what gives TLS "forward secrecy": recording
encrypted traffic today and later stealing the server's private key doesn't
let an attacker decrypt it, because that private key was only ever used to
sign the ephemeral exchange, not to derive the session key directly.
Certificate chain validation — why a chain, not just a certificate. A
leaf certificate is signed by an intermediate CA's private key; the browser
doesn't trust the intermediate directly — it trusts a small set of root CAs
baked into the OS/browser trust store. Validation walks the chain
leaf → intermediate → root, checking at each link that the signature
verifies against the next certificate's public key, until it reaches a root
already in the trust store. nginx's ssl_certificate directive must serve
the full chain (leaf + intermediates) because the server has no way of
knowing which intermediates a given client already has cached — omitting
them is the actual cause of "works in Chrome, fails in curl/old Android"
errors, since some clients bundle common intermediates and others don't.
How Let's Encrypt's ACME HTTP-01 challenge proves domain ownership. The
CA asks your ACME client to place a specific token at
http://yourdomain/.well-known/acme-challenge/<token>, then fetches that
URL itself over plain HTTP from the public internet. Successfully retrieving
the expected token is treated as proof of control over the domain (only
someone controlling the server answering on port 80 for that hostname could
place it there) — this is why the challenge fails behind a firewall blocking
port 80, or when DNS doesn't yet point at the right server.
Exercise¶
- Point a real (or test) domain's A record at a server you control, issue a
Let's Encrypt cert with
certbot --nginx, and verify HTTPS works withcurl -vI https://yourdomain. - Confirm the HTTP→HTTPS redirect certbot added actually returns
301(curl -I http://yourdomain). - Run
certbot renew --dry-runand read its output to confirm it identifies your cert and simulates a successful renewal without changing the on-disk cert files (ls -la /etc/letsencrypt/live/yourdomain/timestamps unchanged).