# TLS / SSL Configuration MuseHub uses HTTPS everywhere. The mechanism differs by environment: | Environment | Who terminates TLS | Certificate type | |-------------|-------------------|-----------------| | Local dev | uvicorn (direct) | mkcert — locally-trusted CA | | Staging / Prod | nginx on EC2 | Cloudflare Origin Certificate | --- ## Local Development ### How it works In local dev, uvicorn serves HTTPS directly — there is no nginx in the docker-compose stack. The entrypoint ([entrypoint.sh](../entrypoint.sh)) detects a cert at `/tls/localhost.crt` and starts uvicorn with `--ssl-certfile` / `--ssl-keyfile`. The cert is bind-mounted from `~/.config/musehub/local-tls/` on the host (see [docker-compose.yml](../docker-compose.yml), `volumes: - ${HOME}/.config/musehub/local-tls:/tls:ro`). The certs live **outside the repo** deliberately — repo operations (checkouts, cleans, directory recreations) cannot delete them. ### First-time setup Use [mkcert](https://github.com/FiloSottile/mkcert). It creates a local certificate authority (CA), installs it into macOS Keychain (and Firefox's trust store), and generates certs signed by that CA. Browsers trust the CA permanently, so you never see certificate warnings for any cert it signs. ```bash # Install mkcert brew install mkcert # Install the local CA into macOS Keychain (and Firefox if installed) mkcert -install # Create the cert directory and generate cert + key for localhost mkdir -p ~/.config/musehub/local-tls cd ~/.config/musehub/local-tls mkcert localhost 127.0.0.1 ::1 # Rename to match the names entrypoint.sh expects mv localhost+2.pem localhost.crt mv localhost+2-key.pem localhost.key ``` Then restart the container: ```bash docker compose restart musehub ``` Verify: ```bash curl https://localhost:1337/healthz # → {"status":"ok"} ``` ### Files ``` ~/.config/musehub/local-tls/ ← outside the repo — safe from repo operations localhost.crt ← certificate (never committed) localhost.key ← private key (never committed) ``` Each developer generates their own cert + key pair locally with mkcert. Neither is committed — the cert is signed by your local mkcert CA, which is specific to your machine and useless to anyone else. Keeping them in `~/.config/musehub/local-tls/` means they survive `muse checkout`, cleans, and any other operation that touches the repo directory. ### Certificate renewal mkcert certs are valid for 10 years by default. You will never need to renew them in practice. If you do need to regenerate (e.g. after running `mkcert -uninstall` and back), just repeat the steps above. --- ## Staging and Production ### Architecture ``` Browser / muse CLI │ HTTPS (TLS terminated here) ▼ Cloudflare edge │ HTTPS (Cloudflare Origin Certificate) ▼ nginx on EC2 (port 443) │ HTTP (internal, loopback only) ▼ Docker container (port 1337 or 1338, blue-green) ``` Cloudflare sits in front of EC2. All traffic from the internet hits Cloudflare first. Cloudflare re-encrypts the connection to the origin using a **Cloudflare Origin Certificate** — a certificate issued by Cloudflare's own CA, valid for 15 years, trusted only by Cloudflare (not by browsers directly). The Cloudflare dashboard SSL/TLS mode must be set to **Full (Strict)**. The Docker containers themselves run plain HTTP on the loopback interface (`127.0.0.1:1337` / `127.0.0.1:1338`). nginx proxies to them. Traffic never leaves the EC2 instance unencrypted — it stays on the loopback adapter between nginx and the containers. ### Why not Let's Encrypt? Let's Encrypt requires the cert to be publicly browser-trusted, which means exposing port 80 for ACME challenges (or using DNS-01 challenges). With Cloudflare in front, Cloudflare already handles the browser-trusted cert. The origin cert only needs to satisfy Cloudflare's validator, not browsers. Cloudflare Origin Certs are valid for 15 years and never need ACME renewal — simpler, more reliable. ### Generating a Cloudflare Origin Certificate Do this once per environment (staging and prod are separate). 1. Cloudflare Dashboard → select the domain → **SSL/TLS** → **Origin Server** 2. Click **Create Certificate** 3. Choose: "Generate private key and CSR with Cloudflare", RSA (2048), 15 years 4. Copy the **Certificate** → `origin.pem` 5. Copy the **Private Key** → `origin.key` Then install on the EC2 instance: ```bash # SCP the two files to the instance (replace with the instance IP or alias) scp origin.pem ec2-user@:~/ scp origin.key ec2-user@:~/ # On the instance sudo mkdir -p /etc/ssl/cloudflare sudo mv ~/origin.pem /etc/ssl/cloudflare/origin.pem sudo mv ~/origin.key /etc/ssl/cloudflare/origin.key sudo chmod 644 /etc/ssl/cloudflare/origin.pem sudo chmod 640 /etc/ssl/cloudflare/origin.key sudo chown root:root /etc/ssl/cloudflare/origin.pem /etc/ssl/cloudflare/origin.key ``` Delete the local copies of `origin.pem` and `origin.key` immediately after installing — never commit them or leave them in your home directory. ### nginx configuration `deploy/nginx-cf.conf` is the nginx config template. It is copied to the EC2 instance during `deploy/setup-ec2.sh` (or `setup-ec2-staging.sh`) and updated on every deploy if it has changed. Key TLS settings in that file: ```nginx ssl_certificate /etc/ssl/cloudflare/origin.pem; ssl_certificate_key /etc/ssl/cloudflare/origin.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:...; ssl_prefer_server_ciphers off; ``` TLS 1.0 and 1.1 are disabled. TLS 1.3 is preferred; TLS 1.2 is retained for older clients. ### EC2 security group Port 443 inbound is restricted to Cloudflare's published IP ranges at the EC2 security group level. Port 80 is open only to redirect to HTTPS. Direct-to-origin HTTPS access (bypassing Cloudflare) is blocked by the security group — the Origin Certificate would be untrusted by a browser anyway. Do not duplicate IP allow/deny rules inside nginx — the `real_ip` module in the nginx config replaces `$remote_addr` with the true client IP (from `CF-Connecting-IP`), which would cause `allow/deny` blocks to incorrectly reject legitimate Cloudflare traffic. ### Certificate renewal Cloudflare Origin Certificates are valid for 15 years. There is no automatic renewal process. Calendar a reminder to rotate the cert before it expires. The rotation procedure is the same as the initial generation above. --- ## TLS version policy All environments enforce TLS 1.2 minimum: - **Local**: Python's `ssl` module default (TLS 1.2+) - **Staging / Prod**: nginx `ssl_protocols TLSv1.2 TLSv1.3` TLS 1.0 and 1.1 are disabled everywhere. This is enforced at the protocol layer, not the application layer.