# Production stack on the Home Server (server.gib, rootful Docker), deployed # as ~/Server/Gbrown/compose.yml. This file is the versioned source: edit it # here and copy it over, never the other way round. # # ~/Server/Gbrown holds only this file, .env and data/. Every bind mount lives # under data/ (data/convex, data/gibtv/channel, data/gibtv/nginx.conf, copied # from apps/gibtv/nginx.conf). No source or build context lives on the server: # app images come from the Gitea registry. CI redeploys at once through the # forced-command key behind ~/Server/ci-deploy.sh (`docker compose pull && up -d`). # # Secrets come from ~/Server/Gbrown/.env (mode 600), written from Infisical # `prod` by scripts/write-prod-env. Never commit or print it. # # NPM reaches everything over nginx-bridge, so nothing publishes a host port: # api.gbrown.org -> http://gbrown-backend:3210 # convex.gbrown.org -> http://gbrown-backend:3211 # dashboard.gbrown.org -> http://gbrown-dashboard:6791 # gbrown.org (host 119) -> http://gbrown-next:3000 (at cutover) # tv.gbrown.org -> http://gbrown-gibtv-web:80 (phase 8) # # Watchtower (label-enabled, 04:00) updates the app images. The Convex backend, # its dashboard and the socket proxy are pinned and opt out: a backend upgrade # migrates its data, so it happens on purpose, not overnight. # # The `tools` profile keeps the one-off normalize pass out of a plain `up -d` # and `pull`. Run it with `docker compose run --rm gbrown-gibtv-normalize`. name: gbrown networks: nginx-bridge: external: true # The agent and its socket proxy only. No route out. agent-socket: internal: true volumes: # The live HLS window is about 10 MB and rewritten every 6 s. Keep it in RAM. gibtv-hls: driver_opts: type: tmpfs device: tmpfs o: size=64m,uid=1000,gid=1000 services: gbrown-backend: # Pinned to the build the VPS Convex stacks already run. Bump on purpose. image: ghcr.io/get-convex/convex-backend@sha256:d715e9ec088784407ca4ba2d3db592702cd328d02c76cdca3852c0018f2a76b4 container_name: gbrown-backend hostname: gbrown-backend networks: ['nginx-bridge'] volumes: ['./data/convex:/convex/data'] # No TZ: the backend panics unless it runs in UTC. environment: - INSTANCE_NAME=${INSTANCE_NAME:?} - INSTANCE_SECRET=${INSTANCE_SECRET:?} - CONVEX_CLOUD_ORIGIN=https://api.gbrown.org - CONVEX_SITE_ORIGIN=https://convex.gbrown.org - DISABLE_BEACON=true - REDACT_LOGS_TO_CLIENT=true labels: ['com.centurylinklabs.watchtower.enable=false'] restart: unless-stopped healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:3210/version'] interval: 10s timeout: 5s retries: 5 start_period: 20s stop_grace_period: 10s stop_signal: SIGINT gbrown-dashboard: # Released alongside the backend; pinned with it. image: ghcr.io/get-convex/convex-dashboard@sha256:f85cf0d0448b9c835ae3df5c7c1c6f0145dd4c8f0f92eb0245daf304efdd9f1a container_name: gbrown-dashboard hostname: gbrown-dashboard networks: ['nginx-bridge'] environment: # The browser talks to the backend directly, so this is the public URL. - NEXT_PUBLIC_DEPLOYMENT_URL=https://api.gbrown.org depends_on: gbrown-backend: condition: service_healthy labels: ['com.centurylinklabs.watchtower.enable=false'] restart: unless-stopped stop_grace_period: 10s stop_signal: SIGINT gbrown-next: image: git.gbrown.org/gib/gbrown-next:latest container_name: gbrown-next hostname: gbrown-next networks: ['nginx-bridge'] environment: - TZ=America/New_York - NODE_ENV=production - SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL} - NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL} - NEXT_PUBLIC_PLAUSIBLE_SRC=${NEXT_PUBLIC_PLAUSIBLE_SRC:-} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY:-} - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-} - NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN} - NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL} - NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG} - NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME} labels: ['com.centurylinklabs.watchtower.enable=true'] restart: unless-stopped healthcheck: test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:3000/'] interval: 30s timeout: 5s retries: 3 start_period: 20s # The agent never sees the Docker socket. The proxy answers only # GET /containers/json, /events, /_ping and /version (with or without an API # version prefix), refuses everything else, and only accepts connections from # the gbrown-agent hostname on the internal agent-socket network. # Docker here applies no SELinux labels to containers, so mounts need no :z. gbrown-agent-socket-proxy: image: docker.io/wollomatic/socket-proxy:1.13.1 container_name: gbrown-agent-socket-proxy hostname: gbrown-agent-socket-proxy networks: ['agent-socket'] command: - '-loglevel=info' - '-listenip=0.0.0.0' - '-allowfrom=gbrown-agent' - '-allowGET=/(v1\.[0-9]+/)?(containers/json|events|_ping|version)' - '-allowhealthcheck' # Exit if the socket breaks (a Docker upgrade) so Docker restarts it. - '-watchdoginterval=3600' - '-stoponwatchdog' - '-shutdowngracetime=5' # nobody, plus gid 963: the host's docker group, which owns the socket. user: '65534:963' volumes: ['/var/run/docker.sock:/var/run/docker.sock:ro'] read_only: true mem_limit: 64M cap_drop: [ALL] security_opt: ['no-new-privileges:true'] healthcheck: test: ['CMD', './healthcheck'] interval: 30s timeout: 5s retries: 2 # Pinned on purpose. Watchtower must not swap the gatekeeper on its own. labels: ['com.centurylinklabs.watchtower.enable=false'] restart: unless-stopped gbrown-agent: image: git.gbrown.org/gib/gbrown-agent:latest container_name: gbrown-agent hostname: gbrown-agent # nginx-bridge to reach gbrown-backend, agent-socket to reach the proxy. networks: ['nginx-bridge', 'agent-socket'] environment: - TZ=America/New_York - HOST_ID=home - DOCKER_HOST=tcp://gbrown-agent-socket-proxy:2375 # Straight to the backend's HTTP actions port, no proxy in between. - INGEST_URL=http://gbrown-backend:3211/ingest - AGENT_INGEST_SECRET=${AGENT_INGEST_SECRET:?} # The UniFi gateway on the LAN. UNIFI_CA is its own certificate, the only # one the agent trusts there. Leave all three unset to skip the network. - UNIFI_URL=${UNIFI_URL:-} - UNIFI_API_KEY=${UNIFI_API_KEY:-} - UNIFI_CA=${UNIFI_CA:-} volumes: ['/home/gib/Server/Nginx/volumes/data/logs:/npm-logs:ro'] depends_on: gbrown-agent-socket-proxy: condition: service_healthy read_only: true cap_drop: [ALL] security_opt: ['no-new-privileges:true'] labels: ['com.centurylinklabs.watchtower.enable=true'] restart: unless-stopped stop_grace_period: 10s # gib.tv (apps/gibtv). Nothing here touches the media library except the # normalize pass, which mounts it read-only. The streamer stream-copies, so # CPU is near zero; each viewer pulls about 1 Mbps of home upload. gbrown-gibtv: image: git.gbrown.org/gib/gbrown-gibtv:latest container_name: gbrown-gibtv hostname: gbrown-gibtv # No network needed: it only writes the HLS window to the shared volume. network_mode: none environment: ['TZ=America/New_York'] volumes: - ./data/gibtv/channel:/media/channel:ro - gibtv-hls:/srv/hls cpus: 0.5 mem_limit: 256m labels: ['com.centurylinklabs.watchtower.enable=true'] restart: unless-stopped gbrown-gibtv-web: image: docker.io/library/nginx:alpine container_name: gbrown-gibtv-web hostname: gbrown-gibtv-web networks: ['nginx-bridge'] depends_on: [gbrown-gibtv] volumes: - gibtv-hls:/usr/share/nginx/html:ro - ./data/gibtv/nginx.conf:/etc/nginx/conf.d/default.conf:ro labels: ['com.centurylinklabs.watchtower.enable=true'] restart: unless-stopped # One-off: `docker compose run --rm gbrown-gibtv-normalize`. Writes one # 640x480 h264 file per episode, skips finished files, safe to resume. gbrown-gibtv-normalize: image: git.gbrown.org/gib/gbrown-gibtv:latest profiles: ['tools'] command: ['nice', '-n', '19', 'bun', 'src/cli/normalize.ts'] network_mode: none volumes: - '/home/gib/Public/TV/Dragnet (1951):/media/source:ro' - ./data/gibtv/channel:/media/channel