Something went wrong. Try again.
A community based topic aggregation platform built on atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603# Coves Production Caddyfile# Handles HTTPS for coves.social (web app + AppView), coves.me (PDS), the# media hostname and the Tidepool bridge.## Domain architecture:# - coves.social: SvelteKit frontend (pages) + AppView (XRPC/OAuth# allowlist) + Tidepool's ActivityPub surface# - *.coves.social: Community handles (route atproto-did to PDS)# - pds.coves.me: PDS canonical hostname (for relay registration)# - coves.me: PDS legacy hostname (kept for compatibility)# Global options: gate on-demand certificate issuance (used by the Tidepool# handle-subdomain block below). Caddy calls this endpoint with ?domain=<host># before requesting a cert; Tidepool answers 200 only for handles it actually# serves. Without the gate, the *.tdpl.io wildcard DNS would let any probe of# a random subdomain burn a Let's Encrypt issuance attempt.{ # ACME account email. Also required (since Caddy 2.8) for ZeroSSL to be # provisioned as a fallback CA — without it Let's Encrypt is the ONLY # issuer, and hitting LE's 50-new-certs-per-domain-per-week limit with # on-demand handle certs would hard-fail new handshakes instead of # falling back. {$ACME_EMAIL} is substituted from the caddy container's # environment when the Caddyfile is parsed (set ACME_EMAIL in .env; a # role address like support@<domain> — never a personal one, this file # is public). email {$ACME_EMAIL} on_demand_tls { ask http://tidepool:80/.well-known/tidepool-tls-ask }}# Community handle subdomains (e.g., gaming.coves.social)# These need to route /.well-known/atproto-did to PDS for handle resolution## NOTE: Wildcard certs require DNS challenge. For Cloudflare:# 1. Create API token with Zone:DNS:Edit permissions# 2. Set CLOUDFLARE_API_TOKEN environment variable# 3. Use caddy-dns/cloudflare plugin (see docker-compose.prod.yml)*.coves.social { tls { dns cloudflare {env.CLOUDFLARE_API_TOKEN} } # Handle resolution - proxy to PDS handle /.well-known/atproto-did { reverse_proxy pds:3000 } # OAuth well-known endpoints - proxy to PDS handle /.well-known/oauth-protected-resource { reverse_proxy pds:3000 } handle /.well-known/oauth-authorization-server { reverse_proxy pds:3000 } # All other requests return 404 (subdomains only exist for handle resolution) handle { respond "Not Found" 404 } # Security headers header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" -Server }}# AppView Domain (root)coves.social { # ── Tidepool's native-user AP surface (AP_USER_ORIGIN) ────────────── # These paths belong to the bridge, not the AppView. More specific than # the /.well-known/* static block below, so they win the handle sort. # # NOTE: no `header_up Host` on these proxies. Caddy v2 forwards the # original Host by default, and Tidepool's Host router keys on it to # choose the persona surface over the bridge surface # (tidepool: internal/personas/hostrouter.go). Rewriting Host to the # upstream address would 421 every one of these requests. handle /.well-known/webfinger { reverse_proxy tidepool:80 { header_up X-Real-IP {remote_host} } } handle /.well-known/nodeinfo { reverse_proxy tidepool:80 { header_up X-Real-IP {remote_host} } } handle /nodeinfo/2.0 { reverse_proxy tidepool:80 { header_up X-Real-IP {remote_host} } } # /ap/actor/{did}, /ap/actor/{did}/outbox, /ap/object/*, /ap/activity/*, # and POST /ap/inbox — the shared inbox for this origin. handle /ap/* { reverse_proxy tidepool:80 { header_up X-Real-IP {remote_host} } } # Serve .well-known files for DID verification handle /.well-known/* { header Access-Control-Allow-Origin "*" root * /srv file_server } # /img/* belongs to the media hostname, and only to the media hostname. # # The AppView registers the image-proxy route on its router with no Host # check, so without this block the catch-all below would serve the exact # same bytes from coves.social — which is DNS-only, not orange-clouded, and # therefore never crosses the scanning edge. That would reduce the whole # choke point to a convention about which URL the AppView happens to emit, # which is not an enforcement boundary: anyone wanting to pull media past # the scanner would just swap the hostname. # # Redirect rather than 404 so an old or hand-built apex URL still resolves, # but resolves through the scanned origin. 301 because the mapping is # permanent and worth caching in the client. handle /img/* { redir https://img.coves.social{uri} permanent } # ── Apex: split by Accept ─────────────────────────────────────────── # `handle /` matches the apex EXACTLY (a Caddy path matcher is exact # unless it ends in *), so this replaces only the bare "/" case that # the catch-all below used to serve. Same-name directives run in # Caddyfile order, so the matched reverse_proxy is tried before the # fallback. # # The split is inverted on purpose: EXPLICIT text/html goes to the web # app; everything else — activity+json, ld+json, AND a request with no # Accept header at all — falls through to Tidepool. The bridge's # instance actor (tidepool: internal/personas/instance.go) requires # that a peer sending no Accept still gets the actor, and a positive # @ap matcher can never satisfy that (an absent header matches # nothing). Browsers always send text/html at the apex, so they are # unaffected; the one visible consequence is that a bare `curl /` # (Accept: */*) now gets the actor JSON, which is the AS2-conventional # answer for a non-browser client. handle / { @html header Accept *text/html* # The web app: the SvelteKit frontend, with the SAME upstream options # as the frontend catch-all below (see there for why each matters). reverse_proxy @html coves-prod-frontend:3000 { header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Host {host} } # Fallback: the instance actor (peers, absent-Accept fetchers). reverse_proxy tidepool:80 { header_up X-Real-IP {remote_host} } } # ── AppView: an explicit allowlist, no longer the catch-all ───────── # Every Go route that is MEANT to be reachable on this hostname is named # here (internal/api/routes/*.go, cmd/server/routes.go). Anything not # listed is a page and belongs to the SvelteKit frontend below — so a # new AppView HTTP route must be added to this matcher, or the frontend # answers it with its rendered 404 page. The guard is # internal/api/routes/caddy_allowlist_test.go, which walks the real # router against this block in both directions (`make test`). # # /xrpc/* XRPC API (browser calls this directly; # the frontend's own server goes to # appview:8080 over the Docker network) # /oauth/* web + mobile OAuth login/callback/ # logout/refresh # /app/oauth/callback mobile deep-link fallback # /oauth-client-metadata.json OAuth client metadata + JWKS # /oauth-client-keys.json # /api/me session → viewer (RequireAuth) # /static/*, /m/turnstile.html Go-served assets + Turnstile host page # /privacy, /safety/*, Go-rendered policy + account pages # /delete-account* (the frontend has /legal, not these; # community-guidelines.md links here) # /health* liveness / consumer health # # Go also registers "/", /img/*, and three /.well-known/* files. Those # are deliberately NOT here: the apex is split by Accept in its own # block above, /img/* is a redirect to the media hostname, and # /.well-known/* is answered by the static file_server block above # (which shadows the AppView's own /.well-known handlers by design). # Keep this matcher's path set DISJOINT from every other handle in the # block: Caddy only specificity-sorts single-path handles, so a # multi-path named matcher like this one takes its precedence purely # from position — it is above the frontend catch-all, and nothing else. # # One `path` line on purpose: Caddy 2.11 merges repeated `path` lines in # a named matcher, but not every reader knows that, and the test above # parses this block literally. @appview { path /xrpc/* /oauth/* /app/oauth/callback /oauth-client-metadata.json /oauth-client-keys.json /api/me /static/* /m/turnstile.html /privacy /safety/* /delete-account /delete-account/* /health /health/* } handle @appview { reverse_proxy appview:8080 { # Health check health_uri /xrpc/_health health_interval 30s health_timeout 5s # Headers for proper DPoP verification # Host headers are critical for DPoP htu (HTTP URI) matching header_up Host {host} header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Host {host} } # Default CSP for AppView responses that carry none of their own. # Set-if-absent: the Turnstile widget host page sets its own # Cloudflare-allowing policy (internal/web/handlers.go # TurnstileHandler) and must pass through untouched. Scoped to THIS # route on purpose — the frontend below owns its policy # (coves-frontend: src/lib/server/security-headers.ts), and a # missing app CSP there must be visibly missing, not papered over # by a static header carrying 'unsafe-inline'. # # img-src covers the media hostname every image URL the AppView # emits now points at. media-src must name the PDS hosts directly: # the image proxy transcodes stills and cannot stream, so # social.coves.embed.video#view serves video from the hosting PDS # (the accepted gap in docs/PRD_CSAM_SCANNING.md workstream 5). # The listed hosts are our own PDS plus tdpl.io, the bridge PDS # hosting blobs for bridged Lemmy communities. Video on a community # hosted by any OTHER peer is blocked, fail-closed, browser-side — # a fixed host list cannot say "any PDS in the network". It closes # when video gets a streaming passthrough on img.coves.social. The # frontend's copy of this list is CSP_VIDEO_ORIGINS in # coves-frontend .env.prod; keep the two in step. header ?Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://img.coves.social; media-src 'self' https://pds.coves.me https://coves.me https://tdpl.io; connect-src 'self' https://*.bsky.network wss://*.bsky.network; base-uri 'self'; object-src 'none'; frame-ancestors 'none'" } # ── Frontend: the catch-all ───────────────────────────────────────── # The SvelteKit app (coves-frontend), deployed as its OWN compose project # from /opt/coves-frontend and reachable only on coves-prod-network. It # is addressed by CONTAINER name: a bare `frontend` service alias is not # unique across the compose projects sharing this network (the same # cross-stack trap as the `postgres` / `jetstream` aliases). # # X-Real-IP is what the frontend reads as the client address # (ADDRESS_HEADER=x-real-ip) and stamps onto every upstream request it # makes to the AppView — the /api/proxy hop AND the per-page /api/me # session check — so the backend's rate limits key on the real user. # header_up REPLACES the header, which is the property the whole scheme # rests on: a client-supplied X-Real-IP must never survive to the # frontend. The container publishes no port, so nothing outside Docker # can reach it; the boundary is membership of coves-prod-network. Peers # on that network (appview, pds, tidepool, the aggregators) COULD dial # it and assert any address — they are our own infrastructure, and that # is the accepted trust model, not an enforced one. # # No active health check, deliberately: there is a single upstream and # nothing to fail over to, so one failed/slow probe would 503 the whole # site until the next probe. Passive dial failure -> 502 carries the # same signal (same reasoning as the tdpl.io block). # # No CSP fallback on the page routes — the app emits a complete, # per-request nonce'd policy (coves-frontend: # src/lib/server/security-headers.ts), and the launch gate is # `curl -sI -H 'Accept: text/html' https://coves.social/` showing # 'nonce-' in script-src (the Accept header matters: the apex split # above sends anything else to Tidepool). # # The frontend's build assets, service worker and the files under its # static/ dir are served by adapter-node's sirv AHEAD of the app's hooks # and carry no CSP of their own. They get a minimal fallback here so # the compiled service worker is still governed by a policy — a CSP on # a script governs the worker it becomes — without touching any page # response. Set-if-absent, like every other default in this file. @frontend_static { path /_app/* /service-worker.js /manifest.json /robots.txt /favicon.svg /font/* /logo_512.png /logo_maskable_512.png } handle @frontend_static { reverse_proxy coves-prod-frontend:3000 { header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Host {host} } header ?Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'self'; object-src 'none'; frame-ancestors 'none'" } handle { # The frontend's /api/proxy buffers request bodies in Node memory # (request.blob()) with no size limit of its own, so bound them at # the edge. Nothing routed here legitimately POSTs more than small # JSON today (posts, comments, auth) — raise this the day a media # upload path is routed through the frontend instead of the PDS. request_body { max_size 1MB } reverse_proxy coves-prod-frontend:3000 { header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Host {host} } } # Logging (Docker captures stdout/stderr) log { output stdout format json } # Security headers. HSTS and -Server are edge-only and unconditional. The # `?` prefix on the rest makes them set-if-absent: the SvelteKit frontend # owns these values (coves-frontend: src/lib/server/security-headers.ts) # and must not be silently overridden by a replace-semantics header the # day it tightens one; the defaults here cover responses that never pass # through its hooks. Content-Security-Policy is deliberately NOT among # them: the AppView's fallback lives inside its handle block and the # frontend's static-asset fallback inside @frontend_static, so no CSP # here can ever land on a frontend page response. header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" ?X-Content-Type-Options "nosniff" ?X-Frame-Options "DENY" ?Referrer-Policy "strict-origin-when-cross-origin" # Remove Server header -Server } # Enable compression encode gzip zstd}# Media hostname. Every image the AppView hands a client — post and comment# embeds, link-card thumbnails, avatars, banners — is addressed here, on a# hostname that carries nothing else.## The isolation is the point: this is the ONLY record in the stack meant to be# orange-clouded (proxied) at Cloudflare, which is what lets an upstream scanner# see the media we serve. Everything else must stay DNS-only — the PDS serves# the atproto sync surface (firehose WebSockets, relay traffic), and tdpl.io# cannot be proxied at all because its on-demand handle certs need DNS pointing# straight at this origin. Because only /img/* exists here, the blast radius of# any CDN feature enabled on this zone is exactly the image proxy.## TLS is issued via the existing Cloudflare DNS-01 token, which works behind the# orange cloud (no Origin CA cert needed). Set the zone to Full (strict).img.coves.social { tls { dns cloudflare {env.CLOUDFLARE_API_TOKEN} } handle /img/* { reverse_proxy appview:8080 { header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} } } # Nothing else is served from this hostname. Keeping it a media-only origin # means a cache rule scoped to img.coves.social/* can never accidentally # cache an API response. handle { respond "Not Found" 404 } header { Strict-Transport-Security "max-age=31536000" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" # Images are loaded cross-origin by coves.social and the mobile app. Access-Control-Allow-Origin "*" -Server } log { output stdout format json } # Images are already compressed; this only benefits the plain-text error # bodies the proxy returns, which are explicitly no-store. encode gzip zstd}# Tidepool bridge (ActivityPub → atproto). The bridge container lives in the# /opt/tidepool stack and joins coves-prod-network so this Caddy can reach it.# Apex: AP inbox/actor/webfinger/nodeinfo + the com.atproto.sync.* surface# (subscribeRepos WebSocket upgrades are automatic in Caddy v2).tdpl.io { # AP activities are small JSON; nothing legitimately POSTs large bodies # here (media is fetched outbound by the bridge, never uploaded). request_body { max_size 1MB } # No active health check: with a single upstream one failed/slow probe # blackholes the whole site with 503s until the next probe (nothing to # fail over to). Passive dial failure -> 502 carries the same signal, # and Lemmy treats 5xx as retryable either way. reverse_proxy tidepool:80 { header_up X-Real-IP {remote_host} } log { output stdout format json } header { Strict-Transport-Security "max-age=31536000" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" -Server } encode gzip zstd}# Bridged-handle subdomains: alice.lemmy-world.tdpl.io etc. — always two# labels below the apex (name.instance-label.tdpl.io). A wildcard cert only# covers ONE label level, so one *.<instance>.tdpl.io wildcard per bridged# instance covers all of that instance's users with a single certificate,# issued ahead of time via DNS-01 (requires CLOUDFLARE_API_TOKEN_TDPL, a# scoped token with Zone:DNS:Edit on the tdpl.io zone ONLY — separate from# CLOUDFLARE_API_TOKEN, which is scoped to coves.social).## Instance list generated from production bridged_actors (2026-07-13):# docker exec tidepool-prod-postgres psql -U tidepool -d tidepool -t -A -c \# "SELECT DISTINCT split_part(handle,'.',2) FROM bridged_actors# WHERE handle LIKE '%.tdpl.io'# AND array_length(string_to_array(handle,'.'),1) = 4 ORDER BY 1"# Instances NOT listed here still work via the on-demand catch-all block# below (degraded: per-handle handshake-time issuance) — add a line here# when a new instance shows up, then force-recreate caddy.*.anarchist-nexus.tdpl.io,*.breakfast-haus.tdpl.io,*.discuss-online.tdpl.io,*.discuss-tchncs-de.tdpl.io,*.downonthestreet-eu.tdpl.io,*.europe-pub.tdpl.io,*.feddit-cl.tdpl.io,*.feddit-dk.tdpl.io,*.feddit-it.tdpl.io,*.feddit-nl.tdpl.io,*.feddit-nu.tdpl.io,*.feddit-org.tdpl.io,*.feddit-uk.tdpl.io,*.fedia-io.tdpl.io,*.growers-social.tdpl.io,*.hexbear-net.tdpl.io,*.infosec-pub.tdpl.io,*.jlai-lu.tdpl.io,*.kakera-kintsugi-moe.tdpl.io,*.leminal-space.tdpl.io,*.lemmus-org.tdpl.io,*.lemmy-blahaj-zone.tdpl.io,*.lemmy-ca.tdpl.io,*.lemmy-cafe.tdpl.io,*.lemmy-curiana-net.tdpl.io,*.lemmy-dbzer0-com.tdpl.io,*.lemmy-decronym-xyz.tdpl.io,*.lemmy-linuxuserspace-show.tdpl.io,*.lemmy-manganiello-tech.tdpl.io,*.lemmy-ml.tdpl.io,*.lemmy-nz.tdpl.io,*.lemmy-pt.tdpl.io,*.lemmy-radio.tdpl.io,*.lemmy-sdf-org.tdpl.io,*.lemmy-today.tdpl.io,*.lemmy-world.tdpl.io,*.lemmy-wtf.tdpl.io,*.lemmy-zip.tdpl.io,*.literature-cafe.tdpl.io,*.mander-xyz.tdpl.io,*.mas-to.tdpl.io,*.mastodon-social.tdpl.io,*.moist-catsweat-com.tdpl.io,*.multiverse-soulism-net.tdpl.io,*.pawb-social.tdpl.io,*.pie-zerojay-com.tdpl.io,*.piefed-blahaj-zone.tdpl.io,*.piefed-ca.tdpl.io,*.piefed-social.tdpl.io,*.piefed-world.tdpl.io,*.piefed-zeromedia-vip.tdpl.io,*.piefed-zip.tdpl.io,*.programming-dev.tdpl.io,*.quokk-au.tdpl.io,*.reddit-kokomo-cloud.tdpl.io,*.reddthat-com.tdpl.io,*.sh-itjust-works.tdpl.io,*.shredderfood-net.tdpl.io,*.slrpnk-net.tdpl.io,*.sopuli-xyz.tdpl.io,*.suppo-fi.tdpl.io,*.swg-empire-de.tdpl.io,*.tarte-nuage-libre-fr.tdpl.io,*.thelemmy-club.tdpl.io { # force_automate: without it, NONE of the wildcards above are ever # issued — the on-demand catch-all below puts *.*.tdpl.io in the same # managed set, and since Caddy 2.10 a covering wildcard silently wins # over its subdomains (caddytls managingWildcardFor), deferring all 64 # names to a policy that only issues at handshake time — i.e. never. # force_automate adds these names to the automate cert loader, the # documented bypass. NOTE: must be the directive argument (not a block # subdirective) on Caddy <= 2.11. # # Two issuers, in priority order. A bare `dns` shortcut would build a # SINGLE Let's Encrypt issuer with no fallback — and LE's # 50-new-certs/week bucket for tdpl.io is regularly exhausted by the # on-demand catch-all below, 429ing every wildcard. ZeroSSL (no # per-domain weekly cap) reuses the ACME account already registered in # storage by the pre-2.10 image's default fallback, so no EAB config # is needed here. tls force_automate { issuer acme { dns cloudflare {env.CLOUDFLARE_API_TOKEN_TDPL} } issuer acme { dir https://acme.zerossl.com/v2/DV90 dns cloudflare {env.CLOUDFLARE_API_TOKEN_TDPL} } } reverse_proxy tidepool:80 { header_up X-Real-IP {remote_host} } header { Strict-Transport-Security "max-age=31536000" X-Content-Type-Options "nosniff" -Server }}# On-demand catch-all for bridged handles on instances NOT yet listed in the# wildcard block above (and any single-label *.tdpl.io hosts). Certificates# are issued on demand at handshake time (per exact hostname, HTTP-01),# gated by the global on_demand_tls ask above. Degraded path: issuance# blocks the first handshake and draws from Let's Encrypt's# 50-new-certs/week bucket — add new instances to the wildcard block above# as they appear. Requires the *.tdpl.io DNS record to point DIRECTLY at# this server (no CDN proxying). Only /.well-known/atproto-did and# resolveHandle matter on these hosts; Tidepool answers from the Host# header.*.tdpl.io, *.*.tdpl.io { tls { on_demand } reverse_proxy tidepool:80 { header_up X-Real-IP {remote_host} } header { Strict-Transport-Security "max-age=31536000" X-Content-Type-Options "nosniff" -Server }}# PDS Domain (both hostnames point to same PDS)# pds.coves.me is the canonical hostname for relay registrationpds.coves.me, coves.me { reverse_proxy pds:3000 { # Health check health_uri /xrpc/_health health_interval 30s health_timeout 5s # Headers for proper client IP handling header_up Host {host} header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} # Note: Caddy v2 handles WebSocket upgrades automatically # No need for explicit Connection/Upgrade headers } # Logging (Docker captures stdout/stderr) log { output stdout format json } # Security headers header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" -Server } # Enable compression encode gzip zstd}