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)
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,
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. 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.
# 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:
docker compose restart musehub
Verify:
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).
- Cloudflare Dashboard → select the domain → SSL/TLS → Origin Server
- Click Create Certificate
- Choose: "Generate private key and CSR with Cloudflare", RSA (2048), 15 years
- Copy the Certificate →
origin.pem - Copy the Private Key →
origin.key
Then install on the EC2 instance:
# SCP the two files to the instance (replace <host> with the instance IP or alias)
scp origin.pem ec2-user@<host>:~/
scp origin.key ec2-user@<host>:~/
# 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:
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
sslmodule 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.