Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
27
infra/occupymedia/.env.example
Normal file
27
infra/occupymedia/.env.example
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# occupymedia.cc deployment secrets — copy to .env on the box, fill every :? value.
|
||||
# NEVER commit the filled .env.
|
||||
|
||||
# --- Cloudflare (already provisioned; tunnel + DNS + R2 buckets exist) ---
|
||||
CLOUDFLARE_ACCOUNT_ID=e50adafec3c6aee02a328f28b967f699
|
||||
CLOUDFLARE_TUNNEL_TOKEN=<fromkeepass-or-ask-coordinator> # occupymedia tunnel e5695d8b-…
|
||||
|
||||
# --- R2 (S3 API token: dashboard → R2 → Manage API Tokens → Object Read & Write,
|
||||
# scoped to the four occupymedia-* buckets) ---
|
||||
R2_ACCESS_KEY=<paste R2 Access Key ID>
|
||||
R2_SECRET_KEY=<paste R2 Secret Access Key>
|
||||
|
||||
# --- Database ---
|
||||
POSTGRES_PASSWORD=<generate: openssl rand -hex 24>
|
||||
# Runtime-only role provisioned by migration 003. Set its production password
|
||||
# with owner access; never reuse POSTGRES_PASSWORD or the development default.
|
||||
DATABASE_URL=postgres://sp_app:<runtime-password>@postgres:5432/occupymedia
|
||||
SCHEDULER_DATABASE_URL=postgres://sp_app:<runtime-password>@postgres:5432/occupymedia
|
||||
# SUPER_DATABASE_URL is passed only to the offline migration CLI, never services.
|
||||
PEERTUBE_DB_PASSWORD=<generate: openssl rand -hex 24> # also paste into peertube.env
|
||||
SESSION_SECRET=<generate: openssl rand -base64 32>
|
||||
|
||||
# --- Client-side envelope encryption for the media archive ---
|
||||
# One random 32-byte base64 key. It wraps EVERY stored blob (DEK/KEK envelope).
|
||||
# Lose it = archive unreadable. Rotate = full re-encrypt. Store in the password manager.
|
||||
S3_KEK=<generate ONCE: openssl rand -base64 32>
|
||||
SUPERPOSTER_KEK=<same bytes as S3_KEK, base64>
|
||||
130
infra/occupymedia/DEPLOY.md
Normal file
130
infra/occupymedia/DEPLOY.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Deploy — occupymedia.cc
|
||||
|
||||
**Storage phase-one addendum:** the current deployment uses separate app/chat/
|
||||
PeerTube CTs. New files/docs work is an independent, undeployed scaffold;
|
||||
see [files runbook](files/README.md) and [storage contracts](../../docs/occupymedia-storage.md).
|
||||
The older one-box instructions below do not deploy that stack. Live conduwuit
|
||||
v0.5.0 still has local media; its volumes require backup. R2 durability alone
|
||||
does not back up media or the metadata needed to resolve it.
|
||||
|
||||
One box, everything behind the Cloudflare tunnel (already provisioned: zone
|
||||
`occupymedia.cc`, tunnel `occupymedia` with ingress app/api/tube/chat, R2
|
||||
buckets `occupymedia-media-encrypted`, `-peertube-videos`, `-peertube-playlists`,
|
||||
`-matrix-media`). No inbound ports. All local ports bind to 127.0.0.1.
|
||||
|
||||
## WP3 release on the app CT (150)
|
||||
|
||||
Use `docker compose -f docker-compose.app.yml` in this directory. Before
|
||||
syncing source, create and verify an owner `pg_dump` in
|
||||
`/opt/tactical-media-broadcast-hub/backups/`. Preserve the previous source for rollback.
|
||||
|
||||
Both `DATABASE_URL` and `SCHEDULER_DATABASE_URL` in the CT `.env` must connect
|
||||
as `sp_app`, without superuser or BYPASSRLS privileges. Provision its password
|
||||
with owner access and keep real values only in the CT `.env`. Keep the
|
||||
existing `SUPERPOSTER_KEK`; scheduler inherits it and `TEMPORAL_ADDRESS`.
|
||||
|
||||
Stop the old scheduler, then run the new API migration CLI offline with
|
||||
`SUPER_DATABASE_URL` set to the owner URL:
|
||||
`pnpm --filter @tactical-media-broadcast-hub/api exec tsx src/db/migrate.cli.ts --dir migrations`.
|
||||
The CLI skips recorded migrations; 019 tracks resumable transactional, batch,
|
||||
and concurrent-index phases. Confirm 018 and 019 in `schema_migrations` and
|
||||
check `assertPublishingSchema` using the runtime URL before starting new code.
|
||||
|
||||
Build `api scheduler web`, then run `up -d --no-deps api`, followed by
|
||||
`scheduler` and `web`. Verify API health/readiness, invite regressions, an
|
||||
existing username/password login, scheduler readiness and Temporal connectivity,
|
||||
and the public web app. Defer production publish tests until explicitly requested.
|
||||
For rollback, restore source from the previous commit, re-sync, rebuild and
|
||||
restart the three services; do not undo migrations automatically.
|
||||
|
||||
## 0. Prerequisites on the box
|
||||
|
||||
- Debian 12 / Ubuntu 24.04, 4+ vCPU, 8 GB RAM, 100 GB disk
|
||||
- Docker Engine + compose plugin (`curl -fsSL https://get.docker.com | sh`)
|
||||
- git
|
||||
|
||||
## 1. Get the code + secrets
|
||||
|
||||
```bash
|
||||
git clone git@gitlab.example.com:mediatools/tacticalmediahub.git
|
||||
cd tactical-media-broadcast-hub/infra/occupymedia
|
||||
|
||||
cp .env.example .env
|
||||
# Fill: CLOUDFLARE_TUNNEL_TOKEN, R2_ACCESS_KEY, R2_SECRET_KEY (dashboard → R2 →
|
||||
# Manage API Tokens → Object Read & Write, scoped to the occupymedia-* buckets),
|
||||
# POSTGRES_PASSWORD, PEERTUBE_DB_PASSWORD, S3_KEK (openssl rand -hex 32 — ONE TIME,
|
||||
# it wraps every stored blob; lose it = archive unreadable)
|
||||
|
||||
# peertube.env: set both PEERTUBE_DB_PASSWORD / POSTGRES_PASSWORD lines to the
|
||||
# value from .env, and set a registration/SMTP sender if you have them.
|
||||
```
|
||||
|
||||
## 2. Build + start the core
|
||||
|
||||
```bash
|
||||
docker compose build api scheduler web media-worker
|
||||
docker compose up -d postgres redis temporal peertube-postgres
|
||||
```
|
||||
|
||||
## 3. Apply API migrations (ordered, once)
|
||||
|
||||
```bash
|
||||
cd ../..
|
||||
for f in apps/api/migrations/*.sql; do
|
||||
docker compose -f infra/occupymedia/docker-compose.yml exec -T postgres \
|
||||
psql -U "$(grep -oP '(?<=POSTGRES_USER:)\S+' infra/occupymedia/docker-compose.yml | head -1 | tr -d '${:-occupymedia}')" \
|
||||
-d occupymedia -v ON_ERROR_STOP=1 < "$f" || { echo "FAILED at $f"; break; }
|
||||
done
|
||||
```
|
||||
|
||||
(If psql errors on an already-applied migration — re-runs are idempotent-safe
|
||||
for most files; check the failing file before continuing.)
|
||||
|
||||
## 4. Bring everything up
|
||||
|
||||
```bash
|
||||
docker compose -f infra/occupymedia/docker-compose.yml up -d
|
||||
docker compose ps # everything Up/healthy
|
||||
```
|
||||
|
||||
## 5. First-boot steps
|
||||
|
||||
1. **Tunnel**: `docker compose logs cloudflared` — should show 4 connections
|
||||
registered. Then `https://app.occupymedia.cc` (control room), `/healthz` on
|
||||
api., tube., chat. should all answer.
|
||||
2. **PeerTube admin**: `docker compose logs peertube 2>&1 | grep -i -A3 password`
|
||||
— random root password printed ONCE. Log in at tube.occupymedia.cc, change
|
||||
it, then run the brand sync (`docs/peertube-setup.md`) and create the live
|
||||
channel.
|
||||
3. **Matrix admin**: register the first account at chat.occupymedia.cc with the
|
||||
token from conduwuit.toml, then set `allow_registration = false` and
|
||||
`docker compose restart conduwuit`. Configure Element as client
|
||||
(`docs/matrix-setup.md`).
|
||||
4. **Telegram bridge** (optional now): `docker compose --profile bridges run --rm
|
||||
mautrix-telegram` → interactive login with the bot/user token; then
|
||||
`docker compose --profile bridges up -d mautrix-telegram`.
|
||||
|
||||
## 6. Live ingest (deferred)
|
||||
|
||||
RTMP/SRT cannot traverse the tunnel. Options documented in the session notes:
|
||||
tiny relay VPS + WireGuard to this box (recommended; keeps the home IP out of
|
||||
DNS), or direct port-forward. Until then the 24/7 channel runs playlists/VOD
|
||||
(media-worker pushes to PeerTube internally over the docker network).
|
||||
|
||||
## 7. Operations
|
||||
|
||||
- **Backup**: `docker compose exec postgres pg_dump -U occupymedia occupymedia |
|
||||
gzip > backup-$(date +%F).sql.gz` + `tar` the named volumes (pgdata,
|
||||
peertube_pgdata, conduwuit_data). Media itself is in R2 (durable) — KEK
|
||||
backup matters more than volume backup.
|
||||
- **Update**: `git pull && docker compose build api scheduler web media-worker &&
|
||||
docker compose up -d` (migrations: repeat step 3 for NEW files only).
|
||||
- **Logs**: `docker compose logs -f --tail=100 <service>`.
|
||||
|
||||
## Known simplifications (first-production-pass)
|
||||
|
||||
- observability (otel/prometheus/grafana) not included — add from
|
||||
infra/docker-compose.yml services when needed
|
||||
- single app instance per service (no horizontal scaling — intended at this size)
|
||||
- file-sharing/collab layer: oCIS S3NG + optional Collabora scaffold in
|
||||
`files/compose.yml`; deployment and Matrix migration remain separate prerequisites
|
||||
25
infra/occupymedia/Dockerfile
Normal file
25
infra/occupymedia/Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Monorepo app builder for occupymedia.cc — APP build-arg: api | scheduler | web
|
||||
# (media-worker has its own image: ../../infra/media-worker.Dockerfile)
|
||||
FROM node:22-slim
|
||||
ARG APP
|
||||
ARG NEXT_PUBLIC_API_URL=https://api.occupymedia.cc
|
||||
ENV APP_MODE=${APP} NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY . .
|
||||
# Full workspace tree present so every workspace:* dependency resolves.
|
||||
# Do NOT "optimize" to a filtered install — scheduler/web builds broke on
|
||||
# missing build tools (tsc) and missing workspace manifests (connector-sdk).
|
||||
RUN NODE_ENV=development npx --yes pnpm@9.12.0 install --frozen-lockfile
|
||||
# egress is native-imported by path at runtime (NOT a package.json dep), so the
|
||||
# --filter app build never builds it — skip this line and the api crashes at boot.
|
||||
RUN npx --yes pnpm@9.12.0 --filter @tactical-media-broadcast-hub/egress build
|
||||
RUN npx --yes pnpm@9.12.0 --filter @tactical-media-broadcast-hub/${APP}... build
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
CMD ["sh", "-c", "case \"$APP_MODE\" in \
|
||||
api) exec node /app/apps/api/dist/apps/api/src/main.js ;; \
|
||||
scheduler) exec node /app/apps/scheduler/dist/worker.js ;; \
|
||||
web) cd /app/apps/web && exec node ./node_modules/next/dist/bin/next start -H 0.0.0.0 -p \"${PORT:-3001}\" ;; \
|
||||
*) echo \"unknown APP $APP_MODE\" && exit 1 ;; \
|
||||
esac"]
|
||||
27
infra/occupymedia/conduwuit.toml
Normal file
27
infra/occupymedia/conduwuit.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Conduwuit — Matrix homeserver for occupymedia.cc (chat plane)
|
||||
# Mounted read-only by infra/occupymedia/docker-compose.yml.
|
||||
# Docs: docs/matrix-setup.md (branding, bridges, Tactical Media Broadcast Hub integration)
|
||||
|
||||
[global]
|
||||
server_name = "chat.occupymedia.cc" # user IDs become @name:chat.occupymedia.cc — PERMANENT
|
||||
address = "0.0.0.0"
|
||||
port = 6167 # mapped to 127.0.0.1:8448 → Cloudflare tunnel (chat.occupymedia.cc)
|
||||
|
||||
database_path = "/var/lib/conduwuit"
|
||||
database_backend = "rocksdb"
|
||||
|
||||
# 20 MB uploads (Matrix media). Bigger files belong in the file-sharing layer.
|
||||
max_request_size = 20_000_000
|
||||
|
||||
# First deploy: register the admin account with the token, then set
|
||||
# allow_registration = false and `docker compose restart conduwuit`.
|
||||
allow_registration = true
|
||||
registration_token = "CHANGE-ME-on-deploy"
|
||||
|
||||
# Federation: on = your users can join rooms on other homeservers, other servers
|
||||
# can join yours (via chat.occupymedia.cc through the tunnel). Set false for a
|
||||
# closed chat island (restart required).
|
||||
allow_federation = true
|
||||
allow_room_creation = true
|
||||
|
||||
# Rate limiting defaults are sane; tune only under load.
|
||||
148
infra/occupymedia/docker-compose.app.yml
Normal file
148
infra/occupymedia/docker-compose.app.yml
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# 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
|
||||
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
|
||||
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"
|
||||
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
|
||||
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
command: ["tunnel", "--no-autoupdate", "run", "--token", "${CLOUDFLARE_TUNNEL_TOKEN:?}"]
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
mediaworkerstate:
|
||||
36
infra/occupymedia/docker-compose.chat.yml
Normal file
36
infra/occupymedia/docker-compose.chat.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# occupymedia.cc — CHAT CT (occ-chat, CT 152, tailscale 100.117.210.52)
|
||||
# Matrix plane: conduwuit (+ optional Telegram bridge, one-time interactive login).
|
||||
|
||||
name: occ-chat
|
||||
|
||||
services:
|
||||
conduwuit:
|
||||
image: girlbossceo/conduwuit:latest
|
||||
environment:
|
||||
CONDUWUIT_SERVER_NAME: chat.occupymedia.cc
|
||||
CONDUWUIT_ADDRESS: 0.0.0.0
|
||||
CONDUWUIT_PORT: "6167"
|
||||
CONDUWUIT_DATABASE_PATH: /var/lib/conduwuit
|
||||
CONDUWUIT_DATABASE_BACKEND: rocksdb
|
||||
CONDUWUIT_MAX_REQUEST_SIZE: "20000000"
|
||||
CONDUWUIT_ALLOW_REGISTRATION: "true"
|
||||
CONDUWUIT_REGISTRATION_TOKEN: "${MATRIX_REGISTRATION_TOKEN:?}"
|
||||
CONDUWUIT_ALLOW_FEDERATION: "true"
|
||||
CONDUWUIT_ALLOW_ROOM_CREATION: "true"
|
||||
volumes:
|
||||
- conduwuit_data:/var/lib/conduwuit
|
||||
ports:
|
||||
- "6167:6167" # tunnel: chat.occupymedia.cc → 100.117.210.52:6167
|
||||
restart: unless-stopped
|
||||
|
||||
# First run is interactive: docker compose run --rm mautrix-telegram
|
||||
mautrix-telegram:
|
||||
image: dock.mau.dev/mautrix/telegram:latest
|
||||
volumes: [mautrix_telegram_data:/data]
|
||||
depends_on: [conduwuit]
|
||||
profiles: ["bridges"]
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
conduwuit_data:
|
||||
mautrix_telegram_data:
|
||||
52
infra/occupymedia/docker-compose.tube.yml
Normal file
52
infra/occupymedia/docker-compose.tube.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# occupymedia.cc — TUBE CT (occ-tube, CT 151, tailscale 100.92.231.98)
|
||||
# PeerTube plane: peertube + dedicated postgres + redis + postfix relay.
|
||||
# Day 1: local storage on the CT volume (500G thin). Flip to R2 by setting
|
||||
# PEERTUBE_OBJECT_STORAGE_ENABLED=true + keys in peertube.env, then recreate.
|
||||
|
||||
name: occ-tube
|
||||
|
||||
services:
|
||||
peertube-postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: peertube
|
||||
POSTGRES_PASSWORD: ${PEERTUBE_DB_PASSWORD:?}
|
||||
POSTGRES_DB: peertube
|
||||
volumes: [peertube_pgdata:/var/lib/postgresql/data]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U peertube"]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
peertube-redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
postfix:
|
||||
image: mwader/postfix-relay
|
||||
environment:
|
||||
- POSTFIX_myhostname=tube.occupymedia.cc
|
||||
restart: unless-stopped
|
||||
|
||||
peertube:
|
||||
image: chocobozzz/peertube:production-bookworm
|
||||
env_file: peertube.env
|
||||
ports:
|
||||
- "9000:9000" # tunnel: tube.occupymedia.cc → 100.92.231.98:9000
|
||||
- "1935:1935" # RTMP ingest from media-worker (cross-CT via tailscale)
|
||||
volumes:
|
||||
- peertube_config:/app/config
|
||||
- peertube_assets:/app/storage/client-overrides
|
||||
- peertube_data:/app/storage
|
||||
depends_on:
|
||||
peertube-postgres: { condition: service_healthy }
|
||||
peertube-redis: { condition: service_started }
|
||||
postfix: { condition: service_started }
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
peertube_pgdata:
|
||||
peertube_config:
|
||||
peertube_assets:
|
||||
peertube_data:
|
||||
185
infra/occupymedia/docker-compose.yml
Normal file
185
infra/occupymedia/docker-compose.yml
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# Production deployment — occupymedia.cc
|
||||
# One box, everything behind the Cloudflare tunnel (no inbound ports).
|
||||
# See DEPLOY.md. Ports bind to 127.0.0.1 ONLY — the tunnel reaches them on localhost.
|
||||
|
||||
name: occupymedia
|
||||
|
||||
services:
|
||||
# ---------- core ----------
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-occupymedia}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
|
||||
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
|
||||
|
||||
# ---------- app (built from the repo; see Dockerfile) ----------
|
||||
api:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: infra/occupymedia/Dockerfile
|
||||
args: { APP: api }
|
||||
environment: &appenv
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-occupymedia}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-occupymedia}
|
||||
REDIS_HOST: redis
|
||||
TEMPORAL_HOST: temporal:7233
|
||||
S3_ENDPOINT: https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com
|
||||
S3_REGION: auto
|
||||
S3_ACCESS_KEY: ${R2_ACCESS_KEY:?}
|
||||
S3_SECRET_KEY: ${R2_SECRET_KEY:?}
|
||||
S3_BUCKET: occupymedia-media-encrypted
|
||||
S3_KEK: ${S3_KEK:?generate once, never rotate casually — it wraps every stored blob}
|
||||
WEB_ORIGIN: https://app.occupymedia.cc
|
||||
API_PUBLIC_URL: https://api.occupymedia.cc
|
||||
ports: ["127.0.0.1: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"
|
||||
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: ["127.0.0.1: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
|
||||
PEERTUBE_PUSH_URL: rtmp://peertube:1935/live # internal; live ingest only
|
||||
volumes: [mediaworkerstate:/var/lib/media-worker]
|
||||
depends_on:
|
||||
redis: { condition: service_healthy }
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------- front door ----------
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
command: ["tunnel", "--no-autoupdate", "run"]
|
||||
environment:
|
||||
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:?}
|
||||
depends_on: [api, web, peertube, conduwuit]
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------- video plane ----------
|
||||
peertube:
|
||||
image: chocobozzz/peertube:production-bookworm
|
||||
env_file: peertube.env
|
||||
ports: ["127.0.0.1:9000:9000"]
|
||||
volumes:
|
||||
- peertube_config:/app/config
|
||||
- peertube_assets:/app/storage/client-overrides
|
||||
depends_on:
|
||||
peertube-postgres: { condition: service_healthy }
|
||||
peertube-redis: { condition: service_started }
|
||||
postfix: { condition: service_started }
|
||||
restart: unless-stopped
|
||||
|
||||
peertube-postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: peertube
|
||||
POSTGRES_PASSWORD: ${PEERTUBE_DB_PASSWORD:?set in peertube.env}
|
||||
POSTGRES_DB: peertube
|
||||
volumes: [peertube_pgdata:/var/lib/postgresql/data]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U peertube"]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
peertube-redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
postfix:
|
||||
image: mwader/postfix-relay
|
||||
environment:
|
||||
- POSTFIX_myhostname=tube.occupymedia.cc
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------- chat plane ----------
|
||||
conduwuit:
|
||||
image: girlbossceo/conduwuit:latest
|
||||
volumes:
|
||||
- conduwuit_data:/var/lib/conduwuit
|
||||
- ./conduwuit.toml:/etc/conduwuit/conduwuit.toml:ro
|
||||
ports: ["127.0.0.1:8448:6167"]
|
||||
restart: unless-stopped
|
||||
|
||||
# Telegram bridge — first run is interactive: docker compose run --rm mautrix-telegram
|
||||
mautrix-telegram:
|
||||
image: dock.mau.dev/mautrix/telegram:latest
|
||||
volumes: [mautrix_telegram_data:/data]
|
||||
depends_on: [conduwuit]
|
||||
profiles: ["bridges"]
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
mediaworkerstate:
|
||||
peertube_pgdata:
|
||||
peertube_config:
|
||||
peertube_assets:
|
||||
conduwuit_data:
|
||||
mautrix_telegram_data:
|
||||
42
infra/occupymedia/peertube.env
Normal file
42
infra/occupymedia/peertube.env
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# PeerTube production config — occupymedia.cc (used as env_file by docker-compose.yml)
|
||||
# Image: chocobozzz/peertube:production-bookworm. Pin to a version tag at first deploy.
|
||||
|
||||
PEERTUBE_WEBSERVER_HOSTNAME=tube.occupymedia.cc
|
||||
PEERTUBE_WEBSERVER_PORT=443
|
||||
PEERTUBE_WEBSERVER_HTTPS=true
|
||||
PEERTUBE_TRUST_PROXY=true
|
||||
|
||||
# First boot: the entrypoint generates the admin user with a random password,
|
||||
# printed once in the logs: `docker compose logs peertube | grep -A2 'user root'`.
|
||||
# Change it immediately, then enable the Tactical Media Broadcast Hub connector bridge.
|
||||
|
||||
# --- database (dedicated peertube-postgres service) ---
|
||||
PEERTUBE_DB_HOSTNAME=peertube-postgres
|
||||
PEERTUBE_DB_USERNAME=peertube
|
||||
PEERTUBE_DB_PASSWORD=<set-same-as-below>
|
||||
POSTGRES_USER=peertube
|
||||
POSTGRES_PASSWORD=<set-same-as-above>
|
||||
POSTGRES_DB=peertube
|
||||
|
||||
# --- object storage on R2 (zero egress) ---
|
||||
PEERTUBE_OBJECT_STORAGE_ENABLED=true
|
||||
PEERTUBE_OBJECT_STORAGE_ENDPOINT=https://e50adafec3c6aee02a328f28b967f699.r2.cloudflarestorage.com
|
||||
PEERTUBE_OBJECT_STORAGE_REGION=auto
|
||||
PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC_BUCKET=occupymedia-peertube-videos
|
||||
PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE_BUCKET=occupymedia-peertube-playlists
|
||||
PEERTUBE_OBJECT_STORAGE_CREDENTIALS_ACCESS_KEY=<R2_ACCESS_KEY>
|
||||
PEERTUBE_OBJECT_STORAGE_CREDENTIALS_SECRET_KEY=<R2_SECRET_KEY>
|
||||
# Videos are served through tube.occupymedia.cc (Cloudflare caches HLS segments);
|
||||
# keep proxied URL mode so playback keys stay server-controlled.
|
||||
PEERTUBE_OBJECT_STORAGE_PROXY=PRIVACY
|
||||
|
||||
# --- email via the postfix relay ---
|
||||
PEERTUBE_SMTP_HOSTNAME=postfix
|
||||
PEERTUBE_SMTP_PORT=25
|
||||
PEERTUBE_SMTP_FROM=noreply@tube.occupymedia.cc
|
||||
PEERTUBE_SMTP_TLS=false
|
||||
|
||||
# --- live (RTMP ingest from media-worker over the internal docker network) ---
|
||||
PEERTUBE_RTMP_PORT=1935
|
||||
PEERTUBE_LIVE_ENABLED=true
|
||||
PEERTUBE_LIVE_TRANSCODING_ENABLED=true
|
||||
Loading…
Add table
Add a link
Reference in a new issue