Something went wrong. Try again.
Read-only ActivityPub → atproto bridge for the threadiverse using Coves lexicons
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399# Tidepool production stack.## Runs next to the Coves stack on the same box (/opt/tidepool beside# /opt/coves). TLS terminates in the COVES Caddy (coves-prod-caddy), which# fronts tdpl.io and every bridged-handle subdomain and reaches the bridge# over the shared coves-prod-network — the `tidepool` service joins that# network as an external one. The tdpl.io site blocks (including the# on-demand-TLS cert gate pointing at /.well-known/tidepool-tls-ask) live in# the Coves repo's Caddyfile.## Migrations: production does NOT migrate on server start. The migrate# one-shot and the server share one locally-built image; the server only# starts after a successful migration run (service_completed_successfully),# so the deploy command is a single targeted:## docker compose -f docker-compose.prod.yml up -d --build tidepool## Follow list: communities.yaml is read through a DIRECTORY bind mount# (./:/repo:ro), not a single-file mount — docker pins single-file mounts to# the inode present at container start, and `git pull` replaces the file, so# a single-file mount would silently serve the pre-pull follow list forever# (the same trap the Coves Caddyfile fell into). With the directory mount a# plain `git pull` is enough; the reconciler picks the new file up on its# next sweep (FOLLOW_LIST_INTERVAL, default 15m) — no container restart.## v2 env: the `tidepool` service below gained the task-13/14/15/16/17 knobs# (AP_USER_ORIGIN, CONSUMER_ENABLED, JETSTREAM_URL, OUTBOUND_*, the admission# quota, DIVERGENCE_*). AP_USER_ORIGIN is REQUIRED in production, so deploying# HEAD onto a pre-v2 /opt/tidepool/.env fails at boot rather than coming up# half-configured. The staged-rollout order, the kill switches and the# coves.social Caddy changes are in DEPLOY.md.## Ops note (README "Sync surface"): the bridge's per-IP rate limiters key on# RemoteAddr and see only Caddy's container IP from behind the proxy —# per-IP limits degrade to global ones until rate limiting exists at the# Caddy edge. Tracked in FOLLOWUPS.md; acceptable at current scale.
x-tidepool-image: &tidepool-image build: . image: tidepool:prod
services: postgres: image: postgres:16 container_name: tidepool-prod-postgres restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-tidepool} POSTGRES_USER: ${POSTGRES_USER:-tidepool} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres-data:/var/lib/postgresql/data - ./backups:/backups networks: - tidepool-internal healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tidepool} -d ${POSTGRES_DB:-tidepool}"] interval: 10s timeout: 5s retries: 5 deploy: resources: limits: memory: 16G reservations: memory: 1G
tidepool-migrate: <<: *tidepool-image container_name: tidepool-prod-migrate command: ["migrate"] restart: "no" environment: DATABASE_URL: postgres://${POSTGRES_USER:-tidepool}:${POSTGRES_PASSWORD}@tidepool-prod-postgres:5432/${POSTGRES_DB:-tidepool}?sslmode=disable networks: - tidepool-internal depends_on: postgres: condition: service_healthy
tidepool: <<: *tidepool-image container_name: tidepool-prod restart: unless-stopped ports: - "127.0.0.1:8091:80" # host-local ops access (admin API without a round trip through Caddy) # Authoritative DNS for delegated instance subzones. Bound to the public # IPv4 only: systemd-resolved holds 127.0.0.53:53/127.0.0.54:53 on the host. - "51.222.47.2:53:53/udp" - "51.222.47.2:53:53/tcp" environment: ENVIRONMENT: production # Soft heap limit under the 8G cap: makes the GC collect before AVIF decode buffers stack up. GOMEMLIMIT: 6GiB # DB host is the container NAME, not the `postgres` service alias: # this service also joins coves-prod-network, where the Coves stack # exposes its own container under the alias `postgres` — the bare # alias can resolve to the WRONG database from here. DATABASE_URL: postgres://${POSTGRES_USER:-tidepool}:${POSTGRES_PASSWORD}@tidepool-prod-postgres:5432/${POSTGRES_DB:-tidepool}?sslmode=disable LISTEN_ADDR: ":80" BRIDGE_HOSTNAME: ${BRIDGE_HOSTNAME:-tdpl.io} PLC_DIRECTORY_URL: https://plc.directory BRIDGE_KEK: ${BRIDGE_KEK} # Empty in steady state (the bridge reads empty as unset). It is set for # the length of a KEK rotation ONLY, and it must be forwarded here rather # than only to a one-off container: the running bridge needs the old key # to open pre-rotation material, and `docker compose run tidepool # rotate-kek` inherits this same service environment. See DEPLOY.md §6, # "Runbook: rotating BRIDGE_KEK". BRIDGE_KEK_PREVIOUS: ${BRIDGE_KEK_PREVIOUS:-} ADMIN_TOKEN: ${ADMIN_TOKEN} # Gentler than the built-in 60/120: the public plc.directory 429s # mint bursts during community backfill (observed at launch), and a # rejected submission wastes a generated DID. Mints retry durably, # so a smoother rate only defers identity creation, never loses it. MINT_RATE_PER_MINUTE: ${MINT_RATE_PER_MINUTE:-10} MINT_BURST: ${MINT_BURST:-20} # PLC handle updates per second for the rename job; empty means the # config default (0.1). Mints share the same plc.directory limit. HANDLE_MIGRATION_RATE: ${HANDLE_MIGRATION_RATE:-} # Announced for WIDER-NETWORK visibility only (and quota-limited # there — see the relay service below). The app path into Coves is # the self-hosted relay + jetstream services in this file. The # internal relay is deliberately NOT listed here: production # requestCrawl egress is SSRF-guarded and refuses private addresses; # the internal relay is bootstrapped once via its host-local port # instead (SELF_HOSTED_RELAY.md). RELAY_HOSTS: ${RELAY_HOSTS:-https://bsky.network} FOLLOW_LIST_PATH: /repo/communities.yaml
# --- v2 (tasks 13-17): the native-user surface and the outbound half --- # # REQUIRED IN PRODUCTION. config.stringVar refuses an unset value outside # development (internal/config/config.go:797-806), so a HEAD deploy onto # an env file without this dies at boot with # config: AP_USER_ORIGIN is required in production # — fail-closed, and deliberately so: this origin is baked into every # actor_id this deployment mints, so a wrong value is not a restart away # from fixed, it is a set of federated identities pointing at the wrong # place forever. Must be https in production (config.go:782-784) and must # not be BRIDGE_HOSTNAME or a subdomain of it (config.go:786-790), which # would shadow the bridged-handle namespace. # # Serving derives every URL from the stored actor_id, never from this # value — so changing it later does NOT migrate existing actors, it only # changes where NEW ones are minted. AP_USER_ORIGIN: ${AP_USER_ORIGIN:?AP_USER_ORIGIN must be set in .env — see DEPLOY.md §1; a defaulted origin mints permanent wrong identities}
# The Jetstream consumer: native Coves users' records flowing OUTWARD. # Default off. Staged here ahead of the flag on purpose — JETSTREAM_URL is # validated whenever it is set (config.go:481-489), not only when the # consumer is on, so a typo is a boot failure with a clear message today # rather than a silent reconnect loop on the day someone flips the switch. # # Container NAME, not the `jetstream` service alias, for the same reason # DATABASE_URL uses tidepool-prod-postgres: this service also joins # coves-prod-network, and a bare alias is only unambiguous until the Coves # stack grows a service by the same name. :8080 is JETSTREAM_ADDR below; # :6060 is its debug listener and serves no /subscribe. CONSUMER_ENABLED: ${CONSUMER_ENABLED:-false} JETSTREAM_URL: ${JETSTREAM_URL:-ws://tidepool-prod-jetstream:8080/subscribe}
# Delivery workers. 0 = OFF, and it is AND-gated on CONSUMER_ENABLED # (cmd/tidepool/main.go:716, 843) — there is no OUTBOUND_ENABLED. With the # consumer on and workers at 0 the real enqueuer still writes # outbound_activities/deliveries, so intent accumulates durably and starts # draining the moment workers are raised. That is the intended staging # order: consumer first, watch the queue, then workers. OUTBOUND_WORKERS: ${OUTBOUND_WORKERS:-0}
# Kill switches (decision 19). A blocked delivery is PARKED — it stays # pending and resumes when the switch clears (internal/outbound/worker.go # :236-242) — never poisoned, never cancelled, so engaging one loses # nothing. DRY_RUN parks too, after translating and logging. # # ALL OF THESE ARE READ ONCE AT BOOT (config.Load, main.go:109); there is # no reload signal. Changing one means editing /opt/tidepool/.env and # recreating the service. # # HOSTS is lowercased on load; COMMUNITIES and ACTORS are matched EXACTLY # and case-sensitively (config.go:519-525). Communities are AP ids # ("https://lemmy.world/c/comicstrips"), matched against the delivery's # ordering key; actors are DIDs. There is no allowlist form — a canary is # expressed by disabling every community except the one under test. OUTBOUND_DRY_RUN: ${OUTBOUND_DRY_RUN:-false} OUTBOUND_DISABLED: ${OUTBOUND_DISABLED:-false} OUTBOUND_DISABLED_HOSTS: ${OUTBOUND_DISABLED_HOSTS:-} OUTBOUND_DISABLED_COMMUNITIES: ${OUTBOUND_DISABLED_COMMUNITIES:-} OUTBOUND_DISABLED_ACTORS: ${OUTBOUND_DISABLED_ACTORS:-}
# The acceptance engine's per-author-per-community flood cap (0 = # unlimited). Tidepool SIGNS the community's acceptance, so it must not # let one native account flood a community it vouches for. ADMISSION_MAX_PER_AUTHOR_PER_COMMUNITY: ${ADMISSION_MAX_PER_AUTHOR_PER_COMMUNITY:-50}
# The reconciliation sweep (task 17e). It is wired UNCONDITIONALLY # (main.go:491-506) and sweeps once at startup before its first tick, so # this interval is not an on/off switch — durationVar refuses 0 or # negative (config.go:666-668). It writes nothing, to peers or to our own # tables, which is what makes an always-on schedule safe. The only way to # quiet it is a large interval; see DEPLOY.md "Not implemented". DIVERGENCE_INTERVAL: ${DIVERGENCE_INTERVAL:-15m} # How long a pending delivery may sit before the sweep calls its # acceptance stale. The report's one crying-wolf knob; 12h is derived from # the retry schedule plus the causal wait budget (internal/ingest/ # divergence.go:72-81), not picked. DIVERGENCE_ACCEPTANCE_STALE_AFTER: ${DIVERGENCE_ACCEPTANCE_STALE_AFTER:-12h}
# Bridged handle resolution over DNS (docs PRD_HANDLE_DNS). Empty # DNS_LISTEN disables the server; the port publish above is then idle. # Cloudflare answers for the tdpl.io zone and delegates each instance # label to ns1/ns2.tdpl.io (A records → this host), which is this server. DNS_LISTEN: ${DNS_LISTEN:-} DNS_PUBLIC_IPV4: ${DNS_PUBLIC_IPV4:-} DNS_PUBLIC_IPV6: ${DNS_PUBLIC_IPV6:-} DNS_UDP_RESPONSE_RATE: ${DNS_UDP_RESPONSE_RATE:-} # Empty CLOUDFLARE_API_TOKEN disables delegation. When set, a pass runs at # startup and every 15 minutes, creates at most 20 NS records per rolling # hour, and stops at DELEGATION_MAX_NS_RECORDS (3,000 under Pro's 3,500). CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-} CLOUDFLARE_ZONE_ID: ${CLOUDFLARE_ZONE_ID:-} DELEGATION_MAX_NS_RECORDS: ${DELEGATION_MAX_NS_RECORDS:-} volumes: - ./:/repo:ro networks: - tidepool-internal - coves-prod-network depends_on: postgres: condition: service_healthy tidepool-migrate: condition: service_completed_successfully healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/xrpc/_health"] interval: 30s timeout: 5s retries: 3 start_period: 10s deploy: resources: limits: memory: 8G reservations: memory: 512M
# Self-hosted atproto relay: crawls ONLY our two PDS hosts (tdpl.io + # pds.coves.me) and merges them into one firehose. Exists because # bsky.network caps a new PDS at 100 accounts — every bridged repo past # the quota is status:"throttled" there and its events are silently # dropped, never reaching a Jetstream-fed AppView (diagnosed 2026-07-16: # 91% of tdpl.io repos throttled, bridged threads rendering empty in # Coves). This relay is the app's ingest path; bsky.network remains the # wider-network visibility path (RELAY_HOSTS above is unchanged). # # Host discovery is push-only — a relay never spiders the network. Both # PDS hosts are registered by a one-time requestCrawl against the # host-local port (see SELF_HOSTED_RELAY.md); the host table # persists in relay-data, so this survives restarts. requestCrawl stays # enabled but is reachable only from 127.0.0.1 and the internal network # — network isolation IS the access control (indigo has no flag to # admin-gate it). Do NOT expose 2470 publicly, and do NOT run # `relay pull-hosts` (that imports the global network's host list). # # NOT announced to via Tidepool's RELAY_HOSTS: production requestCrawl # egress uses the SSRF-guarded client (internal/sync/crawl.go), which # correctly refuses private addresses like this container. The one-time # bootstrap replaces the announcement; keep it that way rather than # punching a hole in the egress guard. relay: build: # Pinned upstream checkout, built with indigo's own Dockerfile. # Bump the SHA deliberately; rebuild with: # docker compose -f docker-compose.prod.yml build relay context: https://github.com/bluesky-social/indigo.git#bdacece3f80ef8bc79676a005616b9a802895356 dockerfile: cmd/relay/Dockerfile args: # indigo's Dockerfile runs `git describe`; git-URL contexts strip # .git unless BuildKit is told to keep it. BUILDKIT_CONTEXT_KEEP_GIT_DIR: 1 image: tidepool-relay:prod container_name: tidepool-prod-relay restart: unless-stopped ports: - "127.0.0.1:2470:2470" # host-local ops: requestCrawl bootstrap, getHostStatus, admin UI environment: ENVIRONMENT: prod RELAY_ADMIN_PASSWORD: ${RELAY_ADMIN_PASSWORD} # sqlite + event persistence both live under /data/relay (relay-data # volume); WORKDIR is / and the defaults are relative paths. DATABASE_URL: sqlite:///data/relay/relay.sqlite RELAY_PERSIST_DIR: /data/relay/persist # The entire point of self-hosting: no account quota. Only our own # hosts can ever be added (see isolation note above), so an # effectively-unlimited default is safe. RELAY_DEFAULT_ACCOUNT_LIMIT: 10000000 # Trusted hosts skip the baseline event-rate limiters (the analog of # bsky.network's ~50 ev/s consumption throttle that put the public # path ~1.3h behind). A reemit burst should drain in seconds here. RELAY_TRUSTED_DOMAINS: tdpl.io,pds.coves.me RELAY_REPLAY_WINDOW: 72h volumes: - relay-data:/data/relay networks: - tidepool-internal healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:2470/xrpc/_health"] interval: 30s timeout: 5s retries: 3 start_period: 15s deploy: resources: limits: memory: 4G reservations: memory: 256M
# The jetstream image is distroless and runs as the nonroot user baked # into the base (65532). A fresh named volume is created root-owned # (the mount path doesn't exist in the image, so docker can't copy # ownership from it), which would EACCES jetstream's first write. This # one-shot hands the volume to that uid before jetstream starts. jetstream-init: image: alpine:3.22 container_name: tidepool-prod-jetstream-init command: ["chown", "-R", "65532:65532", "/data"] restart: "no" volumes: - jetstream-data:/data
# Jetstream over the self-hosted relay: converts the CBOR firehose into # the filtered JSON /subscribe protocol the Coves consumers speak. Coves # (Phase 2) points its @self consumer set at # ws://tidepool-prod-jetstream:8080/subscribe # over the shared coves-prod-network. Not publicly exposed. # # First boot on an EMPTY volume performs a bootstrap backfill: listHosts # on the relay, then listRepos + getRepo directly against each PDS over # https (minutes at our scale); /subscribe answers 503 until it finishes, # so watch `docker logs` before wiring consumers. A volume already in # steady state goes straight to live tailing. jetstream: build: # v0.4.5. Reviewed 2026-10-20 against f6d220f3 (the checkout verified # with the Coves consumers on 2026-07-16): the v1 /subscribe query # params (wantedCollections, wantedDids, cursor), event JSON and v1 # zstd dictionary are unchanged; a time cursor inside the active # segment now replays from that time rather than from the segment # start. The env names below are unchanged; unknown JETSTREAM_* vars # stop the server at boot. An f6d220f3 data dir is read as is (one # sequence-lease record is added on first start). # # Rollback is NOT a SHA revert: once this version has run, f6d220f3 # on the same volume drops every reconnect that carries a cursor. # Copy the jetstream-data volume before the first start and restore # that copy to roll back. context: https://github.com/bluesky-social/jetstream.git#2b2bf447c3fd995de975dc21dc4d491347a6a6b6 image: tidepool-jetstream:prod container_name: tidepool-prod-jetstream restart: unless-stopped environment: JETSTREAM_RELAY_URL: http://relay:2470 JETSTREAM_ADDR: ":8080" JETSTREAM_DATA_DIR: /data # Internal-only debug listener (metrics/pprof/health) — never publish. JETSTREAM_DEBUG_ADDR: ":6060" JETSTREAM_LOG_LEVEL: ${JETSTREAM_LOG_LEVEL:-info} volumes: - jetstream-data:/data networks: - tidepool-internal - coves-prod-network depends_on: relay: condition: service_healthy jetstream-init: condition: service_completed_successfully deploy: resources: limits: memory: 4G reservations: memory: 256M
networks: tidepool-internal: driver: bridge name: tidepool-prod-internal coves-prod-network: external: true
volumes: postgres-data: name: tidepool-prod-postgres-data relay-data: name: tidepool-prod-relay-data jetstream-data: name: tidepool-prod-jetstream-data