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:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View 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
View 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

View 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"]

View 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.

View 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:

View 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:

View 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:

View 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:

View 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