# StrafeChat - single-host production deployment. # # ./setup.sh # writes .env (or: cp .env.example .env and edit it) # docker compose up -d --build # # Every setting comes from .env - this file is not meant to be edited. Caddy terminates TLS # for https://$DOMAIN (Let's Encrypt, automatic) and routes: # / -> web (static client) # /api/* -> equinox API (prefix stripped) # /gateway/* -> stargate WebSocket gateway (prefix stripped) # /cdn/* -> nebula (uploads, avatars, emoji, attachments) # /livekit/* -> livekit (voice/video signalling; media goes direct, see below) # /.well-known/strafe -> equinox (federation discovery document) # # One domain, one certificate, and the instance's federation domain is simply $DOMAIN. # # The three services are built straight from their GitHub repositories, so this repo is all # you need to clone. To build from a local checkout instead - developing, or running a # patched build - point the matching variable at it in .env (./setup.sh does this for you # when it finds the sources beside this directory): # EQUINOX_SRC=../equinox WEB_SRC=../web.strafe.chat NEBULA_SRC=../nebula # # Voice and video (VOICE=true, the default) also need these ports open on the host, # straight to the LiveKit container - WebRTC media does not go through Caddy: # 7881/tcp ICE over TCP, for clients whose network blocks UDP # 50000-50200/udp media # 3478/udp the built-in TURN relay, for clients behind strict NATs # # Email (verification links, password resets) is optional. COMPOSE_PROFILES=mail starts the # bundled outbound-only relay (`mail` below, see README "Email"); or point SMTP_* in .env at # a mail server you already have. Either way the API needs outbound port 25 to be open on # this host only for the bundled relay. services: caddy: image: caddy:2-alpine restart: unless-stopped ports: - "80:80" - "443:443" - "443:443/udp" environment: # :? - without it compose silently substitutes an empty string and you get an # instance that boots, serves no certificate and federates as "". Fail instead. DOMAIN: ${DOMAIN:?not set - run ./setup.sh, or cp .env.example .env and fill it in} ACME_EMAIL: ${ACME_EMAIL:-} # Behind Cloudflare's proxy, Let's Encrypt can't validate: set CLOUDFLARE_ORIGIN_CERT=true # and drop a Cloudflare Origin cert at certs/origin.pem + certs/origin.key. This expands # to a `tls` directive the Caddyfile serves; empty otherwise (Let's Encrypt). See README. CADDY_TLS: ${CLOUDFLARE_ORIGIN_CERT:+tls /certs/origin.pem /certs/origin.key} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro # Cloudflare Origin cert, if you use one (certs/ is git-ignored). Harmless when absent. - ./certs:/certs:ro - caddy-data:/data - caddy-config:/config depends_on: - web - equinox-api - equinox-stargate - nebula web: build: context: ${WEB_SRC:-https://github.com/StrafeChat/web.strafe.chat.git#dev} restart: unless-stopped environment: STRAFE_API_URL: https://${DOMAIN}/api STRAFE_STARGATE_URL: wss://${DOMAIN}/gateway/events STRAFE_CDN_URL: https://${DOMAIN}/cdn # No GIF keys here: everything in this environment ends up in the public config.js. # They go to equinox-api, which searches GIPHY/Heypster on the browser's behalf. scylla: image: scylladb/scylla:5.4 restart: unless-stopped command: --smp ${SCYLLA_SMP:-1} --memory ${SCYLLA_MEMORY:-1G} --overprovisioned 1 volumes: - scylla-data:/var/lib/scylla healthcheck: # Scylla's entrypoint binds CQL to the container's own address # (--rpc-address ), never to loopback, so the obvious # `cqlsh 127.0.0.1 9042` can never connect - every probe fails, and the services # waiting on this one give up after the full retry budget with the database # perfectly healthy behind them. The REST API *does* listen on localhost, and # native_transport returns true exactly when CQL is accepting clients, which is # the condition that matters. It also costs a curl rather than starting a Python # interpreter and a CQL driver every interval. test: ["CMD-SHELL", "curl -sf http://localhost:10000/storage_service/native_transport | grep -q true"] interval: 10s timeout: 5s retries: 36 start_period: 20s # Applies pending CQL migrations (tracked in schema_migrations) and creates the keyspace # on first start. The API and gateway wait for it to finish. -adopt makes a keyspace # that predates the tracking table (migrated by the old cqlsh loop) record the current # files as applied instead of re-running them - some files are destructive. equinox-migrations: build: context: ${EQUINOX_SRC:-https://github.com/StrafeChat/equinox.git#dev} depends_on: scylla: condition: service_healthy command: ["./migrate", "-create-keyspace", "-adopt", "-dir", "/app/migrations"] environment: SCYLLA_HOSTS: scylla SCYLLA_KEYSPACE: strafechat # The migration tool loads the same config as the API and gateway, so it refuses to # start without this too, even though it never touches 2FA data itself. TOTP_ENCRYPTION_KEY: ${TOTP_ENCRYPTION_KEY:?not set - run ./setup.sh, or cp .env.example .env and fill it in} restart: "no" redis: image: redis:7-alpine restart: unless-stopped command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] volumes: - redis-data:/data equinox-api: build: context: ${EQUINOX_SRC:-https://github.com/StrafeChat/equinox.git#dev} restart: unless-stopped depends_on: redis: condition: service_started equinox-migrations: condition: service_completed_successfully nebula: condition: service_started environment: &equinox-env PORT: "4000" STARGATE_PORT: "4001" LOG_LEVEL: ${LOG_LEVEL:-info} SNOWFLAKE_NODE_ID: ${SNOWFLAKE_NODE_ID:-1} SCYLLA_HOSTS: scylla SCYLLA_KEYSPACE: strafechat # Wraps every user's TOTP secret at rest; required, see .env.example. TOTP_ENCRYPTION_KEY: ${TOTP_ENCRYPTION_KEY:?not set - run ./setup.sh, or cp .env.example .env and fill it in} # Passkeys are bound to the exact origin they were created on, so this is where the # relying-party id comes from. Same origin the web client itself is served from. WEB_URL: https://${DOMAIN} REDIS_ADDR: redis:6379 REDIS_CACHE_ENABLED: "true" REDIS_CACHE_PREFIX: "equinox:" # Caddy sits on the compose network; trust its X-Forwarded-For so per-IP rate # limits and session IPs see real clients instead of the proxy. Behind Cloudflare, # also trust Cloudflare's ranges so the real client IP survives the extra hop - # set TRUSTED_PROXIES in .env to "private," (see README). TRUSTED_PROXIES: ${TRUSTED_PROXIES:-private} CORS_ORIGINS: https://${DOMAIN} STARGATE_ALLOWED_ORIGINS: https://${DOMAIN} # Registration. INVITE_ONLY: ${INVITE_ONLY:-false} # Who may start a direct message: shared (friends + people in a common space) or open. PM_POLICY: ${PM_POLICY:-shared} # Refuse throwaway-mail addresses at registration (bundled list), plus your own domains. EMAIL_BLOCK_DISPOSABLE: ${EMAIL_BLOCK_DISPOSABLE:-true} EMAIL_BLOCKED_DOMAINS: ${EMAIL_BLOCKED_DOMAINS:-} # Accounts that may mint instance invites regardless of the database. The first # account registered becomes an administrator on its own; this is the way back in # if that account is lost. INSTANCE_ADMINS: ${INSTANCE_ADMINS:-} CAPTCHA: ${CAPTCHA:-false} CAPTCHA_PROVIDER: ${CAPTCHA_PROVIDER:-altcha} ALTCHA_HMAC_KEY: ${ALTCHA_HMAC_KEY:-} FRIENDLY_CAPTCHA_SITE_KEY: ${FRIENDLY_CAPTCHA_SITE_KEY:-} FRIENDLY_CAPTCHA_API_KEY: ${FRIENDLY_CAPTCHA_API_KEY:-} TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-} TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-} CAP_SITE_KEY: ${CAP_SITE_KEY:-} CAP_SECRET_KEY: ${CAP_SECRET_KEY:-} # What the browser is told to solve against, and how this container reaches Cap. CAP_API_URL: ${CAP_API_URL:-} CAP_INTERNAL_URL: http://cap:3000 # Email: verification links for new accounts and password-reset links. Off while # SMTP_HOST is empty. setup.sh points these at the bundled relay (SMTP_HOST=mail, # SMTP_TLS=none - plaintext is fine on the private `mail` network below) or at a # server of yours. MAIL_FROM defaults to noreply@$DOMAIN inside the API. SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_TLS: ${SMTP_TLS:-starttls} SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} MAIL_FROM: ${MAIL_FROM:-} MAIL_FROM_NAME: ${MAIL_FROM_NAME:-StrafeChat} EMAIL_VERIFICATION: ${EMAIL_VERIFICATION:-false} # Nebula: internal URL for uploads, public URL embedded in asset links. NEBULA_BASE_URL: http://nebula:4010 NEBULA_PUBLIC_URL: https://${DOMAIN}/cdn NEBULA_UPLOAD_SECRET: ${NEBULA_UPLOAD_SECRET:?not set - run ./setup.sh, or cp .env.example .env and fill it in} NEBULA_ATTACHMENT_MAX_MB: ${ATTACHMENT_MAX_MB:-25} # Federation: this instance is reachable as $DOMAIN by other instances. FEDERATION_DOMAIN: ${DOMAIN} FEDERATION_PUBLIC_URL: https://${DOMAIN}/api FEDERATION_GATEWAY_URL: wss://${DOMAIN}/gateway/events FEDERATION_KEY_FILE: /data/federation.key FEDERATION_SIGNING_KEY: ${FEDERATION_SIGNING_KEY:-} FEDERATION_ALLOWLIST: ${FEDERATION_ALLOWLIST:-} FEDERATION_BLOCKLIST: ${FEDERATION_BLOCKLIST:-} # Show spaces listed by the instances you federate with on your Discover page, # and let them show yours. Each space's managers can still opt out of being # shared; set this to false to keep Discover to this instance only. DISCOVER_FEDERATION: ${DISCOVER_FEDERATION:-true} # Voice/video: browsers connect through Caddy, the API talks to LiveKit directly. # Unset (VOICE=false) the three are empty and every call control disappears. LIVEKIT_URL: ${LIVEKIT_URL_OVERRIDE:-wss://${DOMAIN}/livekit} LIVEKIT_INTERNAL_URL: http://livekit:7880 LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-} LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-} # Push notifications to signed-in phones (docs/PUSH.md). Standard Web Push, encrypted # to each device; no Google/Apple credentials. The VAPID key is generated and stored # in the database when PUSH_VAPID_PRIVATE_KEY is empty. PUSH_ENABLED: ${PUSH_ENABLED:-true} PUSH_VAPID_PRIVATE_KEY: ${PUSH_VAPID_PRIVATE_KEY:-} PUSH_VAPID_SUBJECT: ${PUSH_VAPID_SUBJECT:-https://${DOMAIN}} PUSH_ALLOWED_HOSTS: ${PUSH_ALLOWED_HOSTS:-} PUSH_WORKERS: ${PUSH_WORKERS:-4} # GIF picker. The API calls GIPHY/Heypster itself (GET /gifs/*), so the keys stay on # this host and searches come from the instance rather than each user's browser. Same # .env names as when the web container took them, so existing instances keep working. GIPHY_API_KEY: ${STRAFE_GIPHY_API_KEY:-} HEYPSTER_API_KEY: ${STRAFE_HEYPSTER_API_KEY:-} volumes: - federation-data:/data # The API is the one service that can reach the mail relay (the `mail` network is # shared with nothing else), which is what lets the relay accept mail without a login. networks: - default - mail equinox-stargate: build: context: ${EQUINOX_SRC:-https://github.com/StrafeChat/equinox.git#dev} restart: unless-stopped command: ["./stargate"] depends_on: redis: condition: service_started equinox-migrations: condition: service_completed_successfully environment: <<: *equinox-env # The gateway never calls a GIF provider; keep those keys in the API alone. GIPHY_API_KEY: "" HEYPSTER_API_KEY: "" volumes: - federation-data:/data nebula: build: context: ${NEBULA_SRC:-https://github.com/StrafeChat/nebula.git#dev} restart: unless-stopped environment: PORT: "4010" HTTP_BODY_LIMIT_MB: ${ATTACHMENT_MAX_MB:-25} CACHE_CONTROL_MAX_AGE: "86400" UPLOAD_SECRET: ${NEBULA_UPLOAD_SECRET} # Browsers on *other* instances must be able to fetch attachments from this CDN # (encrypted attachments are downloaded with fetch(), which needs CORS). Leave # empty to allow any origin, or list the instances you federate with. CORS_ORIGINS: ${NEBULA_CORS_ORIGINS:-} # Serve the Unicode emoji sets from this instance instead of a third-party CDN, by # seeding them into the store on start (background, idempotent). Set to false to skip - # e.g. an s3 replica whose bucket a primary already seeded. SEED_EMOJI: ${SEED_EMOJI:-true} # fs (the nebula-data volume) or s3 (any S3-compatible bucket). STORAGE_BACKEND: ${STORAGE_BACKEND:-fs} DATA_DIR: /data S3_BUCKET: ${S3_BUCKET:-} S3_ENDPOINT: ${S3_ENDPOINT:-} S3_REGION: ${S3_REGION:-us-east-1} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-} S3_PREFIX: ${S3_PREFIX:-} S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-false} volumes: - nebula-data:/data # Voice/video SFU (LiveKit). Signalling is proxied by Caddy at /livekit; media uses the # ports published here. The whole configuration is generated from .env below. livekit: image: livekit/livekit-server:v1.13 restart: unless-stopped command: --config /etc/livekit.yaml ports: - "7881:7881/tcp" - "50000-50200:50000-50200/udp" - "3478:3478/udp" environment: LIVEKIT_CONFIG: | port: 7880 rtc: tcp_port: 7881 port_range_start: 50000 port_range_end: 50200 # Discover the public IP through STUN so the advertised candidates are the # ones clients can actually reach. LIVEKIT_NODE_IP pins it instead. use_external_ip: ${LIVEKIT_USE_EXTERNAL_IP:-true} node_ip: ${LIVEKIT_NODE_IP:-} keys: ${LIVEKIT_API_KEY:-unset}: ${LIVEKIT_API_SECRET:-unset} webhook: api_key: ${LIVEKIT_API_KEY:-unset} urls: - http://equinox-api:4000/voice/webhook turn: enabled: true udp_port: 3478 domain: ${DOMAIN} room: empty_timeout: 60 departure_timeout: 10 logging: level: ${LIVEKIT_LOG_LEVEL:-warn} # Cap (trycap.dev): the optional self-hosted captcha, for instances that pick # CAPTCHA_PROVIDER=cap instead of the built-in ALTCHA. Only started when you ask for it: # COMPOSE_PROFILES=cap docker compose up -d # Caddy proxies it at /cap, which is both where the browser solves the challenge and # where the dashboard lives (sign in with CAP_ADMIN_KEY, create a site, and paste its # keys into .env). It keeps its own state in the instance's Redis, on a database of its # own so it cannot collide with the API's keys. cap: image: tiago2/cap:latest profiles: ["cap"] restart: unless-stopped depends_on: redis: condition: service_started environment: ADMIN_KEY: ${CAP_ADMIN_KEY:-} REDIS_URL: redis://redis:6379/3 # The bundled outbound mail relay (maddy), for instances that send their own email: # COMPOSE_PROFILES=mail docker compose up -d # Send-only: it takes mail from the API, signs it with the DKIM key it generates on first # start, and delivers it to the recipients' servers itself (outbound port 25). It # publishes no port, receives nothing and has no mailboxes; the API reaches it over the # `mail` network, which no other container is on. The DKIM record to publish: # docker compose exec mail cat /data/dkim_keys/$DOMAIN_$MAIL_DKIM_SELECTOR.dns # DNS checklist (A + PTR for MAIL_HOSTNAME, SPF, DKIM, DMARC) in docs/EMAIL.md. mail: image: foxcpp/maddy:0.9 profiles: ["mail"] restart: unless-stopped environment: MAIL_DOMAIN: ${DOMAIN} MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.${DOMAIN}} MAIL_DKIM_SELECTOR: ${MAIL_DKIM_SELECTOR:-strafe} volumes: - ./mail/maddy.conf:/data/maddy.conf:ro - mail-data:/data networks: - mail networks: default: # Private link between the API and the mail relay; nothing else is attached. mail: volumes: caddy-data: caddy-config: scylla-data: redis-data: nebula-data: federation-data: mail-data: