The catalog in server/containers had drifted from the VPS. Eleven compose
files and twelve units no longer matched what runs, and two running
services, Gbrown_Agent and BillTracker, were missing. server/vps is now a
copy of the live files, and panama-server reads from it.
server/home holds the home server's services in the same shape. They still
run under Docker, so the podman units there are drafts for a later move, and
nothing installs from that tree. PostHog, Sentry and Plausible are checkouts
of upstream repositories. Only their local files are kept, next to an
UPSTREAM file naming the repository and the exact commit.
Every secret in both trees is a ${VAR} reference named in .env.example, or
CHANGE_ME in files that are not compose files. The live servers still hold
some of those values inline, so a live file can differ from its copy on
those lines alone. Existing .env.example files keep their comments, with new
keys appended at the end.
The ignore rules and the secrets contract now cover both trees, plus the
volume/ and volumes/ directories the home server uses.
220 lines
8.6 KiB
YAML
220 lines
8.6 KiB
YAML
# 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
|