HTTPS — Locking the Front Door¶
Our DNS points example.com at 203.0.113.10, and our
Nginx reverse proxy routes requests to the right container.
But so far everything travels as plaintext HTTP. Anyone between the visitor
and our server — a café Wi-Fi router, an internet provider, an attacker on the
same network — can read and even modify every request and response. This
chapter fixes that with HTTPS, taught from first principles, and then shows
two concrete ways to enable it on our stack.
1. HTTP vs HTTPS — what’s exposed¶
HTTP (HyperText Transfer Protocol) is how browsers and servers exchange web pages. By itself it is unencrypted: requests and responses travel as readable text. Over plain HTTP, anyone on the network path can see:
the full URL path and query string (e.g.
/api/visits?user=42),all headers, including cookies and authentication tokens,
the entire request and response body — form fields, passwords, JSON,
and they can tamper with any of it in transit (injecting ads, malware, or altering data) without either side noticing.
HTTPS (HTTP Secure) is the same HTTP, but wrapped in an encrypted tunnel provided by TLS (below). With HTTPS, an eavesdropper sees only the destination domain and IP, plus a stream of encrypted bytes. The path, headers, cookies, and bodies are confidential and protected against tampering.
💡 Intuition: HTTP is a postcard — anyone handling it can read it. HTTPS is a sealed, tamper-evident envelope that only the intended recipient can open.
2. SSL vs TLS — a quick history¶
You’ll hear “SSL” and “TLS” used interchangeably. Here’s the truth:
SSL (Secure Sockets Layer) was the original encryption protocol from the 1990s. All its versions (SSL 2.0, 3.0) are now broken and deprecated — do not use them.
TLS (Transport Layer Security) is SSL’s successor and the protocol actually in use today (TLS 1.2 and TLS 1.3 are current).
Colloquially, people still say “SSL” when they mean TLS — “SSL
certificate,” “SSL termination.” Tools keep the old name too (the Nginx directive
is ssl_certificate, the library is OpenSSL). Just know that under the hood it’s
TLS, and aim for TLS 1.2+.
3. Certificates and the chain of trust¶
For HTTPS to work, the server must prove it really is example.com — otherwise
an attacker could encrypt traffic to themselves and impersonate the site. That
proof is a TLS certificate.
💡 Intuition: A TLS certificate is a tamper-proof ID card issued by a trusted authority. Just as you trust a passport because a government you recognize issued it, your browser trusts a certificate because a Certificate Authority it recognizes signed it.
Public and private keys¶
TLS is built on asymmetric (public-key) cryptography, which uses a matched key pair:
A private key the server keeps absolutely secret.
A public key the server can share freely (it’s embedded in the certificate).
Their magic property: data encrypted with one key can only be decrypted with the other. So a browser can encrypt a secret with the server’s public key, and only the holder of the matching private key (the real server) can decrypt it. This is how both sides establish a shared secret without ever transmitting it in the clear.
The chain of trust¶
A certificate is trusted because of a chain of signatures:
flowchart TD
Root[Root CA certificate] -->|signs| Inter[Intermediate CA certificate]
Inter -->|signs| Leaf[Leaf certificate for example.com]
Browser[Browser trust store] -.trusts.-> Root
Root CA (Certificate Authority) — a small set of highly trusted organizations. Their root certificates ship pre-installed in your operating system and browser (“trust store”). Roots are kept offline and precious.
Intermediate CA — roots delegate day-to-day signing to intermediates, so the root key stays safely offline. The intermediate is signed by the root.
Leaf certificate — your actual certificate for
example.com, signed by an intermediate.
When a browser connects, the server presents the leaf + intermediate(s). The
browser walks the chain up to a root it already trusts. If every signature
checks out and the leaf’s name matches example.com, it’s trusted. Break the
chain anywhere and the browser shows a security warning.
The TLS handshake¶
Before any HTTP is sent, the browser and server perform a TLS handshake to verify identity and agree on encryption keys:
sequenceDiagram
participant C as Browser
participant S as Server (example.com)
C->>S: ClientHello (TLS versions, cipher list)
S->>C: ServerHello (chosen cipher) + certificate chain
Note over C: Verify chain to a trusted root,<br/>check name = example.com, check expiry
C->>S: Key exchange (using server's public key)
Note over C,S: Both derive the same shared session key
C->>S: Finished (encrypted)
S->>C: Finished (encrypted)
Note over C,S: Tunnel established — HTTP now flows encrypted
In words: the browser proposes versions and ciphers (ClientHello); the server picks one and sends its certificate chain (ServerHello); the browser verifies the chain, name, and expiry; the two perform a key exchange to derive a shared session key; from then on all HTTP travels encrypted with that symmetric key (fast) while the asymmetric keys (slow) were only used to bootstrap trust.
5. Why HTTPS matters¶
Confidentiality — nobody on the path can read the traffic.
Integrity — traffic can’t be silently modified in transit.
Authenticity — the certificate proves you’re talking to the real
example.com, not an impostor.SEO — search engines rank HTTPS sites higher.
Browser warnings — modern browsers mark plain HTTP pages “Not secure” and block sensitive features. HTTPS is effectively mandatory.
6. Where TLS terminates in our stack¶
A key design decision: TLS terminates at the Nginx reverse proxy. The proxy
holds the certificate and private key, decrypts incoming HTTPS, and then forwards
plain HTTP to the backend and frontend containers over the internal appnet
network.
flowchart LR
U[Browser] -->|HTTPS :443 encrypted| RP[Nginx reverse proxy]
RP -->|HTTP /| FE[frontend container :80]
RP -->|HTTP /api/| BE[backend container :8000]
BE --> DB[(Postgres)]
This is standard and safe: the internal traffic never leaves the host or crosses
the public internet — it stays on Docker’s private appnet. We manage one
certificate, at one place, instead of one per service. The backend still learns
the request was originally HTTPS thanks to the X-Forwarded-Proto header set
by the proxy (see reverse-proxies.md).
7. Path (a) — Certbot with Nginx¶
Certbot is the official Let’s Encrypt client. Run on the Ubuntu host where Nginx lives, it can obtain a certificate and edit the Nginx config for you.
Install and run¶
# Install certbot and its Nginx plugin (Ubuntu 24.04)
sudo apt update
sudo apt install -y certbot python3-certbot-nginx
# Obtain a cert for both names and let certbot edit Nginx automatically
sudo certbot --nginx -d example.com -d www.example.com
Line by line:
apt install certbot python3-certbot-nginxinstalls Certbot plus the plugin that understands and rewrites Nginx config.certbot --nginxruns Certbot in Nginx mode.-d example.com -d www.example.comrequests a single certificate valid for both names. (Both must already resolve to203.0.113.10per dns.md, and port 80 must be reachable so the HTTP-01 challenge succeeds.)
What Certbot changes¶
When it succeeds, Certbot:
Saves the certificate and key under
/etc/letsencrypt/live/example.com/(fullchain.pem= leaf + intermediates,privkey.pem= your private key).Adds a
listen 443 ssl;server block referencing those files.Adds an HTTP→HTTPS redirect so requests on port 80 are sent to 443.
The resulting Nginx TLS config¶
This is the §5.10 default.conf extended for
TLS. The upstream blocks are unchanged; the single server block becomes two —
one redirecting HTTP, one serving HTTPS:
upstream frontend_upstream {
server frontend:80;
}
upstream backend_upstream {
server backend:8000;
}
# Port 80: redirect every HTTP request to HTTPS.
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
# Port 443: the real, encrypted site.
server {
listen 443 ssl;
server_name example.com www.example.com;
# The certificate chain and private key Certbot installed.
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Modern, safe TLS settings.
ssl_protocols TLSv1.2 TLSv1.3;
# Tell browsers to always use HTTPS for the next 6 months (HSTS).
add_header Strict-Transport-Security "max-age=15768000" always;
# API traffic -> FastAPI backend (prefix preserved, no trailing slash).
location /api/ {
proxy_pass http://backend_upstream;
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;
}
# Everything else -> static frontend.
location / {
proxy_pass http://frontend_upstream;
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;
}
}
Explaining the new lines (the location/proxy_* lines are identical to
reverse-proxies.md):
return 301 https://$host$request_uri;— in the port-80 block, every request gets a permanent redirect to the same path onhttps://. Visitors typinghttp://example.comare bounced to the secure version.listen 443 ssl;— accept HTTPS connections on port 443 and enable TLS.ssl_certificate .../fullchain.pem;— the leaf + intermediate chain the server presents during the handshake (§3).ssl_certificate_key .../privkey.pem;— the matching private key. Keep this file readable only by root; never commit it anywhere.ssl_protocols TLSv1.2 TLSv1.3;— allow only modern TLS versions; refuse the broken old ones.add_header Strict-Transport-Security ... always;— HSTS (HTTP Strict Transport Security) tells browsers to use HTTPS for this domain for the given duration, even if a user typeshttp://— closing a downgrade-attack window.server_name example.com www.example.com;— now pinned to our real names (we replaced the catch-all_), so the right certificate is selected.
Auto-renewal¶
Let’s Encrypt certs last 90 days, so renewal must be automatic. The Certbot package installs a systemd timer (or cron job) that runs twice daily and renews anything close to expiry. You can test it manually:
sudo certbot renew --dry-run # simulate renewal without changing anything
systemctl list-timers | grep certbot # confirm the timer is active
certbot renew only acts on certs within ~30 days of expiry and reloads Nginx
when it renews one, so there’s nothing to do day to day once the timer exists.
8. Path (b) — Traefik automatic certificates¶
If you use Traefik instead of Nginx (see reverse-proxies.md), TLS is even simpler: Traefik speaks ACME natively and obtains/renews Let’s Encrypt certificates by itself. You configure an ACME resolver once, then opt each route into it with a label.
services:
traefik:
image: traefik:v3.1
command:
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# Define a Let's Encrypt resolver named "le" using the HTTP-01 challenge
- "--certificatesresolvers.le.acme.httpchallenge=true"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.le.acme.email=admin@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt # persist issued certs across restarts
networks: [appnet]
backend:
image: ghcr.io/chrisw09/example-app-backend:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.backend.rule=Host(`example.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.backend.entrypoints=websecure"
- "traefik.http.routers.backend.tls.certresolver=le" # <- automatic HTTPS
- "traefik.http.services.backend.loadbalancer.server.port=8000"
networks: [appnet]
volumes:
letsencrypt:
networks:
appnet:
The
certificatesresolvers.le.acme...command flags define a resolver namedlethat uses the HTTP-01 challenge on theweb(:80) entrypoint and stores certs inacme.json.The
--...acme.emailis where Let’s Encrypt sends expiry notices.Persisting
/letsencryptin a volume means certs survive restarts (and you don’t re-request them and hit rate limits).On the backend,
tls.certresolver=leis the whole opt-in: Traefik sees the route’sHost(example.com), requests a certificate for it, serves the route over:443, and renews it automatically before expiry.
Just like the Nginx setup, TLS terminates at Traefik and traffic to the
containers stays plain HTTP on appnet.
⚠️ Common mistakes:
Port 443 not open. A firewall or cloud security group blocking 443 means HTTPS connections never arrive. Open both 80 (for the ACME challenge and the redirect) and 443.
Hitting Let’s Encrypt rate limits. Repeatedly re-issuing the same cert during testing trips weekly limits. Use
--dry-run(Certbot) or Let’s Encrypt’s staging environment while experimenting, and persistacme.jsonso Traefik doesn’t keep re-requesting.Mixed content. An HTTPS page that loads scripts/images over
http://is blocked by browsers. Always reference resources withhttps://or protocol-relative/same-origin paths.Forgetting renewal. Without an active timer (Certbot) or persisted
acme.json(Traefik), the cert silently expires after 90 days and every visitor sees a scary warning. Verify renewal works.Wrong DNS before issuance. If
example.comdoesn’t yet resolve to203.0.113.10, the HTTP-01 challenge can’t reach your server and issuance fails. Fix DNS first (see dns.md).
✅ Production best practices:
Automate renewal and verify it (
certbot renew --dry-run, or confirm the Traefik timer/storage).Redirect HTTP → HTTPS so no one is served over plaintext.
Enable HSTS once you’re confident HTTPS is permanent.
Use strong protocols/ciphers — TLS 1.2 and 1.3 only; disable SSL and old TLS versions.
Protect the private key (
privkey.pem): root-only permissions, never in git, never in an image.Cover both names on the cert (
example.comandwww.example.com).
9. Exercises¶
Exercise 1 — Read the chain.
A browser connects to https://example.com. List, in order, what the server sends
and what the browser checks before any HTTP is exchanged. Which key never leaves
the server?
Answer
The server sends a ServerHello with the chosen cipher plus its certificate
chain (leaf + intermediate(s)). The browser verifies the chain up to a trusted
root, checks the leaf’s name matches example.com, and checks it’s not
expired/revoked. They then do a key exchange to derive a shared session key.
The server’s private key never leaves the server — it’s only used to prove
ownership during the handshake.
Exercise 2 — Why X-Forwarded-Proto?
TLS terminates at Nginx, so the backend is reached over plain HTTP. Why must the
proxy still send X-Forwarded-Proto: https, and what breaks if it doesn’t?
Answer
The backend only sees the internal HTTP hop, so without a hint it assumes the
user is on HTTP. X-Forwarded-Proto: https tells it the original request was
HTTPS, so it builds https:// redirects/links and sets Secure cookies.
Without it you get http:// redirects (causing redirect loops once HSTS/redirects
are in play) and insecure cookies — even though the public connection was secure.
Exercise 3 — Renewal gone wrong. A site worked for three months, then suddenly every visitor sees “your connection is not private.” What almost certainly happened, how would you confirm it, and how do you prevent it?
Answer
The 90-day Let’s Encrypt certificate expired because auto-renewal wasn’t
working. Confirm by checking the cert’s expiry (browser cert viewer, or
echo | openssl s_client -connect example.com:443 | openssl x509 -noout -dates).
Fix by renewing now (sudo certbot renew) and ensuring the renewal timer is
active (systemctl list-timers | grep certbot) — or, with Traefik, that
acme.json is persisted in a volume. Prevent recurrence by testing
certbot renew --dry-run.