Caddy
/etc/caddy/Caddyfile:
sudo systemctl reload caddy — certificates are issued automatically.
Then in the instance’s .env:
docker compose up -d. With the proxy in place, close port 3000 to the
outside (sudo ufw delete allow 3000/tcp) so all traffic flows through TLS.
Notes:
- HSTS lives at the proxy (above), where TLS terminates. The app already
sends
nosniff,Referrer-Policy, and a CSP on HTML. TRUST_PROXY=trueonly behind a proxy you control. The app reads the rightmostx-forwarded-forhop — the one your proxy sets. No Caddyfile directive is needed: Caddy ≥2.5 discards client-suppliedX-Forwarded-*by default and sets the header to the real peer. Do not add atrusted_proxiesrange covering untrusted clients, or the client-claimed value would be preserved and become the rightmost hop.- Chained proxies (CDN → Caddy): the app reads the hop Caddy appended,
i.e. the CDN’s egress IP. Either accept edge-IP keying or strip/normalize
X-Forwarded-Forat the outermost hop so the real client IP lands rightmost. - WebSockets and streaming (file downloads, MCP) proxy transparently; no extra config.
Optional: a dedicated user-content origin (VIEWER_BASE_URL)
The share-link viewer hardening
(viewer-security-model.md) is one more site
block pointing at the same app — a second hostname that carries no app
cookies:
VIEWER_BASE_URL=https://usercontent.example.net in .env. Share
URLs are then minted on that origin, and the server enforces the split by
hostname:
- The viewer hostname answers only
/v/*(decks, assets, the password form),/api/v1/viewer/*(the token-authenticated annotation and forms API the deck runtime calls) and the/healthz//readyzprobes. The dashboard, sign-in,/mcp,/embed.jsand the rest of/api/v1answer 404 there, no session cookie is ever issued on it, and nothing it serves is authenticated by one (the viewer API authenticates on the share secret). - The app hostname (
PUBLIC_BASE_URL) serves everything else and answers/v/*with a redirect to the viewer hostname, so links minted before the switch keep working. - The app API refuses any request whose
Originis the viewer origin (or any foreign origin), and the dashboard’s content policy frames decks from the viewer origin only, so the in-dashboard deck preview keeps working.
Host header (Caddy does
by default; nginx needs proxy_set_header Host $host;) — the split is decided
on it.
Caddy in compose (alternative)
Add todocker-compose.yml when you prefer everything containerized:
Caddyfile of slides.example.com { reverse_proxy app:3000 }, and
remove the app’s host port mapping.