# occupymedia.cc — APP CT (occ-app, CT 150, tailscale 100.78.162.2) # Control room: postgres/redis/temporal + api/scheduler/web/media-worker + the tunnel. # Ports bind 0.0.0.0 in the CT: public access is ONLY via the Cloudflare tunnel # (ingress: app/api → 100.78.162.2). LAN/tailnet can reach them directly. name: occ-app services: postgres: image: postgres:16-alpine environment: POSTGRES_USER: ${POSTGRES_USER:-occupymedia} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} POSTGRES_DB: ${POSTGRES_DB:-occupymedia} volumes: [pgdata:/var/lib/postgresql/data] healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-occupymedia}"] interval: 5s retries: 10 restart: unless-stopped redis: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes"] volumes: [redisdata:/data] healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s retries: 10 restart: unless-stopped temporal: image: temporalio/auto-setup:1.24 depends_on: postgres: { condition: service_healthy } environment: DB: postgres12 DB_PORT: 5432 POSTGRES_USER: ${POSTGRES_USER:-occupymedia} POSTGRES_PWD: ${POSTGRES_PASSWORD:?} POSTGRES_SEEDS: postgres restart: unless-stopped api: build: context: ../.. dockerfile: infra/occupymedia/Dockerfile args: { APP: api } environment: &appenv PORT: "3000" # Runtime pools use the non-owner role; owner access is offline only. DATABASE_URL: ${DATABASE_URL:?Set the sp_app runtime URL} REDIS_HOST: redis REDIS_URL: redis://redis:6379 POSTGRES_HOST: postgres POSTGRES_PORT: "5432" TEMPORAL_HOST: temporal TEMPORAL_PORT: "7233" TEMPORAL_ADDRESS: "temporal:7233" S3_ENDPOINT: https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com S3_REGION: auto S3_ACCESS_KEY: ${R2_ACCESS_KEY:-unset} S3_SECRET_KEY: ${R2_SECRET_KEY:-unset} S3_BUCKET: occupymedia-media-encrypted S3_KEK: ${S3_KEK:?} SUPERPOSTER_KEK: ${SUPERPOSTER_KEK:?} SESSION_SECRET: ${SESSION_SECRET:?} CORS_ORIGINS: https://app.occupymedia.cc,https://files.occupymedia.cc PUBLIC_ORIGIN: https://app.occupymedia.cc WEB_ORIGIN: https://app.occupymedia.cc API_PUBLIC_URL: https://api.occupymedia.cc # OAuth routing: providers redirect the browser to CONNECTIONS_CALLBACK_URL # after consent; the API then lands it on APP_URL. Both must be public — # unset, both default to localhost and the flow breaks silently. APP_URL: https://app.occupymedia.cc CONNECTIONS_CALLBACK_URL: https://api.occupymedia.cc/connections/callback PEERTUBE_INSTANCE_URL: https://tube.occupymedia.cc PEERTUBE_AUTHORIZE_PATH: /oauth/authorize PEERTUBE_TOKEN_PATH: /api/v1/users/token # Group files: oCIS basic-auth WebDAV (values live in the compose .env) FILES_OCID_WEBDAV_URL: ${FILES_OCID_WEBDAV_URL:?} FILES_OCID_USER: ${FILES_OCID_USER:?} FILES_OCID_PASSWORD: ${FILES_OCID_PASSWORD:?} FILES_PUBLIC_BASE: ${FILES_PUBLIC_BASE:-https://files.occupymedia.cc} ARCHIVE_PROVISIONER_HOMESERVER_URL: ${ARCHIVE_PROVISIONER_HOMESERVER_URL:-} ARCHIVE_PROVISIONER_USER: ${ARCHIVE_PROVISIONER_USER:-} ARCHIVE_PROVISIONER_PASSWORD: ${ARCHIVE_PROVISIONER_PASSWORD:-} # In-app Chat sign-in: appservice tokens, see appservice.hub in tuwunel.toml. # Unset = the Chat view falls back to Element's own sign-in screen. MATRIX_SSO_JWT_SECRET: ${MATRIX_SSO_JWT_SECRET:-} MATRIX_SSO_AS_TOKEN: ${MATRIX_SSO_AS_TOKEN:-} MATRIX_SSO_HS_TOKEN: ${MATRIX_SSO_HS_TOKEN:-} MATRIX_SSO_HOMESERVER_URL: ${MATRIX_SSO_HOMESERVER_URL:-https://chat.occupymedia.cc} MATRIX_SSO_LOCALPART_PREFIX: ${MATRIX_SSO_LOCALPART_PREFIX:-} # In-app Drive: oCIS web origin + auth mode (interactive | oidc). FILES_DRIVE_URL: ${FILES_DRIVE_URL:-https://files.occupymedia.cc} FILES_DRIVE_AUTH: ${FILES_DRIVE_AUTH:-interactive} # Publishing workflows and the scheduler worker MUST share one queue, # else approved publishes sit unconsumed. TEMPORAL_TASK_QUEUE: ${TEMPORAL_TASK_QUEUE:-tmbh-publishing} # Hub-as-OIDC-issuer signing keys (see docs/design/2026-10-13-files-oidc-sso.md). OIDC_KEY_DIR: /var/lib/tmbh/oidc volumes: - oidckeys:/var/lib/tmbh/oidc ports: ["3000:3000"] depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } restart: unless-stopped scheduler: build: context: ../.. dockerfile: infra/occupymedia/Dockerfile args: { APP: scheduler } environment: <<: *appenv PORT: "3010" SCHEDULER_DATABASE_URL: ${SCHEDULER_DATABASE_URL:?Set the sp_app runtime URL} SCHEDULER_HEALTH_PORT: "8081" TEMPORAL_TASK_QUEUE: ${TEMPORAL_TASK_QUEUE:-tmbh-publishing} healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8081/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 10s timeout: 5s start_period: 30s retries: 3 depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } temporal: { condition: service_started } restart: unless-stopped web: build: context: ../.. dockerfile: infra/occupymedia/Dockerfile args: APP: web NEXT_PUBLIC_API_URL: https://api.occupymedia.cc environment: <<: *appenv PORT: "3001" ports: ["3001:3001"] depends_on: [api] restart: unless-stopped media-worker: build: context: ../.. dockerfile: infra/media-worker.Dockerfile environment: <<: *appenv PORT: "3100" MEDIA_WORKER_ID: worker-1 MEDIA_FENCE_PATH: /var/lib/media-worker/fence.json POSTGRES_HOST: postgres REDIS_HOST: redis # cross-CT: peertube RTMP on occ-tube via tailscale PEERTUBE_PUSH_URL: rtmp://100.92.231.98:1935/live volumes: [mediaworkerstate:/var/lib/media-worker] depends_on: redis: { condition: service_healthy } restart: unless-stopped # Produce worker (editor v2): claims video_processing_jobs kind compose|mix, # renders assembled/overlaid/voiced cuts, registers derived library assets. # Deliberately its own process: a remote-lane box needs only Postgres + # object storage + ffmpeg (see apps/media-worker/src/produce.ts header). produce-worker: build: context: ../.. dockerfile: infra/media-worker.Dockerfile command: ["pnpm", "--filter", "@tactical-media-broadcast-hub/media-worker", "exec", "tsx", "src/produce.ts"] environment: <<: *appenv MEDIA_WORKER_LANE: local MEDIA_SCRATCH_DIR: /var/lib/media-worker/produce volumes: [mediaworkerstate:/var/lib/media-worker] depends_on: postgres: { condition: service_healthy } restart: unless-stopped cloudflared: image: cloudflare/cloudflared:latest command: ["tunnel", "--no-autoupdate", "run", "--token", "${CLOUDFLARE_TUNNEL_TOKEN:?}"] restart: unless-stopped volumes: pgdata: redisdata: mediaworkerstate: oidckeys: