diff --git a/.gitignore b/.gitignore index 76aff29..c8e44b7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,11 +3,13 @@ /config/bash/.env.lock # Server secrets and state never enter the repo: live .env files belong in # ~/Server// (only compose.yml is symlinked back here), and data/ is -# the bind-mount convention. Ignored anyway as a seatbelt -- the repo is -# public, and tests/server/compose-secrets-contract is the check that this -# line still exists. -/server/containers/**/.env -/server/containers/**/data/ +# the bind-mount convention (the home server's older services use volume/ and +# volumes/). Ignored anyway as a seatbelt -- the repo is public, and +# tests/server/compose-secrets-contract is the check that these lines still exist. +/server/**/.env +/server/**/data/ +/server/**/volume/ +/server/**/volumes/ # Personal espanso triggers (name, email), seeded per-machine by setup-identity. /config/dot/espanso/match/identity.yml # Ignore backups of old config files diff --git a/bin/panama-server b/bin/panama-server index 80a7f92..87be092 100755 --- a/bin/panama-server +++ b/bin/panama-server @@ -2,7 +2,7 @@ # # panama-server – the compose services a server machine runs. # -# The repository carries the catalog: server/containers// holds a +# The repository carries the catalog: server/vps// holds a # compose.yml, a podman-.service user unit, and an .env.example naming # what the service needs told. The machine carries the rest: ~/Server// # is a real directory owning the .env (secrets never sit inside the checkout, @@ -27,7 +27,7 @@ PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd) # Overridable so the contract can point this at a fixture catalog and a # throwaway HOME rather than the machine's real services. -SERVER_DIR="${PANAMA_SERVER_DIR:-$PANAMA_DIR/server/containers}" +SERVER_DIR="${PANAMA_SERVER_DIR:-$PANAMA_DIR/server/vps}" TARGET_DIR="${PANAMA_SERVER_TARGET:-$HOME/Server}" UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" STATE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/server-definitions" @@ -46,7 +46,7 @@ err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; } usage() { cat <.service user unit, and an .env.example - naming what the service needs told + vps/ The catalog panama-server installs from, one directory + per service: compose.yml, the podman-.service user + unit, and an .env.example naming what the service needs told + home/ The home server's services in the same shape (see below) scripts/ update-containers, the nightly image updater systemd/ The units behind it, linked by setup-server ``` +## The home server + +`server/home/` mirrors `~/Server` on the home server, where the services still +run under Docker. Each directory has the same three files as the VPS catalog, +and the unit is drafted for the eventual move to rootless podman. It has never +run, and the composes still use Docker-only features (host networking, the +GPU, the Docker socket) that need converting first. Nothing here is +installable: panama-server reads `server/vps/` only, so a service moves into +the catalog when it actually moves to podman. + +Paths keep their nesting (`server/home/Agents/Gitea` is `~/Server/Agents/Gitea`). +Three services are checkouts of upstream repositories. For those, only the +local files are kept, and an `UPSTREAM` file names the repository and the exact +commit the checkout sits at: `PostHog`, `Sentry` and `Plausible`. + +Both trees are snapshots of the live machines with every secret replaced by a +`${VAR}` named in `.env.example`, or by `CHANGE_ME` in files that are not +compose files. The live servers still carry some secrets inline, so a live +compose and its copy here can differ on those lines alone. + ## The contract with ~/Server The repository carries the definitions; the machine carries the state. @@ -35,7 +56,7 @@ is what `panama server enable` refuses to start on. ## Conventions - Service directories are TitleCase, matching `~/Server` as it has always - looked: `server/containers/Gitea` becomes `~/Server/Gitea`. + looked: `server/vps/Gitea` becomes `~/Server/Gitea`. - Bind mounts live under `./data/` in new services. Imported services keep the volume path they were running with until their cutover, because the repo copy must render identically to the live one — renames happen with @@ -73,7 +94,7 @@ ones — and verify it came back up. One service at a time, quiet hours. ## Adding a service -Add `server/containers//` with the three files, modelled on any +Add `server/vps//` with the three files, modelled on any existing service. The unit is the standard oneshot `podman compose up -d` shape (`WorkingDirectory=%h/Server/`); the shape contract pins the details. Secrets go in `.env.example` as `KEY=CHANGE_ME` lines. diff --git a/server/containers/Gitea/compose.yml b/server/containers/Gitea/compose.yml deleted file mode 100644 index f20367e..0000000 --- a/server/containers/Gitea/compose.yml +++ /dev/null @@ -1,25 +0,0 @@ -networks: - nginx-bridge: - external: true -services: - gitea: - image: docker.io/gitea/gitea:latest - container_name: gitea - hostname: gitea - domainname: git.gbrown.org - networks: - - nginx-bridge - ports: - - '2222:22' - environment: - - TZ=America/New_York - - USER_UID=1001 - - USER_GID=1003 - labels: - com.centurylinklabs.watchtower.enable: 'true' - volumes: - - ./volume:/data:Z - - /home/gib/Media/gitea-packages:/data/gitea/packages:Z - - ./ssh:/data/git/.ssh:Z - tty: true - restart: unless-stopped diff --git a/server/containers/Penpot/.env.example b/server/containers/Penpot/.env.example deleted file mode 100644 index deace8d..0000000 --- a/server/containers/Penpot/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -PENPOT_SECRET_KEY=CHANGE_ME -PENPOT_DATABASE_PASSWORD=CHANGE_ME diff --git a/server/containers/Penpot/compose.yml b/server/containers/Penpot/compose.yml deleted file mode 100644 index 1cea7b4..0000000 --- a/server/containers/Penpot/compose.yml +++ /dev/null @@ -1,120 +0,0 @@ -# Penpot — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12. -# -# ⚠️ NOT STARTED. No systemd unit is enabled. It has never run on either machine, so -# there is no data to migrate. Bring it up with: -# systemctl --user enable --now podman-penpot.service -# -# BEFORE FIRST START, create its role and database on the SHARED postgres (this stack -# no longer ships its own): -# podman exec -i postgresql psql -U npm <<'SQL' -# CREATE ROLE penpot LOGIN PASSWORD ''; -# CREATE DATABASE penpot OWNER penpot; -# SQL -# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql. -# -# NOTE: the home version used postgres:15-alpine with --data-checksums. The shared -# instance is postgres 17 without checksums. Penpot does not care, and since there is -# no existing data this is not a migration — but if you ever DO need checksums, they -# can only be set at initdb time for the whole cluster. -# -# Redis stays local to this stack: Penpot uses it for transient session and -# notification state only, so it is not worth centralising. -# -# Rootless adaptations from the home version: -# - Dropped its private postgres:15-alpine; uses the shared instance (AGENTS.md §2). -# - PENPOT_DATABASE_URI repointed from penpot-db to the shared postgresql. -# - :Z on the assets bind mount. It is shared between frontend and backend, so it -# uses LOWERCASE :z -- uppercase would give each container a private MCS category -# and the second to start would lock the first out (AGENTS.md §6). -# - /etc/localtime mounts removed in favour of TZ (AGENTS.md §8). -# -# Registration is disabled by default (PENPOT_FLAGS disable-registration), so create -# the first account from the backend container before you can log in. -# -# NPM: proxy penpot.gbrown.org -> http://penpot-frontend:8080 - -networks: - nginx-bridge: - external: true - -services: - penpot-frontend: - image: docker.io/penpotapp/frontend:latest - container_name: penpot-frontend - hostname: penpot-frontend - domainname: penpot.gbrown.org - networks: ['nginx-bridge'] - environment: - - TZ=America/New_York - - PENPOT_FLAGS=disable-registration enable-login-with-password disable-smtp - labels: - com.centurylinklabs.watchtower.enable: "true" - volumes: - - ./volumes/assets:/opt/data/assets:z - depends_on: - - penpot-backend - - penpot-exporter - tty: true - stdin_open: true - restart: unless-stopped - - penpot-backend: - image: docker.io/penpotapp/backend:latest - container_name: penpot-backend - hostname: penpot-backend - domainname: penpot.gbrown.org - networks: ['nginx-bridge'] - environment: - - TZ=America/New_York - - PENPOT_FLAGS=disable-registration enable-login-with-password disable-smtp enable-prepl-server - - PENPOT_PUBLIC_URI=https://penpot.gbrown.org - - PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY} - # Shared postgres, not a private container - - PENPOT_DATABASE_URI=postgresql://postgresql/penpot - - PENPOT_DATABASE_USERNAME=penpot - - PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD} - - PENPOT_REDIS_URI=redis://penpot-redis/0 - - PENPOT_ASSETS_STORAGE_BACKEND=assets-fs - - PENPOT_STORAGE_ASSETS_FS_DIRECTORY=/opt/data/assets - - PENPOT_TELEMETRY_ENABLED=false - labels: - com.centurylinklabs.watchtower.enable: "true" - volumes: - - ./volumes/assets:/opt/data/assets:z - depends_on: - - penpot-redis - tty: true - stdin_open: true - restart: unless-stopped - - penpot-exporter: - image: docker.io/penpotapp/exporter:latest - container_name: penpot-exporter - hostname: penpot-exporter - domainname: penpot.gbrown.org - networks: ['nginx-bridge'] - environment: - - TZ=America/New_York - - PENPOT_PUBLIC_URI=http://penpot-frontend:8080 - - PENPOT_REDIS_URI=redis://penpot-redis/0 - labels: - com.centurylinklabs.watchtower.enable: "true" - depends_on: - - penpot-redis - tty: true - stdin_open: true - restart: unless-stopped - - penpot-redis: - image: docker.io/library/redis:7-alpine - container_name: penpot-redis - hostname: penpot-redis - domainname: penpot.gbrown.org - networks: ['nginx-bridge'] - environment: - - TZ=America/New_York - labels: - com.centurylinklabs.watchtower.enable: "true" - tty: true - stdin_open: true - restart: unless-stopped diff --git a/server/containers/Rustdesk/.env.example b/server/containers/Rustdesk/.env.example deleted file mode 100644 index 2c57394..0000000 --- a/server/containers/Rustdesk/.env.example +++ /dev/null @@ -1 +0,0 @@ -RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME diff --git a/server/containers/UseSend/compose.yml b/server/containers/UseSend/compose.yml deleted file mode 100644 index ce0daba..0000000 --- a/server/containers/UseSend/compose.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: usesend -networks: - nginx-bridge: - external: true -services: - redis: - image: redis:7 - container_name: usesend-redis - hostname: usesend-redis - networks: - - nginx-bridge - env_file: .env - labels: - com.centurylinklabs.watchtower.enable: 'true' - volumes: - - ./volumes/redis:/data:Z - command: - - redis-server - - --maxmemory-policy - - noeviction - tty: true - stdin_open: true - restart: unless-stopped - minio: - image: minio/minio - container_name: usesend-storage - hostname: usesend-storage - networks: - - nginx-bridge - env_file: .env - environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} - labels: - com.centurylinklabs.watchtower.enable: 'true' - volumes: - - ./volumes/minio:/data:Z - entrypoint: sh - command: -c 'mkdir -p /data/unsend && minio server /data --console-address ':9001' --address ':9002'' - tty: true - stdin_open: true - restart: unless-stopped - smtp-server: - image: usesend/smtp-proxy:v1.6.6 - container_name: usesend-smtp-server - hostname: usesend-smtp - networks: - - nginx-bridge - env_file: .env - environment: - - SMTP_AUTH_USERNAME=${SMTP_USER:?err} - - USESEND_BASE_URL=${USESEND_URL:?err} - labels: - com.centurylinklabs.watchtower.enable: 'true' - tty: true - stdin_open: true - restart: unless-stopped - usesend: - image: usesend/usesend:v1.6.6 - container_name: usesend - hostname: usesend - domainname: ${USESEND_URL:?err} - networks: - - nginx-bridge - env_file: .env - environment: - - PORT=${USESEND_PORT:-3000} - - DATABASE_URL=${DATABASE_URL:?err} - - NEXTAUTH_URL=${USESEND_URL:?err} - - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?err} - - AWS_ACCESS_KEY=${AWS_ACCESS_KEY:?err} - - AWS_SECRET_KEY=${AWS_SECRET_KEY:?err} - - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err} - - GITHUB_ID=${GITHUB_ID:?err} - - GITHUB_SECRET=${GITHUB_SECRET:?err} - - REDIS_URL=${REDIS_URL:?err} - - NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false} - - API_RATE_LIMIT=${API_RATE_LIMIT:-1} - - SMTP_HOST=${SMTP_HOST:-smtp.usesend.gbrown.org} - - SMTP_USER=${SMTP_USER:-usesend} - labels: - com.centurylinklabs.watchtower.enable: 'true' - depends_on: - redis: - condition: service_started - tty: true - stdin_open: true - restart: unless-stopped diff --git a/server/containers/Vaultwarden/.env.example b/server/containers/Vaultwarden/.env.example deleted file mode 100644 index 2dcc48f..0000000 --- a/server/containers/Vaultwarden/.env.example +++ /dev/null @@ -1 +0,0 @@ -ADMIN_TOKEN=CHANGE_ME diff --git a/server/containers/Vaultwarden/compose.yml b/server/containers/Vaultwarden/compose.yml deleted file mode 100644 index 6f9ed39..0000000 --- a/server/containers/Vaultwarden/compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -# Vaultwarden — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/vaultwarden, -# staged 2026-08-12. -# -# ⚠️ STAGED, NOT AUTHORITATIVE. The home instance is still live and still serving -# vault.gbrown.org. This copy holds a point-in-time snapshot of the SQLite database. -# DO NOT log into this one and add credentials before cutover -- the two would diverge -# and one side's changes would be lost. -# -# Cutover procedure (do it in this order, it matters): -# 1. stop vaultwarden on the home server (clean SQLite, no torn WAL) -# 2. rsync -a server.gib:~/Server/vaultwarden/volume/ ~/Server/Vaultwarden/volume/ -# 3. systemctl --user restart podman-vaultwarden -# 4. point vault.gbrown.org at the VPS, add the NPM proxy host -# 5. verify a login + an item decrypts, THEN leave home stopped -# -# The SQLite copy taken while home was running is crash-consistent only. Step 1 is what -# makes it clean -- don't skip it. -# -# Rootless adaptations: -# - :Z on the data mount (SELinux Enforcing). -# - /etc/localtime mount dropped in favour of TZ (AGENTS.md §8). -# - No published port; NPM proxies to http://vaultwarden:80 over nginx-bridge. -# - Image starts as root and drops privileges internally, so no userns_mode needed. -# -# NOTE: ADMIN_TOKEN is inline here, matching the home server's file verbatim. It grants -# access to /admin. Worth moving to a mode-600 .env at some point -- compose files get -# rsynced offsite by ~/Server/backup. - -networks: - nginx-bridge: - external: true - -services: - vaultwarden: - image: docker.io/vaultwarden/server:latest - container_name: vaultwarden - hostname: vaultwarden - domainname: vault.gbrown.org - networks: - - nginx-bridge - environment: - - TZ=America/New_York - - DOMAIN=https://vault.gbrown.org - - LOGIN_RATELIMIT_MAX_BURST=10 - - LOGIN_RATELIMIT_SECONDS=60 - - WEB_VAULT_ENABLED=true - - SIGNUPS_ALLOWED=false - - WEBSOCKET_ENABLED=true - - ADMIN_TOKEN=${ADMIN_TOKEN} - labels: - com.centurylinklabs.watchtower.enable: "true" - volumes: - - ./volume:/data:Z - tty: true - restart: unless-stopped diff --git a/server/home/Agents/Beszel/compose.yml b/server/home/Agents/Beszel/compose.yml new file mode 100644 index 0000000..772b366 --- /dev/null +++ b/server/home/Agents/Beszel/compose.yml @@ -0,0 +1,48 @@ +# Beszel AGENT ONLY — the hub now lives on the VPS. +# +# The hub (web UI + database) was moved to the VPS on 2026-08-11 and is reachable at +# https://beszel.gibbyb.com. This host keeps only the agent, which collects metrics and +# serves them to that hub over the WireGuard link (VPS 192.168.2.2 -> here 192.168.1.2:45876). +# +# KEY below is the VPS hub's public key. If the VPS hub is ever rebuilt its keypair +# changes, and this value must be updated to match: +# ssh-keygen -y -f ~/Server/Beszel/volumes/hub/id_ed25519 (run on the VPS) +# +# Previous config, including the local hub service, is in compose.yml.bak-2026-08-11. +# +# The agent uses network_mode: host — that is the documented setup and is what lets it +# read real host network interfaces rather than a container's veth. Docker forbids setting +# hostname/domainname together with host networking, so those keys are absent. + +services: + beszel-agent: + image: henrygd/beszel-agent-nvidia:latest + container_name: beszel-agent + network_mode: host + # Give the agent read access to NVIDIA's management interface so Beszel can + # report GPU utilization, temperature, power draw, and VRAM usage. + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [utility] + environment: + - TZ=America/New_York + - PORT=45876 + - KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBWNqxKGRltqXJZg+wlDOtICeLo19ftQ6P3P8+uJg8y + # /boot is a separate 974M partition (nvme1n1p1), not part of the LUKS + # root the agent finds on its own. It filling up is what broke a 1032 + # package dnf transaction on 2026-08-09, so it is worth alerting on. + - EXTRA_FILESYSTEMS=/boot + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/agent:/var/lib/beszel-agent + - /var/run/docker.sock:/var/run/docker.sock:ro + - /boot:/boot:ro + - /etc/localtime:/etc/localtime:ro + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/home/Agents/Beszel/podman-agents-beszel.service b/server/home/Agents/Beszel/podman-agents-beszel.service new file mode 100644 index 0000000..2d93d05 --- /dev/null +++ b/server/home/Agents/Beszel/podman-agents-beszel.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Agents/Beszel +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Agents/Beszel +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Agents/Gitea/.env.example b/server/home/Agents/Gitea/.env.example new file mode 100644 index 0000000..8d45cbb --- /dev/null +++ b/server/home/Agents/Gitea/.env.example @@ -0,0 +1 @@ +GITEA_RUNNER_REGISTRATION_TOKEN=CHANGE_ME diff --git a/server/home/Agents/Gitea/compose.yml b/server/home/Agents/Gitea/compose.yml new file mode 100644 index 0000000..02da314 --- /dev/null +++ b/server/home/Agents/Gitea/compose.yml @@ -0,0 +1,153 @@ +# Gitea Actions runners — 3 parallel runners, each with its own dind sidecar. +# +# Each runner gets an isolated Docker daemon rather than the host socket, so a +# CI job cannot reach the other ~180 containers on this box. Cost: each dind +# keeps its own image cache under ./volumes/runner-N/dind. +# +# The pre-existing host runner (/home/gib/.local/bin/act_runner, name +# "server-gib") is intentionally left running. +# +# Registration token generated with: +# docker exec -u git gitea gitea actions generate-runner-token +# It is reusable — all three runners register with the same one. + +networks: + nginx-bridge: + external: true + +x-runner-env: &runner-env + TZ: America/New_York + GITEA_INSTANCE_URL: https://git.gbrown.org + GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN} + GITEA_RUNNER_LABELS: "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" + DOCKER_TLS_VERIFY: "1" + DOCKER_CERT_PATH: /certs/client + CONFIG_FILE: /data/config.yml + +x-dind-env: &dind-env + TZ: America/New_York + DOCKER_TLS_CERTDIR: /certs + +services: + gitea-runner-1: + image: gitea/act_runner:latest + container_name: gitea-runner-1 + hostname: gitea-runner-1 + domainname: git.gbrown.org + networks: ['nginx-bridge'] + environment: + <<: *runner-env + GITEA_RUNNER_NAME: gitea-runner-1 + DOCKER_HOST: tcp://gitea-runner-1-dind:2376 + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/runner-1/data:/data + - ./volumes/runner-1/certs:/certs + - /etc/localtime:/etc/localtime:ro + depends_on: + - gitea-runner-1-dind + tty: true + stdin_open: true + restart: unless-stopped + + gitea-runner-1-dind: + image: docker:dind + container_name: gitea-runner-1-dind + hostname: gitea-runner-1-dind + domainname: git.gbrown.org + networks: ['nginx-bridge'] + privileged: true + environment: + <<: *dind-env + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/runner-1/certs:/certs + - ./volumes/runner-1/dind:/var/lib/docker + - /etc/localtime:/etc/localtime:ro + tty: true + stdin_open: true + restart: unless-stopped + + gitea-runner-2: + image: gitea/act_runner:latest + container_name: gitea-runner-2 + hostname: gitea-runner-2 + domainname: git.gbrown.org + networks: ['nginx-bridge'] + environment: + <<: *runner-env + GITEA_RUNNER_NAME: gitea-runner-2 + DOCKER_HOST: tcp://gitea-runner-2-dind:2376 + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/runner-2/data:/data + - ./volumes/runner-2/certs:/certs + - /etc/localtime:/etc/localtime:ro + depends_on: + - gitea-runner-2-dind + tty: true + stdin_open: true + restart: unless-stopped + + gitea-runner-2-dind: + image: docker:dind + container_name: gitea-runner-2-dind + hostname: gitea-runner-2-dind + domainname: git.gbrown.org + networks: ['nginx-bridge'] + privileged: true + environment: + <<: *dind-env + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/runner-2/certs:/certs + - ./volumes/runner-2/dind:/var/lib/docker + - /etc/localtime:/etc/localtime:ro + tty: true + stdin_open: true + restart: unless-stopped + + gitea-runner-3: + image: gitea/act_runner:latest + container_name: gitea-runner-3 + hostname: gitea-runner-3 + domainname: git.gbrown.org + networks: ['nginx-bridge'] + environment: + <<: *runner-env + GITEA_RUNNER_NAME: gitea-runner-3 + DOCKER_HOST: tcp://gitea-runner-3-dind:2376 + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/runner-3/data:/data + - ./volumes/runner-3/certs:/certs + - /etc/localtime:/etc/localtime:ro + depends_on: + - gitea-runner-3-dind + tty: true + stdin_open: true + restart: unless-stopped + + gitea-runner-3-dind: + image: docker:dind + container_name: gitea-runner-3-dind + hostname: gitea-runner-3-dind + domainname: git.gbrown.org + networks: ['nginx-bridge'] + privileged: true + environment: + <<: *dind-env + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/runner-3/certs:/certs + - ./volumes/runner-3/dind:/var/lib/docker + - /etc/localtime:/etc/localtime:ro + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/home/Agents/Gitea/podman-agents-gitea.service b/server/home/Agents/Gitea/podman-agents-gitea.service new file mode 100644 index 0000000..ee2499e --- /dev/null +++ b/server/home/Agents/Gitea/podman-agents-gitea.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Agents/Gitea +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Agents/Gitea +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Agents/Portainer/compose.yml b/server/home/Agents/Portainer/compose.yml new file mode 100644 index 0000000..3e93e0b --- /dev/null +++ b/server/home/Agents/Portainer/compose.yml @@ -0,0 +1,39 @@ +services: + # portainer: + # image: portainer/portainer-ee:latest + # container_name: portainer + # hostname: portainer + # domainname: port.gbrown.org + # networks: + # - nginx-bridge + # labels: + # com.centurylinklabs.watchtower.enable: "true" + # ports: + # - 9443:9443 + # volumes: + # - ./volumes/data:/data + # - /var/run/docker.sock:/var/run/docker.sock + # tty: true + # stdin_open: true + # restart: unless-stopped + + portainer-agent: + image: portainer/agent:2.33.2 + container_name: portainer-agent + hostname: portainer-agent + networks: + - nginx-bridge + labels: + com.centurylinklabs.watchtower.enable: "true" + ports: ["9001:9001"] + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /var/lib/docker/volumes:/var/lib/docker/volumes + - /:/host + tty: true + stdin_open: true + restart: unless-stopped + +networks: + nginx-bridge: + external: true diff --git a/server/home/Agents/Portainer/podman-agents-portainer.service b/server/home/Agents/Portainer/podman-agents-portainer.service new file mode 100644 index 0000000..6a85324 --- /dev/null +++ b/server/home/Agents/Portainer/podman-agents-portainer.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Agents/Portainer +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Agents/Portainer +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Frigate/.env.example b/server/home/Frigate/.env.example new file mode 100644 index 0000000..8a0f946 --- /dev/null +++ b/server/home/Frigate/.env.example @@ -0,0 +1,2 @@ +RTSP_PASSWORD=CHANGE_ME +PLUS_API_KEY=CHANGE_ME diff --git a/server/home/Frigate/compose.yml b/server/home/Frigate/compose.yml new file mode 100644 index 0000000..1b4b6cd --- /dev/null +++ b/server/home/Frigate/compose.yml @@ -0,0 +1,79 @@ +networks: + nginx-bridge: + external: true +services: + frigate: + image: ghcr.io/blakeblackshear/frigate:0.17.1-tensorrt # For Nvidia GPU + container_name: frigate + hostname: frigate + domainname: frigate.gbrown.org + networks: [nginx-bridge] + ports: + #- "8971:8971" + - "5000:5000" + #- "8554:8554" + #- "8555:8555" + # Check host system's actual render/video/plugdev group IDs with 'getent group render', 'getent group video', and 'getent group plugdev' + # Must add these exact IDs in container's group_add section or OpenVINO GPU acceleration will fail + group_add: + - "105" # render + - "110" # render + - "39" # video + - "46" # plugdev + shm_size: "256mb" + env_file: + - .env + environment: + - TZ=America/New_York + - PGID=1000 + - PUID=1000 + - FRIGATE_RTSP_PASSWORD=${RTSP_PASSWORD} + #- HSA_OVERRIDE_GFX_VERSION=11.0.0 + - PLUS_API_KEY=${PLUS_API_KEY} + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/frigate/config:/config:rw + - ./volumes/frigate/data:/usr/local/nginx/conf:rw + - /home/gib/Public/Server_Backup/frigate:/media/frigate + - type: tmpfs + target: /tmp/cache + tmpfs: + size: 1000000000 + - /etc/localtime:/etc/localtime:ro + # - /dev/bus/usb:/dev/bus/usb # Uncomment for Google Coral USB + devices: + - /dev/dri:/dev/dri # for intel hwaccel, needs to be updated for your hardware + #- /dev/kfd:/dev/kfd + stop_grace_period: 30s + restart: unless-stopped + tty: true + security_opt: ['seccomp:unconfined','label:disable','systempaths=unconfined'] + #privileged: true + runtime: nvidia + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + + mqtt: + image: eclipse-mosquitto:2 + container_name: mqtt + hostname: mqtt + domainname: mqtt.gbrown.org + networks: [nginx-bridge] + ports: ["1883:1883"] + #env_file: [.env] + environment: [TZ=America/New_York] + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/mosquitto/config:/mosquitto/config:rw + - ./volumes/mosquitto/data:/mosquitto/data:rw + - ./volumes/mosquitto/log:/mosquitto/log:rw + tty: true + security_opt: ['seccomp:unconfined','label:disable','systempaths=unconfined'] + restart: unless-stopped diff --git a/server/home/Frigate/podman-frigate.service b/server/home/Frigate/podman-frigate.service new file mode 100644 index 0000000..f30adab --- /dev/null +++ b/server/home/Frigate/podman-frigate.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Frigate +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Frigate +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Gbrown/.env.example b/server/home/Gbrown/.env.example new file mode 100644 index 0000000..cea2e30 --- /dev/null +++ b/server/home/Gbrown/.env.example @@ -0,0 +1,16 @@ +AGENT_INGEST_SECRET=CHANGE_ME +INSTANCE_NAME=CHANGE_ME +INSTANCE_SECRET=CHANGE_ME +NEXT_PUBLIC_CONVEX_URL=CHANGE_ME +NEXT_PUBLIC_PLAUSIBLE_SRC=CHANGE_ME +NEXT_PUBLIC_POSTHOG_HOST=CHANGE_ME +NEXT_PUBLIC_POSTHOG_KEY=CHANGE_ME +NEXT_PUBLIC_SENTRY_DSN=CHANGE_ME +NEXT_PUBLIC_SENTRY_ORG=CHANGE_ME +NEXT_PUBLIC_SENTRY_PROJECT_NAME=CHANGE_ME +NEXT_PUBLIC_SENTRY_URL=CHANGE_ME +NEXT_PUBLIC_SITE_URL=CHANGE_ME +SENTRY_AUTH_TOKEN=CHANGE_ME +UNIFI_API_KEY=CHANGE_ME +UNIFI_CA=CHANGE_ME +UNIFI_URL=CHANGE_ME diff --git a/server/home/Gbrown/compose.yml b/server/home/Gbrown/compose.yml new file mode 100644 index 0000000..01925d5 --- /dev/null +++ b/server/home/Gbrown/compose.yml @@ -0,0 +1,219 @@ +# 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 diff --git a/server/home/Gbrown/podman-gbrown.service b/server/home/Gbrown/podman-gbrown.service new file mode 100644 index 0000000..b429d61 --- /dev/null +++ b/server/home/Gbrown/podman-gbrown.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Gbrown +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Gbrown +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Home/compose.yml b/server/home/Home/compose.yml new file mode 100644 index 0000000..02b9317 --- /dev/null +++ b/server/home/Home/compose.yml @@ -0,0 +1,24 @@ +services: + homeassistant: + image: homeassistant/home-assistant:latest + container_name: home + hostname: home + domainname: home.gbrown.org + network_mode: host + cap_add: ['NET_ADMIN','NET_RAW'] + #networks: + #- nginx-bridge + #ports: + #- "8123:8123" + volumes: + - ./volume:/config + - /run/dbus:/run/dbus:ro + - /etc/localtime:/etc/localtime:ro + labels: + com.centurylinklabs.watchtower.enable: 'true' + tty: true + stdin_open: true + restart: unless-stopped +#networks: + #nginx-bridge: + #external: true diff --git a/server/home/Home/podman-home.service b/server/home/Home/podman-home.service new file mode 100644 index 0000000..e0dabbd --- /dev/null +++ b/server/home/Home/podman-home.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Home +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Home +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Immich/.env.example b/server/home/Immich/.env.example new file mode 100644 index 0000000..7a5e89c --- /dev/null +++ b/server/home/Immich/.env.example @@ -0,0 +1,7 @@ +UPLOAD_LOCATION=CHANGE_ME +DB_DATA_LOCATION=CHANGE_ME +IMMICH_VERSION=CHANGE_ME +DB_PASSWORD=CHANGE_ME +DB_USERNAME=CHANGE_ME +DB_DATABASE_NAME=CHANGE_ME +TZ=CHANGE_ME diff --git a/server/home/Immich/compose.yml b/server/home/Immich/compose.yml new file mode 100644 index 0000000..9296e90 --- /dev/null +++ b/server/home/Immich/compose.yml @@ -0,0 +1,86 @@ +name: immich +networks: + nginx-bridge: + external: true + +services: + immich-server: + image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} + extends: + file: hwaccel.transcoding.yml + service: nvenc + container_name: immich-server + hostname: immich-server + domainname: immich.gbrown.org + networks: ['nginx-bridge'] + #ports: ['2283:2283'] + env_file: [.env] + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ${UPLOAD_LOCATION}:/data + - /etc/localtime:/etc/localtime:ro + tty: true + stdin_open: true + restart: unless-stopped + depends_on: + - redis + - database + healthcheck: + disable: false + privileged: true + + immich-machine-learning: + image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-cuda + extends: + file: hwaccel.transcoding.yml + service: nvenc + container_name: immich-machine-learning + hostname: immich-machine-learning + networks: ['nginx-bridge'] + #ports: ['3003:3003'] + env_file: [.env] + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: ['./volumes/model-cache:/cache:z'] + tty: true + stdin_open: true + restart: unless-stopped + healthcheck: + disable: false + privileged: true + + redis: + image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f + container_name: immich-redis + hostname: immich-redis + networks: ['nginx-bridge'] + env_file: [.env] + labels: + com.centurylinklabs.watchtower.enable: "true" + tty: true + stdin_open: true + restart: unless-stopped + healthcheck: + test: redis-cli ping || exit 1 + + database: + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23 + container_name: immich-postgres + hostname: immich-postgres + networks: ['nginx-bridge'] + #ports: ['5432:5432'] + env_file: [.env] + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_DB: ${DB_DATABASE_NAME} + POSTGRES_INITDB_ARGS: '--data-checksums' + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ${DB_DATA_LOCATION}:/var/lib/postgresql/data + tty: true + stdin_open: true + restart: unless-stopped + shm_size: 128mb diff --git a/server/home/Immich/podman-immich.service b/server/home/Immich/podman-immich.service new file mode 100644 index 0000000..cf527ef --- /dev/null +++ b/server/home/Immich/podman-immich.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Immich +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Immich +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Media/.env.example b/server/home/Media/.env.example new file mode 100644 index 0000000..c65aaea --- /dev/null +++ b/server/home/Media/.env.example @@ -0,0 +1,9 @@ +PLEX_CLAIM=CHANGE_ME +PLEX_TOKEN=CHANGE_ME +JELLYFIN_TOKEN=CHANGE_ME +OPENVPN_PASSWORD=CHANGE_ME +QBIT_PASS=CHANGE_ME +SONARR_API_KEY=CHANGE_ME +RADARR_API_KEY=CHANGE_ME +UN_SONARR_0_API_KEY=CHANGE_ME +UN_RADARR_0_API_KEY=CHANGE_ME diff --git a/server/home/Media/compose.yml b/server/home/Media/compose.yml new file mode 100644 index 0000000..ddb7dc0 --- /dev/null +++ b/server/home/Media/compose.yml @@ -0,0 +1,429 @@ +networks: + nginx-bridge: + external: true + +services: + plex: + image: plexinc/pms-docker:latest + container_name: plex + hostname: plex + domainname: plex.gbrown.org + networks: [nginx-bridge] + group_add: [105, 39] + ports: + - 32400:32400/tcp # REQUIRED + #- 5353:5353/udp # For older Bonjour/Avahi network discovery + #- 8324:8324/tcp # For Plex Roku App + - 32410:32410/udp # For network discovery + - 32412:32412/udp # For network discovery + - 32413:32413/udp # For network discovery + - 32414:32414/udp # For network discovery + #- 1900:1900/udp # For DLNA + - 32469:32469/tcp # For DLNA + environment: + - TZ=America/New_York + - PLEX_UID=1000 + - PLEX_GID=1000 + - PLEX_CLAIM=${PLEX_CLAIM} + - ADVERTISE_IP=http://192.168.0.43 + - NVIDIA_VISIBLE_DEVICES=all + - NVIDIA_DRIVER_CAPABILITIES=compute,video,utility + labels: ["com.centurylinklabs.watchtower.enable=true"] + volumes: + - /run:/run + - ./volumes/plex/config:/config + - ./volumes/plex/transcode:/transcode + - /home/gib/Public/Movies:/data/movies + - /home/gib/Public/TV:/data/tvshows + - /home/gib/Public/Music:/data/music + - /home/gib/Public/Audiobooks:/data/audiobooks + - /home/gib/Public/Podcasts:/data/podcasts + devices: [/dev/dri:/dev/dri] + tty: true + stdin_open: true + restart: unless-stopped + security_opt: ["seccomp:unconfined"] + runtime: nvidia + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + + jellyfin: + image: jellyfin/jellyfin + container_name: jellyfin + hostname: jellyfin + domainname: jellyfin.gbrown.org + networks: [nginx-bridge] + group_add: [105, 39] + ports: [7359:7359/udp] + user: 1000:1000 + environment: + - JELLYFIN_PublishedServerUrl=https://jellyfin.gbrown.org + labels: [com.centurylinklabs.watchtower.enable=true] + volumes: + - ./volumes/jellyfin/config:/config + - ./volumes/jellyfin/cache:/cache + - /home/gib/Public/Movies:/data/movies + - /home/gib/Public/TV:/data/tvshows + - /home/gib/Public/Music:/data/music + - /home/gib/Public/Audiobooks:/data/audiobooks + - /home/gib/Public/Podcasts:/data/podcasts + - /home/gib/.local/share/fonts:/usr/local/share/fonts/custom:ro + tty: true + stdin_open: true + restart: unless-stopped + devices: [/dev/dri:/dev/dri] + security_opt: [seccomp:unconfined] + runtime: nvidia + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + + audiobookshelf: + image: ghcr.io/advplyr/audiobookshelf:latest + container_name: audiobookshelf + hostname: audiobookshelf + domainname: audio.gbrown.org + networks: [nginx-bridge] + #ports: [80:80] + environment: [TZ=America/New_York, PUID=1000, PGID=1000] + labels: ["com.centurylinklabs.watchtower.enable=true"] + volumes: + - ./volumes/audiobookshelf/config:/config + - ./volumes/audiobookshelf/metadata:/metadata + - /home/gib/Public/Audiobooks:/audiobooks + - /home/gib/Public/Podcasts:/podcasts + tty: true + stdin_open: true + restart: unless-stopped + + jellyplex-watched: + image: luigi311/jellyplex-watched:latest + container_name: jellyplex-watched + hostname: jellyplex-watched + networks: [nginx-bridge] + environment: + DRYRUN: False + DEBUG_LEVEL: INFO # Or debug for verbose logging + RUN_ONLY_ONCE: False + SLEEP_DURATION: 3600 + REQUEST_TIMEOUT: 300 + MAX_THREADS: 2 + GENERATE_GUIDS: False + GENERATE_LOCATIONS: True + USER_MAPPING: '{ "gib": "gibby_b", "adrian": "Adrian"}' + LIBRARY_MAPPING: '{ "TV Shows": "Shows"}' + PLEX_BASEURL: http://plex:32400 + PLEX_TOKEN: ${PLEX_TOKEN} + SSL_BYPASS: False + JELLYFIN_BASEURL: http://jellyfin:8096 + JELLYFIN_TOKEN: ${JELLYFIN_TOKEN} + SYNC_FROM_PLEX_TO_JELLYFIN: True + SYNC_FROM_JELLYFIN_TO_PLEX: True + tty: true + stdin_open: true + restart: unless-stopped + + gluetun: + image: qmcgaw/gluetun:latest + container_name: gluetun + hostname: gluetun + networks: [nginx-bridge] + cap_add: [NET_ADMIN] + devices: [/dev/net/tun:/dev/net/tun] + #ports: + #- 8000:8000 # HTTP control server + #- 8080:8080 # For SABnzbd + #- 6969:6969 # For qBittorrent + #- 6881:6881 # For torrent connectivity + #- 6881:6881/udp # For torrent connectivity + environment: + - VPN_SERVICE_PROVIDER=private internet access + - VPN_TYPE=openvpn + - SERVER_REGIONS=US East + - OPENVPN_USER=p7671057 + - OPENVPN_PASSWORD=${OPENVPN_PASSWORD} + - TZ=America/New_York + - PUID=1000 + - PGID=100 + labels: [com.centurylinklabs.watchtower.enable=true] + restart: unless-stopped + tty: true + stdin_open: true + + sabnzbd: + image: lscr.io/linuxserver/sabnzbd:latest + container_name: sabnzbd + domainname: sabnzbd.gbrown.org + network_mode: service:gluetun + environment: [PUID=1000, PGID=1000, TZ=America/New_York] + labels: [com.centurylinklabs.watchtower.enable=true] + volumes: + - ./volumes/sabnzbd:/config + - /home/gib/Public/Downloads:/downloads + - /home/gib/Public/Downloads/incomplete:/incomplete-downloads + depends_on: [gluetun] + restart: unless-stopped + tty: true + stdin_open: true + + qbittorrent: + image: linuxserver/qbittorrent:latest + container_name: qbittorrent + network_mode: service:gluetun + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/New_York + - WEBUI_PORT=6969 + labels: [com.centurylinklabs.watchtower.enable=true] + volumes: + - ./volumes/qbittorrent:/config + - /home/gib/Public/Downloads:/downloads + depends_on: [gluetun] + tty: true + stdin_open: true + restart: unless-stopped + + qbit-guard: + image: ghcr.io/gengines/qbit-guard:latest + container_name: qbitguard + hostname: qbitguard + networks: [nginx-bridge] + labels: [com.centurylinklabs.watchtower.enable=true] + environment: + - QBIT_HOST=http://gluetun:6969 + - QBIT_USER=gib + - QBIT_PASS=${QBIT_PASS} + - QBIT_ALLOWED_CATEGORIES=tv-sonarr,radarr + - ENABLE_PREAIR_CHECK=1 + - SONARR_URL=http://sonarr:8989 + - SONARR_API_KEY=${SONARR_API_KEY} + - SONARR_CATEGORIES=tv-sonarr + - RADARR_URL=http://radarr:7878 + - RADARR_API_KEY=${RADARR_API_KEY} + - RADARR_CATEGORIES=radarr + - ENABLE_ISO_CHECK=1 + tty: true + stdin_open: true + restart: unless-stopped + + seerr: + image: ghcr.io/seerr-team/seerr:latest + container_name: seerr + init: true + hostname: seerr + domainname: seerr.gbrown.org + networks: [nginx-bridge] + #ports: [5055:5055] + environment: [LOG_LEVEL=debug, TZ=America/New_York] + volumes: [./volumes/seerr:/app/config] + labels: [com.centurylinklabs.watchtower.enable=true] + depends_on: [radarr, sonarr] + healthcheck: + test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1 + start_period: 20s + timeout: 3s + interval: 15s + retries: 3 + tty: true + stdin_open: true + restart: unless-stopped + + prowlarr: + image: lscr.io/linuxserver/prowlarr:latest + container_name: prowlarr + hostname: prowlarr + domainname: prowlarr.gbrown.org + networks: [nginx-bridge] + #ports: [9696:9696] + environment: [PUID=1000, PGID=1000, TZ=America/New_York] + volumes: [./volumes/prowlarr:/config] + labels: [com.centurylinklabs.watchtower.enable=true] + depends_on: [qbittorrent, sabnzbd] + tty: true + stdin_open: true + restart: unless-stopped + + lidarr: + image: lscr.io/linuxserver/lidarr:latest + container_name: lidarr + hostname: lidarr + domainname: lidarr.gbrown.org + networks: [nginx-bridge] + #ports: [8686:8686] + environment: [PUID=1000, PGID=1000, TZ=America/New_York] + volumes: + - ./volumes/lidarr:/config + - /home/gib/Public/Music:/music + - /home/gib/Public/Downloads:/downloads + labels: [com.centurylinklabs.watchtower.enable=true] + depends_on: [prowlarr, qbittorrent, sabnzbd] + tty: true + stdin_open: true + restart: unless-stopped + + radarr: + image: ghcr.io/linuxserver/radarr:latest + container_name: radarr + hostname: radarr + domainname: radarr.gbrown.org + networks: [nginx-bridge] + #ports: [7878:7878] + environment: [PUID=1000, PGID=1000, TZ=America/New_York] + volumes: + - ./volumes/radarr:/config + - /home/gib/Public/Movies:/movies + - /home/gib/Public/Downloads:/downloads + labels: [com.centurylinklabs.watchtower.enable=true] + depends_on: [prowlarr, qbittorrent, sabnzbd] + tty: true + stdin_open: true + restart: unless-stopped + + sonarr: + image: ghcr.io/linuxserver/sonarr:latest + container_name: sonarr + hostname: sonarr + domainname: sonarr.gbrown.org + networks: [nginx-bridge] + #ports: [8989:8989] + environment: [PUID=1000, PGID=1000, TZ=America/New_York] + labels: [com.centurylinklabs.watchtower.enable=true] + volumes: + - ./volumes/sonarr:/config + - /home/gib/Public/TV:/tv + - /home/gib/Public/Downloads:/downloads + depends_on: [prowlarr, qbittorrent, sabnzbd] + tty: true + stdin_open: true + restart: unless-stopped + + bazarr: + image: lscr.io/linuxserver/bazarr:latest + container_name: bazarr + hostname: bazarr + domainname: bazarr.gbrown.org + networks: [nginx-bridge] + #ports: [6767:6767] + environment: [PUID=1000, PGID=1000, TZ=America/New_York] + volumes: + - ./volumes/bazarr:/config + - /home/gib/Public/Movies:/movies + - /home/gib/Public/TV:/tv + labels: [com.centurylinklabs.watchtower.enable=true] + tty: true + stdin_open: true + restart: unless-stopped + + whisperasr: + image: onerahmet/openai-whisper-asr-webservice:latest-gpu + container_name: whisperasr + hostname: whisperasr + networks: [nginx-bridge] + #ports: [9000:9000] + environment: [TZ=America/New_York, PUID=1000, PGID=1000, ASR_MODEL=small] + labels: [com.centurylinklabs.watchtower.enable=true] + devices: [/dev/dri:/dev/dri] + tty: true + stdin_open: true + restart: unless-stopped + security_opt: ["seccomp:unconfined"] + runtime: nvidia + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + + unpackerr: + image: golift/unpackerr:latest + container_name: unpackerr + hostname: unpackerr + networks: [nginx-bridge] + volumes: + - /home/gib/Public/Downloads:/downloads + - /home/gib/Server/Media/volumes/unpackerr:/config + environment: + - TZ=America/New_York + # log lives in its own volume, NOT in /downloads where the *arr apps scan + - UN_LOG_FILE=/config/unpackerr.log + - UN_LOG_FILES=4 + - UN_LOG_FILE_MB=5 + - UN_SONARR_0_URL=http://sonarr:8989 + - UN_SONARR_0_API_KEY=${UN_SONARR_0_API_KEY} + - UN_RADARR_0_URL=http://radarr:7878 + - UN_RADARR_0_API_KEY=${UN_RADARR_0_API_KEY} + labels: [com.centurylinklabs.watchtower.enable=true] + tty: true + stdin_open: true + restart: always + + metube: + image: ghcr.io/alexta69/metube:latest + container_name: metube + hostname: metube + domainname: metube.gbrown.org + networks: [nginx-bridge] + #ports: [8081:8081] + environment: + - TZ=America/New_York + - PUID=1000 + - PGID=1000 + - YTDL_OPTIONS={"cookiefile":"/cookies/cookies.txt"} + volumes: + - ./volumes/metube:/cookies:rw + - /home/gib/Public/Downloads:/downloads:rw + labels: [com.centurylinklabs.watchtower.enable=true] + tty: true + stdin_open: true + restart: unless-stopped + + pinchflat: + image: ghcr.io/kieraneglin/pinchflat:latest + container_name: pinchflat + hostname: pinchflat + domainname: pinchflat.gbrown.org + networks: [nginx-bridge] + #ports: [8945:8945] + environment: [TZ=America/New_York, PUID=1000, PGID=1000] + volumes: + - ./volumes/pinchflat:/config:rw + - /home/gib/Public/Downloads:/downloads:rw + labels: [com.centurylinklabs.watchtower.enable=true] + tty: true + stdin_open: true + restart: unless-stopped + + configarr: + image: ghcr.io/raydak-labs/configarr:latest + container_name: configarr + hostname: configarr + domainname: configarr.gbrown.org + networks: [nginx-bridge] + environment: [TZ=America/New_York, PUID=1000, PGID=1000] + labels: [com.centurylinklabs.watchtower.enable=true] + volumes: + - ./volumes/configarr/config:/app/config + - ./volumes/configarr/repos:/app/repos + - ./volumes/configarr/cfs:/app/cfs + - ./volumes/configarr/templates:/app/templates + depends_on: [radarr, sonarr] + tty: true + stdin_open: true + # Run-to-completion: configarr syncs TRaSH guides into Sonarr/Radarr, + # prints an execution summary, and exits 0. It has no built-in scheduler + # (upstream points at Ofelia or cron for that), so `unless-stopped` here + # meant a completed sync was immediately restarted -- a hot loop of ~9 + # full syncs every 3 minutes. `no` lets it run once per `up` and stop. + restart: "no" diff --git a/server/home/Media/podman-media.service b/server/home/Media/podman-media.service new file mode 100644 index 0000000..33744c6 --- /dev/null +++ b/server/home/Media/podman-media.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Media +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Media +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Minecraft/Bedrock/Adrian/compose.yml b/server/home/Minecraft/Bedrock/Adrian/compose.yml new file mode 100644 index 0000000..1346a7b --- /dev/null +++ b/server/home/Minecraft/Bedrock/Adrian/compose.yml @@ -0,0 +1,28 @@ +networks: + nginx-bridge: + external: true + +services: + bedrock-minecraft: + image: itzg/minecraft-bedrock-server + container_name: mc-bedrock-adrian + network_mode: host + #hostname: mc-bedrock-adrian + #domainname: mc-bedrock-adrian.gbrown.org + #networks: ['nginx-bridge'] + #ports: + #- 19132:19132 + #- 19132:19132/udp + environment: + - EULA=TRUE + #- UID=1000 + #- GID=1000 + - TZ=America/New_York + #user: 1000:1000 + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - './data:/data' + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/home/Minecraft/Bedrock/Adrian/podman-minecraft-bedrock-adrian.service b/server/home/Minecraft/Bedrock/Adrian/podman-minecraft-bedrock-adrian.service new file mode 100644 index 0000000..2099c2e --- /dev/null +++ b/server/home/Minecraft/Bedrock/Adrian/podman-minecraft-bedrock-adrian.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Minecraft/Bedrock/Adrian +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Minecraft/Bedrock/Adrian +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Minecraft/Java/compose.yml b/server/home/Minecraft/Java/compose.yml new file mode 100644 index 0000000..cc95bd7 --- /dev/null +++ b/server/home/Minecraft/Java/compose.yml @@ -0,0 +1,76 @@ +# Java Edition Minecraft behind mc-router. Live since 2026-10-06 at mc.gbrown.org. +# +# mc-router owns the one public port (25565) and routes each player by the hostname +# they typed: Java clients send it in their first packet, so several servers can +# share one port and one IP. Bedrock clients do not send it, which is why the +# Bedrock server in ../Bedrock stays separate on its own UDP port. +# +# Start: docker compose up -d (from this directory) +# +# Each hostname needs a DNS-only (grey cloud) A record in Cloudflare pointing at the +# home IP. The proxied *.gbrown.org wildcard can't carry Minecraft traffic, and a +# specific record overrides it. UniFi forwards 25565/tcp to this host. Home +# Assistant's Cloudflare integration keeps these records on the current home IP; +# select each new record there too. +# +# Add a server: +# 1. mkdir servers/, then copy the "gib" service below and point it there +# 2. add "=:25565" to MAPPING +# 3. add the DNS-only record for +# Servers publish no ports of their own; only mc-router is reachable from outside. + +services: + mc-router: + image: itzg/mc-router:latest + container_name: mc-router + environment: + # =: + MAPPING: | + mc.gbrown.org=gib:25565 + ports: ["25565:25565"] + networks: [minecraft] + labels: + com.centurylinklabs.watchtower.enable: "true" + restart: unless-stopped + + gib: + image: itzg/minecraft-server:latest + container_name: mc-gib + environment: + # 26.1.2 matches the newest OptiFine (SEUS shaders) on the desktop. The 1.20.4 + # world is in backups/; upgrades rewrite the world and cannot be undone. + - VERSION=26.1.2 + - DIFFICULTY=normal + - MAX_PLAYERS=10 + - TYPE=VANILLA + - MEMORY=12G + - SIMULATION_DISTANCE=16 + - VIEW_DISTANCE=16 + - ALLOW_FLIGHT=true + - ENABLE_COMMAND_BLOCK=true + - ENABLE_WHITELIST=true + - UID=1000 + - GID=1000 + - EULA=true + - TZ=America/New_York + - ENABLE_ROLLING_LOGS=true + - LANG=en_US.UTF-8 + - LANGUAGE=en_US:en + - LC_ALL=en_US.UTF-8 + - SERVER_NAME=Gib's Minecraft Server + - SNOOPER_ENABLED=false + - WHITELIST_FILE=/data/whitelist.json + #- ENABLE_AUTOPAUSE=true + # Don't change MOTD it was really hard to make it look good. + - MOTD= \u00A7aGib's Minecraft Server\u00A7r \u00A74Welcome to HECK + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./servers/gib:/data + networks: [minecraft] + stdin_open: true + tty: true + restart: unless-stopped + +networks: + minecraft: {} diff --git a/server/home/Minecraft/Java/podman-minecraft-java.service b/server/home/Minecraft/Java/podman-minecraft-java.service new file mode 100644 index 0000000..b8dfeef --- /dev/null +++ b/server/home/Minecraft/Java/podman-minecraft-java.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Minecraft/Java +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Minecraft/Java +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Nginx/.env.example b/server/home/Nginx/.env.example new file mode 100644 index 0000000..4a4bd58 --- /dev/null +++ b/server/home/Nginx/.env.example @@ -0,0 +1,2 @@ +DB_POSTGRES_PASSWORD=CHANGE_ME +POSTGRES_PASSWORD=CHANGE_ME diff --git a/server/home/Nginx/compose.yml b/server/home/Nginx/compose.yml new file mode 100644 index 0000000..92c598a --- /dev/null +++ b/server/home/Nginx/compose.yml @@ -0,0 +1,55 @@ +services: + app: + image: 'jc21/nginx-proxy-manager:latest' + container_name: nginx-proxy-manager + hostname: nginx-proxy-manager + domainname: nginx.gbrown.org + networks: + - nginx-bridge + restart: unless-stopped + ports: + - '80:80' + - '443:443' + - '81:81' # Admin Web Port + #- '21:21' # FTP + #- '22:22' # SSH + #- '25565:25565' # Minecraft + environment: + PUID: 1000 + PGID: 1000 + DB_POSTGRES_HOST: 'db' + DB_POSTGRES_PORT: '5432' + DB_POSTGRES_USER: 'npm' + DB_POSTGRES_PASSWORD: ${DB_POSTGRES_PASSWORD} + DB_POSTGRES_NAME: 'npm' + labels: + com.centurylinklabs.watchtower.enable: 'true' + volumes: + - ./volumes/data:/data + - ./volumes/letsencrypt:/etc/letsencrypt + depends_on: + - db + + db: + image: postgres:17 + container_name: nginx-proxy-manager-db + hostname: nginx-proxy-manager-db + networks: + - nginx-bridge + environment: + POSTGRES_USER: 'npm' + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: 'npm' + labels: + com.centurylinklabs.watchtower.enable: 'true' + volumes: + - ./volumes/postgres:/var/lib/postgresql/data + restart: unless-stopped + +networks: + nginx-bridge: + driver: bridge + ipam: + config: + - subnet: 172.25.0.0/24 + name: nginx-bridge diff --git a/server/home/Nginx/podman-nginx.service b/server/home/Nginx/podman-nginx.service new file mode 100644 index 0000000..0075965 --- /dev/null +++ b/server/home/Nginx/podman-nginx.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Nginx +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Nginx +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/OpenWebUI/.env.example b/server/home/OpenWebUI/.env.example new file mode 100644 index 0000000..a823fc7 --- /dev/null +++ b/server/home/OpenWebUI/.env.example @@ -0,0 +1,4 @@ +BW_SESSION=CHANGE_ME +OPENAI_API_KEY=CHANGE_ME +OAUTH_CLIENT_ID=CHANGE_ME +OAUTH_CLIENT_SECRET=CHANGE_ME diff --git a/server/home/OpenWebUI/compose.yml b/server/home/OpenWebUI/compose.yml new file mode 100644 index 0000000..7c0e451 --- /dev/null +++ b/server/home/OpenWebUI/compose.yml @@ -0,0 +1,146 @@ +networks: + nginx-bridge: + external: true + +services: + openwebui: + image: ghcr.io/open-webui/open-webui:main + #image: ghcr.io/open-webui/open-webui:ollama + container_name: openwebui + hostname: openwebui + domainname: chat.gbrown.org + networks: + - nginx-bridge + #ports: + #- 8080:8080 + environment: + - WEBUI_NAME=Gib's Chat + - WEBUI_URL=https://chat.gbrown.org + - OLLAMA_BASE_URL=http://ollama:11434 + - ADMIN_EMAIL=admin@gbrown.org + - DEFAULT_MODELS=llama3.1 + - TASK_MODEL=llama3.1 + - ENABLE_AUTOCOMPLETE_GENERATION=false + - OPENAI_API_KEY=${OPENAI_API_KEY} + - AUTOMATIC1111_BASE_URL=http://192.168.0.43:7860 + - COMFYUI_BASE_URL=http://comfyui:8188 + - WHISPER_MODEL_AUTO_UPDATE=True + - PDF_EXTRACT_IMAGES=True + - ENABLE_RAG_WEB_SEARCH=True + - RAG_WEB_SEARCH_ENGINE=searxng + - SEARXNG_QUERY_URL=https://search.gbrown.org/search?q= + - ENABLE_IMAGE_GENERATION=True + - IMAGE_GENERATION_ENGINE=ComfyUI + - IMAGE_SIZE=1216x832 + #- USE_OLLAMA_DOCKER=True + #- USE_CUDA_DOCKER=True + - DO_NOT_TRACK=True + - ENABLE_OAUTH_SIGNUP=true + - OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true + #- ENABLE_OAUTH_ROLE_MANAGEMENT=true + #- OAUTH_ADMIN_ROLES=admin,internal,superuser + - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} + - OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET} + - OPENID_PROVIDER_URL=https://auth.gbrown.org/application/o/openwebui/.well-known/openid-configuration + - OPENID_REDIRECT_URI=https://chat.gbrown.org/oauth/oidc/callback + - OAUTH_PROVIDER_NAME=Gib's Auth + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/openwebui:/app/backend/data + #- /home/gib/Public/Server_Backup/Ollama_Models:/root/.ollama + #devices: + #- /dev/dri:/dev/dri + tty: true + restart: unless-stopped + #security_opt: ['seccomp:unconfined','label:disable'] + #deploy: + #resources: + #reservations: + #devices: + #- driver: nvidia + #count: 1 + #capabilities: [gpu] + + ollama: + #image: ollama/ollama:rocm + image: ollama/ollama:latest + container_name: ollama + hostname: ollama + domainname: ollama.gbrown.org + networks: + - nginx-bridge + labels: + com.centurylinklabs.watchtower.enable: "true" + ports: + - 11434:11434 + volumes: + - /home/gib/Public/Server_Backup/Ollama_Models:/root/.ollama + devices: + - /dev/dri:/dev/dri + tty: true + restart: unless-stopped + security_opt: ["seccomp:unconfined", "label:disable"] + runtime: nvidia + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + + comfyui: + #image: yanwk/comfyui-boot:rocm + image: yanwk/comfyui-boot:cu128-megapak + init: true + container_name: comfyui + hostname: comfyui + domainname: comfyui.gbrown.org + networks: + - nginx-bridge + labels: + com.centurylinklabs.watchtower.enable: "true" + #ports: [8188:8188] + volumes: + - /home/gib/Public/Server_Backup/ComfyUI/:/root + #environment: + #- HSA_OVERRIDE_GFX_VERSION=11.0.0 + #- CLI_ARGS='' + devices: + - /dev/dri:/dev/dri + #- /dev/kfd:/dev/kfd + tty: true + security_opt: ["seccomp:unconfined", "label:disable"] + restart: unless-stopped + group_add: [video] + #cap_add: [SYS_PTRACE] + runtime: nvidia + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + + mcpo: + image: ghcr.io/open-webui/mcpo:main + container_name: mcpo + hostname: mcpo.gbrown.org + domainname: mcpo.gbrown.org + command: 'mcpo --port 8010 --config /app/config.json --api-key "Tragedy8-Unfazed-Craftily-Hedging-Gem-Velcro"' + restart: unless-stopped + tty: true + networks: + - nginx-bridge + ports: + - 8010:8010 + labels: + com.centurylinklabs.watchtower.enable: "true" + env_file: + - .env + volumes: + - ./volumes/mcpo/openapi.json:/app/config.json + - ./volumes/mcpo/bin/gitea-mcp:/usr/local/bin/gitea-mcp + - ./volumes/mcpo/bin/bw:/usr/local/bin/bw diff --git a/server/home/OpenWebUI/podman-openwebui.service b/server/home/OpenWebUI/podman-openwebui.service new file mode 100644 index 0000000..2f34e99 --- /dev/null +++ b/server/home/OpenWebUI/podman-openwebui.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: OpenWebUI +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/OpenWebUI +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Plausible/.env.example b/server/home/Plausible/.env.example new file mode 100644 index 0000000..3dd4632 --- /dev/null +++ b/server/home/Plausible/.env.example @@ -0,0 +1,8 @@ +BASE_URL=CHANGE_ME +SECRET_KEY_BASE=CHANGE_ME +HTTP_PORT=CHANGE_ME +HTTPS_PORT=CHANGE_ME +SMTP_USER_NAME=CHANGE_ME +SMTP_USER_PWD=CHANGE_ME +POSTGRES_PASSWORD=CHANGE_ME +TOTP_VAULT_KEY=CHANGE_ME diff --git a/server/home/Plausible/UPSTREAM b/server/home/Plausible/UPSTREAM new file mode 100644 index 0000000..0e2b048 --- /dev/null +++ b/server/home/Plausible/UPSTREAM @@ -0,0 +1,6 @@ +checkout: ~/Server/Plausible/ +repo: https://github.com/plausible/community-edition +ref: tag v3.0.1 +commit: 20385b05aa8e5645c88fc1a526360608e0fba324 + +compose.yml here is the local, modified copy of the upstream file. diff --git a/server/home/Plausible/compose.yml b/server/home/Plausible/compose.yml new file mode 100644 index 0000000..61b7d94 --- /dev/null +++ b/server/home/Plausible/compose.yml @@ -0,0 +1,131 @@ +networks: + nginx-bridge: + external: true + +services: + plausible_db: + image: postgres:16-alpine + container_name: plausible_db + hostname: plausible_db + networks: [nginx-bridge] + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - db-data:/var/lib/postgresql/data + environment: + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + start_period: 1m + restart: always + tty: true + + plausible_events_db: + image: clickhouse/clickhouse-server:24.12-alpine + container_name: plausible_events_db + hostname: plausible_events_db + networks: [nginx-bridge] + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - event-data:/var/lib/clickhouse + - event-logs:/var/log/clickhouse-server + - ./clickhouse/logs.xml:/etc/clickhouse-server/config.d/logs.xml:ro + # This makes ClickHouse bind to IPv4 only, since Docker doesn't enable IPv6 in bridge networks by default. + # Fixes "Listen [::]:9000 failed: Address family for hostname not supported" warnings. + - ./clickhouse/ipv4-only.xml:/etc/clickhouse-server/config.d/ipv4-only.xml:ro + # This makes ClickHouse consume less resources, which is useful for small setups. + # https://clickhouse.com/docs/en/operations/tips#using-less-than-16gb-of-ram + #- ./clickhouse/low-resources.xml:/etc/clickhouse-server/config.d/low-resources.xml:ro + ulimits: + nofile: + soft: 262144 + hard: 262144 + environment: + - CLICKHOUSE_SKIP_USER_SETUP=1 + healthcheck: + test: + [ + "CMD-SHELL", + "wget --no-verbose --tries=1 -O - http://127.0.0.1:8123/ping || exit 1", + ] + start_period: 1m + restart: always + tty: true + + plausible: + image: ghcr.io/plausible/community-edition:v3.2.1 + container_name: plausible + hostname: plausible + networks: [nginx-bridge] + #ports: + #- ${HTTP_PORT}:8000 + #- ${HTTPS_PORT}:443 + command: sh -c "/entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" + depends_on: + plausible_db: + condition: service_healthy + plausible_events_db: + condition: service_healthy + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - plausible-data:/var/lib/plausible + ulimits: + nofile: + soft: 65535 + hard: 65535 + extra_hosts: + # Pin the relay to the WireGuard address. Docker containers do NOT read the host + # /etc/hosts, and the public *.gbrown.org wildcard still returns Cloudflare AAAA + # records alongside the UniFi A record -- so without this, delivery depends on + # resolver address-ordering. The name (not the IP) is used so the wildcard cert + # still validates by hostname. + - "smtp.usesend.gbrown.org:192.168.2.2" + environment: + - TMPDIR=/var/lib/plausible/tmp + # required: https://github.com/plausible/community-edition/wiki/configuration#required + - BASE_URL=${BASE_URL} + - SECRET_KEY_BASE=${SECRET_KEY_BASE} + # optional: https://github.com/plausible/community-edition/wiki/configuration#optional + # registration: https://github.com/plausible/community-edition/wiki/configuration#registration + - TOTP_VAULT_KEY=${TOTP_VAULT_KEY} + - DISABLE_REGISTRATION=invite_only + - ENABLE_EMAIL_VERIFICATION=true + # web: https://github.com/plausible/community-edition/wiki/configuration#web + - HTTP_PORT=${HTTP_PORT} + - HTTPS_PORT=${HTTPS_PORT} + # databases: https://github.com/plausible/community-edition/wiki/configuration#database + - DATABASE_URL + - CLICKHOUSE_DATABASE_URL + # Google: https://github.com/plausible/community-edition/wiki/configuration#google + - GOOGLE_CLIENT_ID + - GOOGLE_CLIENT_SECRET + # geolocation: https://github.com/plausible/community-edition/wiki/configuration#ip-geolocation + - IP_GEOLOCATION_DB + - GEONAMES_SOURCE_FILE + - MAXMIND_LICENSE_KEY + - MAXMIND_EDITION + # email: https://github.com/plausible/community-edition/wiki/configuration#email + - MAILER_ADAPTER + - MAILER_EMAIL=admin@plausible.gbrown.org + - MAILER_NAME=Gib's Plausible Analytics + - SMTP_HOST_ADDR=smtp.usesend.gbrown.org + - SMTP_HOST_PORT=587 + - SMTP_USER_NAME=${SMTP_USER_NAME} + - SMTP_USER_PWD=${SMTP_USER_PWD} + - SMTP_HOST_SSL_ENABLED=false + - POSTMARK_API_KEY + - MAILGUN_API_KEY + - MAILGUN_DOMAIN + - MAILGUN_BASE_URI + - MANDRILL_API_KEY + - SENDGRID_API_KEY + restart: always + tty: true + +volumes: + db-data: + event-data: + event-logs: + plausible-data: diff --git a/server/home/Plausible/podman-plausible.service b/server/home/Plausible/podman-plausible.service new file mode 100644 index 0000000..313d3a4 --- /dev/null +++ b/server/home/Plausible/podman-plausible.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Plausible +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Plausible +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/PostHog/.env.example b/server/home/PostHog/.env.example new file mode 100644 index 0000000..ece209e --- /dev/null +++ b/server/home/PostHog/.env.example @@ -0,0 +1,29 @@ +POSTHOG_SECRET=CHANGE_ME +ENCRYPTION_SALT_KEYS=CHANGE_ME +BROWSERLESS_SECRET=CHANGE_ME +DOMAIN=CHANGE_ME +REGISTRY_URL=CHANGE_ME +POSTHOG_APP_TAG=CHANGE_ME +TLS_BLOCK=CHANGE_ME +CADDY_TLS_BLOCK=CHANGE_ME +CADDY_HOST=CHANGE_ME +POSTHOG_HTTP_PORT=CHANGE_ME +ANTHROPIC_API_KEY=CHANGE_ME +OPENAI_API_KEY=CHANGE_ME +USESEND_API_KEY=CHANGE_ME +POSTHOG_GITLAB_LOGIN_KEY=CHANGE_ME +POSTHOG_GITLAB_LOGIN_SECRET=CHANGE_ME +SECRET_KEY=CHANGE_ME +OBJECT_STORAGE_ACCESS_KEY_ID=CHANGE_ME +OBJECT_STORAGE_SECRET_ACCESS_KEY=CHANGE_ME +SESSION_RECORDING_V2_S3_ACCESS_KEY_ID=CHANGE_ME +SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY=CHANGE_ME +INTERNAL_API_SECRET=CHANGE_ME +CSP_REPORT_ENDPOINT=CHANGE_ME +DOCKER_REGISTRY_PREFIX=CHANGE_ME +ELAPSED=CHANGE_ME +HEATMAP_BROWSERLESS_SCREENSHOT_COOKIES_ENABLED=CHANGE_ME +OPT_OUT_CAPTURE=CHANGE_ME +POSTHOG_NODE_TAG=CHANGE_ME +SEAWEEDFS_DOCKER_NAME=CHANGE_ME +TIMEOUT=CHANGE_ME diff --git a/server/home/PostHog/README.md b/server/home/PostHog/README.md new file mode 100644 index 0000000..093a564 --- /dev/null +++ b/server/home/PostHog/README.md @@ -0,0 +1,391 @@ +# PostHog (self-hosted) on Fedora + +PostHog's official installer (`bin/deploy-hobby`) assumes Ubuntu/Debian and a +bare host. This directory is that install done by hand, adapted to this server. + +## Why the official installer doesn't work here + +| What `deploy-hobby` does | Why it fails / what we did instead | +|---|---| +| `apt update`, `apt install git brotli curl` | Not Debian. All three are already installed via dnf. | +| Installs Docker from `download.docker.com/linux/ubuntu` | Docker 29.8 is already installed. | +| Downloads the v1 `docker-compose` binary to `/usr/local/bin` | We use the Compose v5 plugin (`docker compose`). | +| Runs Caddy on host ports `80:80` / `443:443` and gets its own Let's Encrypt certs | nginx-proxy-manager owns 80/443. Caddy now serves plain HTTP on `127.0.0.1:8002` and NPM terminates TLS in front of it. | +| Publishes Temporal on `0.0.0.0:7233` and its UI on `0.0.0.0:8081` | Rebound to `127.0.0.1`. | +| Phones home to PostHog Cloud with your domain | Skipped. | +| Generates `compose/start` calling `./compose/wait` | Upstream bug, fixed here. WORKDIR is `/code` but the mount is at `/compose`, so the relative path never resolves. Harmless (`bin/migrate` retries 10x) but it meant the readiness check never ran. Ours calls `/compose/wait`. | + +Note: Docker here runs *without* SELinux labelling (`docker info` lists only +seccomp and cgroupns), so the bind mounts need no `:z` / `:Z` relabelling even +though the host is SELinux-enforcing. + +Caddy is kept rather than replaced by NPM because it does path-based routing +that NPM can't express in one proxy host: `/e`, `/i/v0`, `/batch`, `/capture` +go to `capture`; `/s` to `replay-capture`; `/i/v1/logs` to `capture-logs`; +`/livestream` to `livestream`; `/posthog/*` to object storage; everything else +to `web`. NPM just forwards the whole hostname to Caddy. + +## Layout + + compose.yml docker-compose.hobby.yml + local patches + compose.override.yml additive local settings, auto-merged by compose + docker-compose.base.yml verbatim from the repo (compose.yml `extends` it) + .env secrets + domain (chmod 600, never regenerate) + .env.services verbatim from the repo + posthog/ upstream checkout, only used for config bind mounts + share/ GeoLite2-City.mmdb + compose/ entrypoint scripts the web/worker containers run, + plus local-patches.py (see "Multiple projects" below) + bin/upgrade Fedora-native replacement for bin/upgrade-hobby + bin/apply-local-patches.py replays our compose.yml deltas after an upgrade + +## nginx-proxy-manager + +Add a proxy host: + +- Domain: `posthog.gbrown.org` +- Scheme: `http`, Forward host: `posthog`, Forward port: `80` +- **Websockets: ON** (required — the `/livestream` endpoint) +- Request an SSL cert, force SSL + +`posthog` is a network alias on `nginx-bridge` for the Caddy container. + +Also worth raising NPM's `client_max_body_size` for this host (Advanced tab: +`client_max_body_size 50m;`) — session-recording batches and source-map +uploads exceed the 1 MB default. + +## Two config files, two reasons + +`compose.yml` holds changes that *replace* upstream values -- the proxy's ports +and networks. These have to be patched into the file directly, because Compose +merges list-valued keys like `ports` by appending: an override file saying +`8002:80` would leave you with 80, 443 *and* 8002. `bin/apply-local-patches.py` +replays them after an upgrade overwrites the file. + +`compose.override.yml` holds purely *additive* settings (currently +`TRUST_ALL_PROXIES` for the three Django services). Compose merges it +automatically and no upgrade ever touches it, so nothing needs replaying. + +Rule of thumb: if you're adding an env var, put it in the override file. If +you're changing a port or a network, patch compose.yml and add it to +`bin/apply-local-patches.py`. `command` is the exception: Compose replaces it +on merge rather than appending, so the worker's command lives in the override. + +## Operating it + + docker compose up -d # start + docker compose stop # stop (keeps volumes) + docker compose logs -f web # watch migrations / app + docker compose ps # status + bin/upgrade # upgrade (runs async migration checks first) + +First boot runs Django + ClickHouse migrations and takes 5-15 minutes before +`/_health` returns 200. The first person to visit the site creates the admin +account, so log in promptly. + +## Multiple projects (source patches) + +Without a license, upstream PostHog allows one project per org and hides the +"New project" button. PostHog no longer sells self-hosted licenses, so +`compose/local-patches.py` patches two files in `/code` when `web`, `worker` +and `temporal-django-worker` start: + +- `posthog/utils.py`: `can_create_org` follows `MULTI_ORG_ENABLED` alone, which + shows the button. +- `posthog/models/organization.py`: the org keeps the `organizations_projects` + feature, which the project API checks. Without this, the hourly + `sync_all_organization_available_product_features` task resets the org's + features to `[]` and the API refuses a second project. + +Both are gated on `MULTI_ORG_ENABLED` in `compose.override.yml`. Because they run +at container start, recreating a container no longer loses them. An earlier +version patched running containers by hand and was wiped when the containers +were recreated on 2026-09-22. + +If upstream changes the code, the script logs `PATTERN NOT FOUND` and the app +starts unpatched. Check after upgrades (`bin/upgrade` prints it): + + docker compose logs web worker | grep LOCAL-PATCH + +## Rebooting the host + +A host boot is not `docker compose up`. Dockerd starts containers from each one's +restart policy alone, in no order, and never reads `depends_on`, so the dependency +ordering the stack is built around does not exist at boot time. + +Upstream ships `restart: no` or `on-failure` on nearly every service, and +`on-failure` does not retry a clean exit. Several services exit *zero* on purpose: +`hypercache-server` when it loses the race to `redis7` on a cold boot, and +`capture` when it loses Kafka (on 2026-09-27 a host memory spike stalled Kafka, +capture shut down gracefully, and it stayed down for three days). So +`compose.override.yml` sets `restart: unless-stopped` on every long-running +service. A manual `docker compose stop` is still respected. + +Expect `hypercache-server` to flap a few times early in boot until `redis7` +answers. That is the retry working, not a fault. + +`kafka-init` sitting at `Exited (0)` is correct. It is a one-shot init job and +must keep `restart: no`. + +The web UI serves fine while these four are missing, so check +`docker ps -a --filter name=posthog` for anything not `Up` before assuming a +reboot went cleanly. To bring back anything stopped, in dependency order, without +recreating running containers: + + docker compose up -d --no-recreate + +## Watchtower is deliberately not enabled + +Every other stack here carries `com.centurylinklabs.watchtower.enable=true`. +PostHog does not, on purpose: its upgrades run schema migrations that must be +checked with `asyncmigrationscheck` before the app restarts, and a silent image +swap underneath a running ClickHouse/Kafka pipeline can corrupt data. Use +`bin/upgrade` instead. + +## Resource notes + +This stack is 37 containers (ClickHouse, Kafka, Zookeeper, Postgres, Redis, +Valkey, Temporal, Elasticsearch, SeaweedFS, Browserless, and ~12 Rust/Node +consumers). PostHog asks for 8 GB minimum; with Sentry already running here, +keep an eye on `free -h`. + +If memory gets tight, these can be commented out of `compose.yml` with little +loss on a personal instance: + +- `elasticsearch` — Temporal is configured with `ENABLE_ES=false`, so it is + only there for Temporal's advanced visibility. Saves ~1 GB. +- `temporal-ui` — debugging UI for Temporal workflows only. +- `cymbal` / `cymbal-resolution` — error-tracking stack-trace symbolication. + Skip if you're keeping errors in Sentry rather than PostHog. + +## Ad blockers and the hostname + +The host is `analytics.gbrown.org`. It was originally `posthog.gbrown.org`, which +uBlock Origin blocked, leaving the dashboard a blank white page. + +The culprit is EasyPrivacy line 2964 (EasyPrivacy is enabled by default in uBO): + + ://posthog.$script,domain=~posthog.com + +That is an *unanchored substring* pattern, not a domain rule, so any URL +containing `://posthog.` matches -- including `https://posthog.gbrown.org/...`. +The `$script` option is why the symptom was a white page rather than a failed +connection: the HTML document is a `document` request and loaded fine, but every +`/static/*.js` was blocked, so React never mounted. + +Renaming fixes it. Checked against all five lists uBO enables by default +(EasyPrivacy, EasyList, uBO filters/privacy/badware, ~160k rules), with controls +validating in both directions: + + posthog.gbrown.org BLOCKED (rule above) + analytics.gbrown.org clean + stats/insights/metrics clean + bills.gbrown.org clean (control) + +Also clean on the new host: `/static/array.js`, `/static/recorder.js`, +`/static/recorder-v2.js`, `/e/`, `/i/v0/e/`, `/batch/`, `/capture/`, `/decide/`. + +Generic words like "analytics" survive because the lists anchor to specific +vendor domains (`||google-analytics.com`) rather than bare words -- blocking the +word outright would break the web. The `://posthog.` rule is the unusual one. + +### To change the hostname again + +1. Edit `DOMAIN` in `.env`. +2. Add the proxy host in nginx-proxy-manager: http -> `posthog` -> 80, + websockets on, `client_max_body_size 50m;`. +3. `docker compose up -d`. + +Caddy listens on `:80` regardless of hostname, so nothing inside the stack needs +touching -- that is the payoff of terminating TLS at NPM instead of in Caddy. +Note that `SITE_URL` feeds `CSRF_TRUSTED_ORIGINS`, so existing browser sessions +may need a fresh login after a rename. Capture/API traffic is unaffected. + +Still worth proxying capture through the app's own origin (a Next.js `/ingest` +rewrite): it is same-origin, so no CORS and no preflight, and it survives future +filter-list changes regardless of what this host is called. + +## Capture settings for the bill-tracker app (decided 2026-09-17) + +The bill tracker (Next + Expo + Convex) sends to this instance. Gib asked for +everything on, so both clients ship with session replay, autocapture, heatmaps, +console log capture and exception capture ENABLED, on posthog-js defaults: + + maskAllInputs: true (default — form inputs are masked) + rendered text: NOT masked (default) + +The tradeoff, recorded deliberately rather than discovered later: this is a +finance app, so bill amounts, balances, payee names and due dates are rendered +*text*, not inputs, and therefore land in session replays verbatim. Autocapture +also stores the text of clicked elements, so a button reading "Pay $1,247.33 to +Chase" becomes an event property. Console log capture ships whatever the app +logs, which in dev is often a whole transaction object. + +This is considered acceptable because the instance is self-hosted on Gib's own +hardware and serves his own household — the data never leaves the house. + +To tighten later: + + maskTextSelector: '*' // or scope to the money elements + class="ph-no-capture" // on balance / amount / payee nodes + +One asymmetry worth remembering: tightening does NOT retroactively clean stored +replays. Anything recorded before the change stays recorded, so the cheap moment +to decide is before real household data flows, not after. + +Client config lives in the bill-tracker repo, not here. The host is read from +NEXT_PUBLIC_POSTHOG_HOST / EXPO_PUBLIC_POSTHOG_HOST (Infisical), and web routes +capture through a same-origin /ingest rewrite rather than hitting this host +directly. + +## Blocked asset filenames — why the client requests ext-a.js / ext-b.js + +Renaming the host to `analytics.gbrown.org` fixed the dashboard, but two of +PostHog's own script filenames are blocked by rules that match the FILENAME, not +the host, so a reverse proxy cannot dodge them: + + easyprivacy.txt line 1906: /posthog-recorder.js + easyprivacy.txt line 809: /dead-clicks-autocapture.js + +Both are unanchored substrings with no type or domain options, so they match on +any host, any request type, first-party included. Confirmed blocked both direct +and through the app's /ingest proxy. Symptom: session replay and dead-click +capture silently never start for anyone running uBlock. + +The bill-tracker app works around this with posthog-js's +`prepare_external_dependency_script` hook, serving them under inert names: + + posthog-recorder.js -> ext-a.js + dead-clicks-autocapture.js -> ext-b.js + +(`strict_script_versioning` off, so only the plain `/static/.js?v=` form is +requested.) The Next rewrite maps the disguised names back. Verified: replay +blobs land in `posthog.session_replay_events`, which was stuck at 0 before. + +Everything else PostHog serves is clean on both paths — `array.js`, +`array.full.js`, `recorder.js`, `recorder-v2.js`, `surveys.js`, +`exception-autocapture.js`, `web-vitals-with-attribution.js`, +`tracing-headers.js`, `toolbar.js`, `external-scripts-loader.js`, `/e/`, +`/flags/`. Note the legacy `recorder.js` and `recorder-v2.js` names are NOT +listed; only the newer `posthog-recorder.js` is. + +One more, shape-dependent: `/s/array//config` is blocked by +ubo-privacy.txt line 3159 (`$script,xhr,1p` — first-party too), but +`/array//config.js` is clean. posthog-js 1.433.10 requests the latter, so +it does not bite today. Worth re-checking after a major posthog-js upgrade. + +### Expected absences (not bugs) + +- `$pageleave` does NOT fire on client-side SPA navigation. posthog-js hangs it + off the real unload/visibilitychange lifecycle, so it tracks actual page exits + only. Verified against every pageleave in the instance. +- `$web_vitals` flushes on page hide; `$dead_click` needs a click that resolves + to nothing. Both stay absent until those conditions occur. + +## nginx-proxy-manager buffer sizes (required, or replay breaks) + +Symptom: the Session Replay list page shows "Error while trying to load +recordings". `GET /api/environments//session_recordings` returns 502 from +openresty in ~130ms, while a SINGLE recording +(`.../session_recordings/`) returns 200 and `/events` returns 200. + +Cause is nginx, not PostHog. From `/data/logs/proxy-host-121_error.log`: + + upstream sent too big header while reading response header from upstream + +nginx's default `proxy_buffer_size` is 4-8KB. PostHog's session_recordings LIST +endpoint sends response headers larger than that, so nginx aborts before reading +the body and returns 502. The request never reaches Django, which is why nothing +appears in the web container logs and Caddy logs no 5xx at all. The single +recording endpoint sends normal-sized headers, hence the asymmetry. + +Required in the NPM Advanced tab for this proxy host: + + client_max_body_size 50m; + proxy_buffering on; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + +The Sentry proxy host (1.conf) already carried the same three buffer directives — +same nginx default, same fix, just never applied to PostHog's host. + +Verified in `/data/logs/proxy-host-121_access.log`: 502 at 22:46:06 before the +change, 200 at 22:46:36 after, no further "too big header" entries. + +Do NOT edit the generated `.conf` files directly — NPM regenerates them from its +own database, so a direct edit reverts on the next save. + +### Verifying replay from the server side + +ClickHouse `session_replay_events` proves events ARRIVED; it does not prove they +can be PLAYED BACK. Those are different paths (ingestion -> Kafka -> object +storage vs. API -> proxy -> browser). To check the storage side: + + docker compose exec seaweedfs sh -c \ + "echo 'fs.ls -l /buckets/posthog/session_recordings/5y' | weed shell -master=localhost:9333" + +Blobs live under `session_recordings//`, named `-`. + +## "No recordings found" with replay working fine + +The Session Replay list ships a DEFAULT filter of `> 5 active seconds`. Any +session with 5 or fewer *active* seconds is hidden, even though it recorded +perfectly and plays back fine. + +Active time is not wall-clock time. The first verified session here ran 458s +(7.6 min) but logged only 643ms of activity — one click and a little mouse +movement in an otherwise idle tab. PostHog counts interaction, not duration, +deliberately, to suppress bounces and accidental loads. + +That default makes quick dev checks vanish and looks identical to "replay is +broken". To see them: open the duration chip on /replay/home and set the +condition to `> 0 active seconds`, or save a filter preset. Date range and the +"filter out internal and test users" toggle are usually NOT the culprit. + +Check the real numbers server-side before assuming anything is missing: + + docker compose exec clickhouse clickhouse-client -q \ + "select session_id, sum(active_milliseconds) active_ms, + dateDiff('second', min(min_first_timestamp), max(max_last_timestamp)) duration_s, + sum(click_count) clicks, sum(event_count) evts + from posthog.session_replay_events group by session_id" + +Verified 2026-09-17: session 01a0b177-d35c-7292-ac59-021af608d29a recorded, +listed once the threshold was lowered, and played back in the browser. + +## Real client IPs through NPM -> Caddy + +By default Django logged `x_forwarded_for=172.25.0.8` — nginx-proxy-manager's own +container IP — so every event's `$ip` resolved to the proxy and GeoIP failed on a +private address. + +NPM was not at fault; it sends `X-Forwarded-For $proxy_add_x_forwarded_for` and +`X-Real-IP $remote_addr` from its global proxy include. The loss was in Caddy: +its global options block was empty, so `trusted_proxies` was unset, and Caddy's +default is to distrust an incoming X-Forwarded-For and OVERWRITE it with the +direct peer. Proven by sending `X-Forwarded-For: 9.9.9.9` to Caddy and watching +Django log `172.25.0.1`. + +Fixed in `.env` — no compose patch, because `CADDY_TLS_BLOCK` is substituted +directly into Caddy's global options block: + + CADDY_TLS_BLOCK="servers { + trusted_proxies static 172.25.0.0/24 + }" + +Multi-line values work in `.env`; `docker compose config` resolves it correctly +and `caddy validate` accepts it. Apply with `docker compose up -d proxy`. + +Verified after: a spoofed `X-Forwarded-For: 9.9.9.9` arrives as `ip=9.9.9.9`, and +a real request through NPM arrives as the true client address. + +TRADEOFF: `/24` trusts every container on nginx-bridge (~42 of them), any of +which could forge an X-Forwarded-For that Caddy now believes — compounded by +`TRUST_ALL_PROXIES=true` on Django (see compose.override.yml). Acceptable for a +LAN-only household instance; tighten to NPM's address if this is ever exposed to +the internet. Pinning `172.25.0.8/32` is narrower but breaks silently whenever +Docker reassigns NPM's IP. + +Note this does NOT produce geolocation: LAN clients are 192.168.x.x, which is not +in the MaxMind database, so GeoIP still errors. What it buys is the ability to +tell devices apart. diff --git a/server/home/PostHog/UPSTREAM b/server/home/PostHog/UPSTREAM new file mode 100644 index 0000000..6f852a2 --- /dev/null +++ b/server/home/PostHog/UPSTREAM @@ -0,0 +1,6 @@ +checkout: ~/Server/PostHog/posthog/ +repo: https://github.com/PostHog/posthog +ref: master at this commit +commit: ddfaf8843fca8e796bf1f795976842d2d1adb380 + +docker-compose.base.yml is the upstream file at that commit, unchanged. diff --git a/server/home/PostHog/bin/apply-local-patches.py b/server/home/PostHog/bin/apply-local-patches.py new file mode 100755 index 0000000..7b40a57 --- /dev/null +++ b/server/home/PostHog/bin/apply-local-patches.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Re-apply this server's deltas to a freshly-copied docker-compose.hobby.yml. + +Upstream's bin/upgrade-hobby overwrites compose.yml from the repo on every +upgrade, so these three changes have to be replayed afterwards. Idempotent: +running it on an already-patched file is a no-op. + +Usage: bin/apply-local-patches.py [compose.yml] +""" +import sys + +path = sys.argv[1] if len(sys.argv) > 1 else 'compose.yml' +s = open(path).read() +changed = [] + +# 1) nginx-proxy-manager owns host 80/443. Publish Caddy on loopback instead and +# put it on nginx-bridge so NPM can reach it by name. +old = """ ports: + - '80:80' + - '443:443' +""" +new = """ ports: + # nginx-proxy-manager owns host 80/443. Loopback only; NPM reaches + # this container over nginx-bridge as "posthog". + - '127.0.0.1:${POSTHOG_HTTP_PORT:-8002}:80' + networks: + default: + nginx-bridge: + aliases: + - posthog +""" +if old in s: + s = s.replace(old, new) + changed.append('proxy ports + nginx-bridge') + +# 2) TLS terminates at NPM, so Caddy must not try to get its own certs. Take the +# site address from .env (CADDY_HOST=:80) instead of the hardcoded domain. +old = " CADDY_HOST: '$DOMAIN, http://, https://'\n" +if old in s: + s = s.replace(old, " CADDY_HOST: $CADDY_HOST\n") + changed.append('CADDY_HOST from .env') + +# 3) Temporal gRPC + UI on loopback, not every interface. +for a, b in ((" - 7233:7233\n", " - 127.0.0.1:7233:7233\n"), + (" - 8081:8080\n", " - 127.0.0.1:8081:8080\n")): + if a in s: + s = s.replace(a, b) + changed.append(a.strip()) + +# 4) nginx-bridge is created and owned by ~/Server/Nginx. +old = "networks:\n otel_network:\n" +if old in s and 'nginx-bridge:\n external: true' not in s: + s = s.replace(old, "networks:\n otel_network:\n nginx-bridge:\n external: true\n") + changed.append('nginx-bridge external') + +open(path, 'w').write(s) +print(f"{path}: " + (", ".join(changed) if changed else "already patched, no changes")) diff --git a/server/home/PostHog/bin/upgrade b/server/home/PostHog/bin/upgrade new file mode 100755 index 0000000..dcdc187 --- /dev/null +++ b/server/home/PostHog/bin/upgrade @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Fedora-native replacement for posthog/bin/upgrade-hobby. +# +# Upstream's script is unusable here: it calls apt-get, re-downloads the v1 +# `docker-compose` binary, and overwrites compose.yml from the repo (throwing +# away this server's nginx-proxy-manager and port changes). This does the same +# work using dnf-era tooling and replays the local patches afterwards. +# +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "==> Refreshing the PostHog checkout" +git -C posthog fetch origin +git -C posthog reset --hard origin/master +echo " now on $(git -C posthog rev-parse --short HEAD)" + +echo "==> Refreshing compose files and re-applying local patches" +cp posthog/docker-compose.base.yml docker-compose.base.yml +cp posthog/docker-compose.hobby.yml compose.yml +cp posthog/.env.services .env.services +bin/apply-local-patches.py compose.yml +docker compose config >/dev/null && echo " compose config OK" + +echo "==> Refreshing the GeoIP database" +curl -sL 'https://mmdbcdn.posthog.net/' --http1.1 | brotli --decompress > share/GeoLite2-City.mmdb +echo "{\"date\": \"$(date +%F)\"}" > share/GeoLite2-City.json +chmod 644 share/GeoLite2-City.mmdb share/GeoLite2-City.json + +echo "==> Pulling images" +docker compose pull + +echo "==> Checking async migrations against the new version" +docker compose up -d db redis7 clickhouse +docker compose run --rm asyncmigrationscheck + +echo "==> Restarting the stack" +docker compose up -d --remove-orphans + +# web, worker and temporal-django-worker apply compose/local-patches.py on start. +# Surface the result so an upstream change that breaks a patch is not missed. +echo "==> Local source patches" +sleep 15 +docker compose logs --since 5m web worker temporal-django-worker 2>&1 | grep LOCAL-PATCH || \ + echo " no LOCAL-PATCH lines yet, check: docker compose logs web worker | grep LOCAL-PATCH" + +echo +echo "Done. Watch the migrations with: docker compose logs -f web" diff --git a/server/home/PostHog/compose.override.yml b/server/home/PostHog/compose.override.yml new file mode 100644 index 0000000..d445a04 --- /dev/null +++ b/server/home/PostHog/compose.override.yml @@ -0,0 +1,99 @@ +services: + web: &id001 + restart: unless-stopped + environment: + TRUST_ALL_PROXIES: 'true' + MULTI_ORG_ENABLED: 'true' + # SSO: the "GitLab" login button is really Authentik. NPM on the VPS maps GitLab's + # /oauth/* and /api/v4/user paths on auth.gbrown.org to Authentik, the same shim + # Infisical uses. GitLab login needs no license here; only Google is gated. + SOCIAL_AUTH_GITLAB_API_URL: https://auth.gbrown.org + SOCIAL_AUTH_GITLAB_KEY: ${POSTHOG_GITLAB_LOGIN_KEY:?set POSTHOG_GITLAB_LOGIN_KEY in .env} + SOCIAL_AUTH_GITLAB_SECRET: ${POSTHOG_GITLAB_LOGIN_SECRET:?set POSTHOG_GITLAB_LOGIN_SECRET in .env} + PERSONHOG_ADDR: personhog-router:50052 + PERSISTED_FEATURE_FLAGS: sql-editor-vim-mode,cdp-hog-sources,customer-analytics-roadmap,marketing-analytics,web-experiments,dashboard-widgets,twig,tracing,metrics + EMAIL_ENABLED: 'true' + EMAIL_HOST: smtp.usesend.gbrown.org + EMAIL_PORT: '587' + EMAIL_HOST_USER: usesend + EMAIL_HOST_PASSWORD: ${USESEND_API_KEY:?set USESEND_API_KEY in .env} + EMAIL_USE_TLS: 'true' + EMAIL_USE_SSL: 'false' + EMAIL_DEFAULT_FROM: PostHog + EMAIL_REPLY_TO: analytics@analytics.gbrown.org + worker: + <<: *id001 + # compose/worker applies compose/local-patches.py, then runs upstream's + # command. `command` is replaced on merge (not appended), so it is safe here. + command: /compose/worker + volumes: + - ./compose:/compose + temporal-django-worker: *id001 + # Every long-running service restarts regardless of exit code. Upstream ships + # `on-failure` (or `no`), which never retries a clean exit, and these services + # exit 0 on purpose: capture shuts down gracefully when it loses Kafka + # (2026-09-27, down 3 days), hypercache-server exits 0 when it beats redis7 at + # boot. web/worker/temporal-django-worker get this via the anchor above. + # kafka-init and asyncmigrationscheck are one-shots and must stay `no`. + browserless: + restart: unless-stopped + capture: + restart: unless-stopped + capture-logs: + restart: unless-stopped + clickhouse: + restart: unless-stopped + cymbal: + restart: unless-stopped + cymbal-resolution: + restart: unless-stopped + db: + restart: unless-stopped + elasticsearch: + restart: unless-stopped + feature-flags: + restart: unless-stopped + hypercache-server: + restart: unless-stopped + ingestion-error-tracking: + restart: unless-stopped + ingestion-general: + restart: unless-stopped + ingestion-logs: + restart: unless-stopped + ingestion-sessionreplay: + restart: unless-stopped + ingestion-traces: + restart: unless-stopped + kafka: + restart: unless-stopped + livestream: + restart: unless-stopped + objectstorage: + restart: unless-stopped + personhog-replica: + restart: unless-stopped + personhog-router: + restart: unless-stopped + plugins: + restart: unless-stopped + property-defs-rs: + restart: unless-stopped + recording-api: + restart: unless-stopped + redis7: + restart: unless-stopped + replay-capture: + restart: unless-stopped + seaweedfs: + restart: unless-stopped + temporal: + restart: unless-stopped + temporal-admin-tools: + restart: unless-stopped + temporal-ui: + restart: unless-stopped + valkey: + restart: unless-stopped + zookeeper: + restart: unless-stopped diff --git a/server/home/PostHog/compose.yml b/server/home/PostHog/compose.yml new file mode 100644 index 0000000..df95f06 --- /dev/null +++ b/server/home/PostHog/compose.yml @@ -0,0 +1,767 @@ +# +# `docker-compose` file used ONLY for hobby deployments. +# +# Please take a look at https://posthog.com/docs/self-host/deploy/hobby +# for more info. +# +# PostHog has sunset support for self-hosted K8s deployments. +# See: https://posthog.com/blog/sunsetting-helm-support-posthog +# + +x-logging: &default-logging + driver: json-file + options: + max-size: '50m' + max-file: '3' + +services: + db: + extends: + file: docker-compose.base.yml + service: db + image: ${DOCKER_REGISTRY_PREFIX:-}postgres:15.12-alpine + logging: *default-logging + volumes: + - postgres-data:/var/lib/postgresql/data + + redis7: + extends: + file: docker-compose.base.yml + service: redis7 + logging: *default-logging + volumes: + - redis7-data:/data + + valkey: + extends: + file: docker-compose.base.yml + service: valkey + logging: *default-logging + + clickhouse: + extends: + file: docker-compose.base.yml + service: clickhouse + restart: on-failure + logging: *default-logging + depends_on: + - kafka + - zookeeper + volumes: + - ./posthog/posthog/idl:/idl + - ./posthog/docker/clickhouse/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d + - ./posthog/docker/clickhouse/config.xml:/etc/clickhouse-server/config.xml + - ./posthog/docker/clickhouse/config.d/default.xml:/etc/clickhouse-server/config.d/default.xml + - ./posthog/docker/clickhouse/users.xml:/etc/clickhouse-server/users.xml + - ./posthog/docker/clickhouse/user_defined_function.xml:/etc/clickhouse-server/user_defined_function.xml + - ./posthog/posthog/user_scripts:/var/lib/clickhouse/user_scripts + - clickhouse-data:/var/lib/clickhouse + + zookeeper: + extends: + file: docker-compose.base.yml + service: zookeeper + logging: *default-logging + environment: + ZOO_AUTOPURGE_PURGEINTERVAL: 1 + ZOO_AUTOPURGE_SNAPRETAINCOUNT: 3 + volumes: + - zookeeper-datalog:/datalog + - zookeeper-data:/data + - zookeeper-logs:/logs + kafka: + extends: + file: docker-compose.base.yml + service: kafka + logging: *default-logging + depends_on: + - zookeeper + environment: + KAFKA_LOG_RETENTION_MS: 3600000 + KAFKA_LOG_RETENTION_CHECK_INTERVAL_MS: 300000 + KAFKA_LOG_RETENTION_HOURS: 1 + volumes: + - kafka-data:/bitnami/kafka + + worker: + extends: + file: docker-compose.base.yml + service: worker + logging: *default-logging + environment: + SITE_URL: https://$DOMAIN + SECRET_KEY: ${SECRET_KEY} + OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID} + OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY} + OBJECT_STORAGE_ENDPOINT: http://objectstorage:19000 + OBJECT_STORAGE_PUBLIC_ENDPOINT: https://$DOMAIN + SESSION_RECORDING_V2_S3_ENDPOINT: http://seaweedfs:8333 + SESSION_RECORDING_V2_S3_ACCESS_KEY_ID: ${SESSION_RECORDING_V2_S3_ACCESS_KEY_ID} + SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: ${SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY} + OBJECT_STORAGE_ENABLED: true + RECORDING_API_URL: http://recording-api:6738 + INTERNAL_API_SECRET: ${INTERNAL_API_SECRET:-$POSTHOG_SECRET} + ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS} + POSTHOG_SKIP_MIGRATION_CHECKS: '1' + PERSONHOG_ADDR: 'personhog-router:50052' + PERSONHOG_ENABLED: 'true' + BROWSERLESS_CDP_URL: 'ws://browserless:3000' + BROWSERLESS_TOKEN: ${BROWSERLESS_SECRET:-$POSTHOG_SECRET} + HEATMAP_BROWSERLESS_URL: 'http://browserless:3000' + HEATMAP_BROWSERLESS_TOKEN: ${BROWSERLESS_SECRET:-$POSTHOG_SECRET} + HEATMAP_BROWSERLESS_SCREENSHOT_COOKIES_ENABLED: ${HEATMAP_BROWSERLESS_SCREENSHOT_COOKIES_ENABLED:-false} + # Point Django at the Rust feature-flags container; the setting + # otherwise defaults to localhost:3001. + FEATURE_FLAGS_SERVICE_URL: 'http://feature-flags:3001' + # The logs product uses a dedicated ClickHouse cluster config (separate + # from CLICKHOUSE_HOST) whose host defaults to localhost and whose SECURE + # defaults to true outside DEBUG. Point it at the stack's ClickHouse over + # plaintext; the other logs-cluster settings already default correctly + # (native port 9000, user "default", empty password). + CLICKHOUSE_LOGS_CLUSTER_HOST: 'clickhouse' + CLICKHOUSE_LOGS_CLUSTER_SECURE: 'false' + # AI provider keys for PostHog's AI features. Blank unless set in .env. + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + image: $REGISTRY_URL:$POSTHOG_APP_TAG + depends_on: + db: + condition: service_healthy + redis7: + condition: service_healthy + clickhouse: + condition: service_started + kafka: + condition: service_healthy + web: + condition: service_started + personhog-router: + condition: service_started + browserless: + condition: service_started + web: + extends: + file: docker-compose.base.yml + service: web + command: /compose/start + logging: *default-logging + volumes: + - ./compose:/compose + image: $REGISTRY_URL:$POSTHOG_APP_TAG + environment: + SITE_URL: https://$DOMAIN + LIVESTREAM_HOST: 'https://${DOMAIN}/livestream' + SECRET_KEY: ${SECRET_KEY} + OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID} + OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY} + SESSION_RECORDING_V2_S3_ACCESS_KEY_ID: ${SESSION_RECORDING_V2_S3_ACCESS_KEY_ID} + SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: ${SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY} + OBJECT_STORAGE_ENDPOINT: http://objectstorage:19000 + OBJECT_STORAGE_PUBLIC_ENDPOINT: https://$DOMAIN + SESSION_RECORDING_V2_S3_ENDPOINT: http://seaweedfs:8333 + OBJECT_STORAGE_ENABLED: true + RECORDING_API_URL: http://recording-api:6738 + INTERNAL_API_SECRET: ${INTERNAL_API_SECRET:-$POSTHOG_SECRET} + ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS} + OTEL_SERVICE_NAME: 'posthog' + OTEL_EXPORTER_OTLP_ENDPOINT: '' + OTEL_SDK_DISABLED: 'true' + OPT_OUT_CAPTURE: ${OPT_OUT_CAPTURE:-false} + # Blank sends no CSP reports. Point it at this instance's own /report/ endpoint to + # collect them yourself. + CSP_REPORT_ENDPOINT: ${CSP_REPORT_ENDPOINT:-} + DEPLOYMENT: 'hobby' + PERSONHOG_ADDR: 'personhog-router:50052' + PERSONHOG_ENABLED: 'true' + # Point Django at the Rust feature-flags container; the setting + # otherwise defaults to localhost:3001. + FEATURE_FLAGS_SERVICE_URL: 'http://feature-flags:3001' + # The logs product uses a dedicated ClickHouse cluster config (separate + # from CLICKHOUSE_HOST) whose host defaults to localhost and whose SECURE + # defaults to true outside DEBUG. Point it at the stack's ClickHouse over + # plaintext; the other logs-cluster settings already default correctly + # (native port 9000, user "default", empty password). + CLICKHOUSE_LOGS_CLUSTER_HOST: 'clickhouse' + CLICKHOUSE_LOGS_CLUSTER_SECURE: 'false' + # AI provider keys for PostHog's AI features. Blank unless set in .env. + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + depends_on: + - db + - redis7 + - clickhouse + - kafka + - objectstorage + - seaweedfs + - personhog-router + + plugins: + extends: + file: docker-compose.base.yml + service: plugins + image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest} + logging: *default-logging + environment: + SITE_URL: https://$DOMAIN + SECRET_KEY: ${SECRET_KEY} + OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID} + OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY} + OBJECT_STORAGE_ENDPOINT: http://objectstorage:19000 + OBJECT_STORAGE_PUBLIC_ENDPOINT: https://$DOMAIN + OBJECT_STORAGE_ENABLED: true + CDP_REDIS_HOST: redis7 + CDP_REDIS_PORT: 6379 + LOGS_REDIS_HOST: redis7 + LOGS_REDIS_PORT: 6379 + LOGS_REDIS_TLS: 'false' + TRACES_REDIS_HOST: redis7 + TRACES_REDIS_PORT: 6379 + TRACES_REDIS_TLS: 'false' + # Every CDP process dual-writes to Valkey and refuses to start without a host. + CDP_VALKEY_HOST: valkey + CDP_VALKEY_PORT: 6379 + ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS} + PERSONS_DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog' + BEHAVIORAL_COHORTS_DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog' + OTEL_EXPORTER_OTLP_ENDPOINT: '' + volumes: + - ./share:/share + depends_on: + - db + - redis7 + - valkey + - clickhouse + - kafka + - objectstorage + - seaweedfs + + ingestion-general: + extends: + file: docker-compose.base.yml + service: ingestion-general + image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest} + logging: *default-logging + environment: + PERSONS_DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog' + BEHAVIORAL_COHORTS_DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog' + ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS} + # The combined-mode AI consumer reads persons through PersonHog + PERSONHOG_ADDR: 'personhog-router:50052' + PERSONHOG_ENABLED: 'true' + depends_on: + - db + - redis7 + - clickhouse + - kafka + # The combined AI consumer's blob store healthchecks the ai-blobs bucket at startup + - objectstorage + - personhog-router + + ingestion-sessionreplay: + extends: + file: docker-compose.base.yml + service: ingestion-sessionreplay + image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest} + logging: *default-logging + environment: + SESSION_RECORDING_V2_S3_ACCESS_KEY_ID: ${SESSION_RECORDING_V2_S3_ACCESS_KEY_ID} + SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: ${SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY} + SESSION_RECORDING_V2_S3_TIMEOUT_MS: 120000 + SESSION_RECORDING_V2_S3_ENDPOINT: http://seaweedfs:8333 + ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS} + depends_on: + - db + - redis7 + - kafka + - seaweedfs + + recording-api: + extends: + file: docker-compose.base.yml + service: recording-api + image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest} + logging: *default-logging + environment: + # Read side of the session recording V2 blob store. Must point at the same + # SeaweedFS the ingestion-sessionreplay writer uses — without these the + # service falls back to its localhost:8333 default, so every playback + # request 500s with ECONNREFUSED ("We're unable to play this recording"). + SESSION_RECORDING_V2_S3_ACCESS_KEY_ID: ${SESSION_RECORDING_V2_S3_ACCESS_KEY_ID} + SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: ${SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY} + SESSION_RECORDING_V2_S3_ENDPOINT: http://seaweedfs:8333 + ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS} + # Must resolve to the same value as the web and worker services, which send it as + # X-Internal-Api-Secret. The JWT signing secret is deliberately left unset here, so hobby + # stays on the legacy shared secret until that scheme is rolled out. + INTERNAL_API_SECRET: ${INTERNAL_API_SECRET:-$POSTHOG_SECRET} + depends_on: + - db + - redis7 + - clickhouse + - seaweedfs + + ingestion-error-tracking: + extends: + file: docker-compose.base.yml + service: ingestion-error-tracking + image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest} + logging: *default-logging + environment: + ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS} + ERROR_TRACKING_CYMBAL_BASE_URL: 'http://cymbal:3302' + depends_on: + - db + - redis7 + - kafka + + ingestion-logs: + extends: + file: docker-compose.base.yml + service: ingestion-logs + image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest} + logging: *default-logging + environment: + LOGS_REDIS_TLS: 'false' + depends_on: + - db + - redis7 + - kafka + + ingestion-traces: + extends: + file: docker-compose.base.yml + service: ingestion-traces + image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest} + logging: *default-logging + environment: + TRACES_REDIS_TLS: 'false' + depends_on: + - db + - redis7 + - kafka + + proxy: + extends: + file: docker-compose.base.yml + service: proxy + logging: *default-logging + ports: + # nginx-proxy-manager owns host 80/443. Loopback only; NPM reaches + # this container over nginx-bridge as "posthog". + - '127.0.0.1:${POSTHOG_HTTP_PORT:-8002}:80' + networks: + default: + nginx-bridge: + aliases: + - posthog + volumes: + - caddy-data:/data + - caddy-config:/config + environment: + CADDY_TLS_BLOCK: $TLS_BLOCK + CADDY_HOST: $CADDY_HOST + depends_on: + - web + - livestream + objectstorage: + # SeaweedFS on the same objectstorage:19000 endpoint and credentials this service + # served when it ran MinIO, so no other service in this file changes. + extends: + file: docker-compose.base.yml + service: objectstorage + restart: on-failure + logging: *default-logging + # The dev/CI bootstrap this replaces also registers an `anonymous` identity with + # Admin rights. Hobby must not, because Caddy proxies /posthog/* straight to this + # service: an anonymous identity opens every object to the internet. With only the + # posthog identity, SeaweedFS rejects unsigned requests and the presigned URLs + # Django hands out keep working. There is no duckgres here, so there is no + # ducklake-dev bucket either. The loop holds no shell variables on purpose: + # bin/upgrade-hobby pipes this file through envsubst, which collapses a + # doubled-dollar variable reference to a bare dollar sign. + entrypoint: + - /bin/sh + - -c + - | + ( + while true; do + printf 's3.configure -access_key=object_storage_root_user -secret_key=object_storage_root_password -user=posthog -actions=Admin -apply\ns3.bucket.create -name posthog\ns3.bucket.create -name ai-blobs\n' \ + | /usr/bin/weed shell -master=localhost:19001 >/dev/null 2>&1 || true + if echo 's3.bucket.list' | /usr/bin/weed shell -master=localhost:19001 2>/dev/null | grep -q ai-blobs \ + && echo 's3.configure' | /usr/bin/weed shell -master=localhost:19001 2>/dev/null | grep -q object_storage_root_user; then + touch /tmp/objectstorage-ready + echo "Objectstorage SeaweedFS ready with required buckets and identities" + break + fi + sleep 1 + done + ) & + exec /usr/bin/weed "$$@" + - -- + # A new volume. SeaweedFS cannot read the MinIO layout, so the old objects stay on + # the `objectstorage` volume until the operator copies them across with + # bin/migrate-storage-hobby or deletes the volume. + volumes: + - objectstorage-data:/data + ports: + # Localhost only. MinIO published this port on every interface. + - '127.0.0.1:19000:19000' + + seaweedfs: + extends: + file: docker-compose.base.yml + service: seaweedfs + container_name: '${SEAWEEDFS_DOCKER_NAME:-seaweedfs-main}' + restart: on-failure + logging: *default-logging + ports: + - '127.0.0.1:8333:8333' # S3 API + - '127.0.0.1:9333:9333' # Master server + volumes: + - seaweedfs:/data + healthcheck: + test: + [ + 'CMD', + 'sh', + '-c', + "echo 's3.bucket.list' | /usr/bin/weed shell -master=localhost:9333 2>&1 | grep -q posthog", + ] + interval: 5s + timeout: 10s + retries: 30 + start_period: 10s + + browserless: + # Renders image exports and heatmap screenshots (no browser ships in the PostHog image). + image: ghcr.io/browserless/chromium:v2.51.2 + restart: on-failure + logging: *default-logging + environment: + TOKEN: ${BROWSERLESS_SECRET:-$POSTHOG_SECRET} + LOG_LEVEL: warn + DEBUG: '-*' + + asyncmigrationscheck: + extends: + file: docker-compose.base.yml + service: asyncmigrationscheck + image: $REGISTRY_URL:$POSTHOG_APP_TAG + logging: *default-logging + environment: + SITE_URL: https://$DOMAIN + SECRET_KEY: ${SECRET_KEY} + SKIP_ASYNC_MIGRATIONS_SETUP: 0 + + # Temporal containers + temporal: + extends: + file: docker-compose.base.yml + service: temporal + logging: *default-logging + environment: + - ENABLE_ES=false + ports: + - 127.0.0.1:7233:7233 + volumes: + - ./posthog/docker/temporal/dynamicconfig:/etc/temporal/config/dynamicconfig + elasticsearch: + extends: + file: docker-compose.base.yml + service: elasticsearch + logging: *default-logging + temporal-admin-tools: + extends: + file: docker-compose.base.yml + service: temporal-admin-tools + logging: *default-logging + depends_on: + - temporal + temporal-ui: + extends: + file: docker-compose.base.yml + service: temporal-ui + logging: *default-logging + ports: + - 127.0.0.1:8081:8080 + depends_on: + temporal: + condition: service_started + db: + condition: service_healthy + temporal-django-worker: + command: /compose/temporal-django-worker + extends: + file: docker-compose.base.yml + service: temporal-django-worker + logging: *default-logging + volumes: + - ./compose:/compose + image: $REGISTRY_URL:$POSTHOG_APP_TAG + environment: + SITE_URL: https://$DOMAIN + SECRET_KEY: ${SECRET_KEY} + BROWSERLESS_CDP_URL: 'ws://browserless:3000' + BROWSERLESS_TOKEN: ${BROWSERLESS_SECRET:-$POSTHOG_SECRET} + HEATMAP_BROWSERLESS_URL: 'http://browserless:3000' + HEATMAP_BROWSERLESS_TOKEN: ${BROWSERLESS_SECRET:-$POSTHOG_SECRET} + HEATMAP_BROWSERLESS_SCREENSHOT_COOKIES_ENABLED: ${HEATMAP_BROWSERLESS_SCREENSHOT_COOKIES_ENABLED:-false} + # This worker runs the recording deletion Temporal activities, which call + # recording-api. Without these two the calls go out unauthenticated and + # recording-api rejects them with a 401. + RECORDING_API_URL: http://recording-api:6738 + INTERNAL_API_SECRET: ${INTERNAL_API_SECRET:-$POSTHOG_SECRET} + # Point Django at the Rust feature-flags container; the setting + # otherwise defaults to localhost:3001. + FEATURE_FLAGS_SERVICE_URL: 'http://feature-flags:3001' + # The logs product uses a dedicated ClickHouse cluster config (separate + # from CLICKHOUSE_HOST) whose host defaults to localhost and whose SECURE + # defaults to true outside DEBUG. Point it at the stack's ClickHouse over + # plaintext; the other logs-cluster settings already default correctly + # (native port 9000, user "default", empty password). This worker runs + # the logs alert-check Temporal activities, which query ClickHouse under + # Workload.LOGS. + CLICKHOUSE_LOGS_CLUSTER_HOST: 'clickhouse' + CLICKHOUSE_LOGS_CLUSTER_SECURE: 'false' + # AI provider keys for PostHog's AI features. Blank unless set in .env. + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + depends_on: + - db + - redis7 + - clickhouse + - kafka + - objectstorage + - seaweedfs + - temporal + - browserless + + capture: + build: + context: ./posthog/rust + extends: + file: docker-compose.base.yml + service: capture + logging: *default-logging + + capture-logs: + build: + context: ./posthog/rust + extends: + file: docker-compose.base.yml + service: capture-logs + logging: *default-logging + + replay-capture: + build: + context: ./posthog/rust + extends: + file: docker-compose.base.yml + service: replay-capture + logging: *default-logging + + property-defs-rs: + build: + context: ./posthog/rust + extends: + file: docker-compose.base.yml + service: property-defs-rs + logging: *default-logging + depends_on: + kafka-init: + condition: service_completed_successfully + db: + condition: service_healthy + + livestream: + extends: + file: docker-compose.base.yml + service: livestream + logging: *default-logging + environment: + - LIVESTREAM_JWT_SECRET=${POSTHOG_SECRET} + volumes: + - ./posthog/docker/livestream/configs-hobby.yml:/configs/configs.yml + + personhog-replica: + build: + context: ./posthog/rust + extends: + file: docker-compose.base.yml + service: personhog-replica + logging: *default-logging + environment: + PRIMARY_DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog' + depends_on: + db: + condition: service_healthy + + personhog-router: + build: + context: ./posthog/rust + extends: + file: docker-compose.base.yml + service: personhog-router + logging: *default-logging + depends_on: + - personhog-replica + + feature-flags: + build: + context: ./posthog/rust + extends: + file: docker-compose.base.yml + service: feature-flags + logging: *default-logging + depends_on: + - db + - redis7 + + hypercache-server: + build: + context: ./posthog/rust + extends: + file: docker-compose.base.yml + service: hypercache-server + logging: *default-logging + depends_on: + - redis7 + + kafka-init: + image: docker.io/redpandadata/redpanda:v25.1.9 + entrypoint: /bin/sh + logging: *default-logging + command: + - -c + - | + set -x + echo "Waiting for Kafka broker to accept connections..." + TIMEOUT=60 + ELAPSED=0 + # Use rpk topic list as the readiness check - it's a simple operation + # that confirms the broker is accepting Kafka protocol requests + until rpk topic list --brokers kafka:9092 2>/dev/null; do + echo "Kafka broker not ready yet (elapsed: $${ELAPSED}s)..." + sleep 2 + ELAPSED=$$((ELAPSED + 2)) + if [ $$ELAPSED -ge $$TIMEOUT ]; then + echo "Timeout waiting for Kafka broker after $${TIMEOUT}s" + echo "Final attempt to list topics:" + rpk topic list --brokers kafka:9092 || true + exit 1 + fi + done + echo "Kafka broker is accepting requests, creating topics..." + # The topic list is docker/kafka/topics.txt, mounted below, shared with + # docker-compose.dev.yml so the two stacks cannot drift apart. That header + # explains what needs pre-creating. + # Note: $$ escapes $ for docker-compose variable substitution + TOPICS=$$(grep -vE '^[[:space:]]*#|^[[:space:]]*$$' /topics.txt) + for topic in $$TOPICS; do + if rpk topic create "$$topic" --brokers kafka:9092 -p 1 -r 1 2>&1; then + echo "Topic $$topic created successfully" + else + if rpk topic list --brokers kafka:9092 | awk '{print $$1}' | grep -qx "$$topic"; then + echo "Topic $$topic already exists, continuing" + else + echo "Failed to create topic $$topic" + exit 1 + fi + fi + done + echo "Final topic list:" + rpk topic list --brokers kafka:9092 + echo "Topics ready" + volumes: + - ./posthog/docker/kafka/topics.txt:/topics.txt:ro + depends_on: + kafka: + condition: service_healthy + + cymbal: + image: ghcr.io/posthog/posthog/cymbal:master + build: + context: ./posthog/rust + args: + BIN: cymbal + restart: on-failure + logging: *default-logging + volumes: + - ./share:/share + environment: + KAFKA_HOSTS: 'kafka:9092' + OBJECT_STORAGE_BUCKET: posthog + OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID} + OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY} + OBJECT_STORAGE_ENDPOINT: 'http://seaweedfs:8333' + OBJECT_STORAGE_FORCE_PATH_STYLE: 'true' + BIND_HOST: '0.0.0.0' + BIND_PORT: '3302' + DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog' + PERSONS_URL: 'postgres://posthog:posthog@db:5432/posthog' + MAXMIND_DB_PATH: '/share/GeoLite2-City.mmdb' + REDIS_URL: 'redis://redis7:6379/' + ISSUE_BUCKETS_REDIS_URL: 'redis://redis7:6379/' + RUST_LOG: 'info' + # Processing offloads all symbol resolution to cymbal-resolution over gRPC. Both are + # mandatory: startup aborts when either is empty, so omitting them crash-loops cymbal. + CYMBAL_REMOTE_RESOLUTION_HOST: 'cymbal-resolution' + INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} + depends_on: + kafka-init: + condition: service_completed_successfully + seaweedfs: + condition: service_healthy + db: + condition: service_healthy + redis7: + condition: service_healthy + cymbal-resolution: + condition: service_started + + # The cymbal binary in resolution mode: the gRPC symbol-resolution service that cymbal's + # processing mode calls. Needs only Postgres and object storage — no Kafka or Redis — hence + # the narrower env than cymbal above. + cymbal-resolution: + image: ghcr.io/posthog/posthog/cymbal:master + build: + context: ./posthog/rust + args: + BIN: cymbal + restart: on-failure + logging: *default-logging + environment: + CYMBAL_MODE: resolution + GRPC_ADDRESS: '0.0.0.0:50061' + OBJECT_STORAGE_BUCKET: posthog + OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID} + OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY} + OBJECT_STORAGE_ENDPOINT: 'http://seaweedfs:8333' + OBJECT_STORAGE_FORCE_PATH_STYLE: 'true' + DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog' + INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} + RUST_LOG: 'info' + depends_on: + seaweedfs: + condition: service_healthy + db: + condition: service_healthy + +networks: + otel_network: + nginx-bridge: + external: true + +volumes: + zookeeper-data: + zookeeper-datalog: + zookeeper-logs: + objectstorage-data: + seaweedfs: + postgres-data: + clickhouse-data: + caddy-data: + caddy-config: + redis7-data: + kafka-data: + redpanda-data: diff --git a/server/home/PostHog/compose/local-patches.py b/server/home/PostHog/compose/local-patches.py new file mode 100644 index 0000000..5689273 --- /dev/null +++ b/server/home/PostHog/compose/local-patches.py @@ -0,0 +1,72 @@ +"""Local source patches, applied to /code at container start. + +Called from compose/start, compose/worker and compose/temporal-django-worker +before the app boots, so every recreated container comes back patched. (The old +approach patched running containers by hand and silently vanished on the next +`docker compose up`.) + +Both patches exist because PostHog no longer sells self-hosted licenses, so the +license branches below are unreachable here. Both are gated on +MULTI_ORG_ENABLED (set in compose.override.yml), so unsetting it restores +upstream behaviour without un-patching. Only posthog/ files are touched, which +is MIT-licensed; ee/ is not involved. + +Idempotent. If upstream changes shape, this warns loudly and lets the app start +unpatched rather than taking PostHog down. Check with: + docker compose logs web worker | grep LOCAL-PATCH +""" + +import sys + +PATCHES = [ + ( + # Shows the "New project" / "New organization" buttons. Upstream gates + # them on preflight's can_create_org, which demands a license. + "can_create_org", + "/code/posthog/utils.py", + """ if settings.MULTI_ORG_ENABLED: + license = get_cached_instance_license() + if license is not None and AvailableFeature.ZAPIER in license.available_features: + return True + else: + logger.warning("You have configured MULTI_ORG_ENABLED, but not the required premium PostHog plan!") +""", + """ if settings.MULTI_ORG_ENABLED: + # Local patch, see compose/local-patches.py + return True +""", + ), + ( + # Lifts the 1-project-per-org limit in the project API. Without a + # license, every org save and the periodic feature sync reset the org's + # features to [], and an org without `organizations_projects` may only + # have one project. With it and no `limit`, the cap is upstream's + # MAX_ALLOWED_PROJECTS_PER_ORG (2000). + "organizations_projects", + "/code/posthog/models/organization.py", + """ return self.available_product_features + + def get_available_feature(""", + """ # Local patch, see compose/local-patches.py + if settings.MULTI_ORG_ENABLED and not self.get_available_feature("organizations_projects"): + self.available_product_features = [ + *self.available_product_features, + {"key": "organizations_projects", "name": "Organizations projects"}, + ] + + return self.available_product_features + + def get_available_feature(""", + ), +] + +for name, path, old, new in PATCHES: + src = open(path).read() + if new in src: + status = "already applied" + elif src.count(old) == 1: + open(path, "w").write(src.replace(old, new)) + status = "applied" + else: + status = "PATTERN NOT FOUND, upstream changed, running UNPATCHED" + print(f"LOCAL-PATCH {name}: {status}", file=sys.stderr, flush=True) diff --git a/server/home/PostHog/compose/start b/server/home/PostHog/compose/start new file mode 100755 index 0000000..f7ea40c --- /dev/null +++ b/server/home/PostHog/compose/start @@ -0,0 +1,5 @@ +#!/bin/bash +python /compose/local-patches.py +/compose/wait +./bin/migrate +./bin/docker-server diff --git a/server/home/PostHog/compose/temporal-django-worker b/server/home/PostHog/compose/temporal-django-worker new file mode 100755 index 0000000..ae36226 --- /dev/null +++ b/server/home/PostHog/compose/temporal-django-worker @@ -0,0 +1,3 @@ +#!/bin/bash +python /compose/local-patches.py +./bin/temporal-django-worker diff --git a/server/home/PostHog/compose/wait b/server/home/PostHog/compose/wait new file mode 100755 index 0000000..b56b60d --- /dev/null +++ b/server/home/PostHog/compose/wait @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 + +import socket +import time + +def loop(): + print("Waiting for ClickHouse and Postgres to be ready") + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect(('clickhouse', 9000)) + print("Clickhouse is ready") + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect(('db', 5432)) + print("Postgres is ready") + except ConnectionRefusedError as e: + time.sleep(5) + loop() + +loop() diff --git a/server/home/PostHog/compose/worker b/server/home/PostHog/compose/worker new file mode 100755 index 0000000..49f7c86 --- /dev/null +++ b/server/home/PostHog/compose/worker @@ -0,0 +1,3 @@ +#!/bin/bash +python /compose/local-patches.py +exec ./bin/docker-worker-celery --with-scheduler diff --git a/server/home/PostHog/podman-posthog.service b/server/home/PostHog/podman-posthog.service new file mode 100644 index 0000000..9e64844 --- /dev/null +++ b/server/home/PostHog/podman-posthog.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: PostHog +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/PostHog +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Romm/.env.example b/server/home/Romm/.env.example new file mode 100644 index 0000000..d803c30 --- /dev/null +++ b/server/home/Romm/.env.example @@ -0,0 +1,10 @@ +DB_PASSWD=CHANGE_ME +ROMM_AUTH_SECRET_KEY=CHANGE_ME +IGDB_CLIENT_ID=CHANGE_ME +IGDB_CLIENT_SECRET=CHANGE_ME +RETROACHIEVEMENTS_API_KEY=CHANGE_ME +STEAMGRIDDB_API_KEY=CHANGE_ME +OIDC_CLIENT_ID=CHANGE_ME +OIDC_CLIENT_SECRET=CHANGE_ME +MARIADB_ROOT_PASSWORD=CHANGE_ME +MARIADB_PASSWORD=CHANGE_ME diff --git a/server/home/Romm/compose.yml b/server/home/Romm/compose.yml new file mode 100644 index 0000000..7b3c709 --- /dev/null +++ b/server/home/Romm/compose.yml @@ -0,0 +1,66 @@ +networks: + nginx-bridge: + external: true +services: + romm: + image: rommapp/romm:latest + container_name: romm + hostname: romm + domainname: romm.gbrown.org + networks: [nginx-bridge] + restart: unless-stopped + stdin_open: true + tty: true + labels: [com.centurylinklabs.watchtower.enable=true] + environment: + - TZ=America/New_York + - DB_HOST=romm-db + - DB_NAME=romm + - DB_USER=gib + - DB_PASSWD=${DB_PASSWD} + - ROMM_AUTH_SECRET_KEY=${ROMM_AUTH_SECRET_KEY} + - IGDB_CLIENT_ID=${IGDB_CLIENT_ID} + - IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET} + - RETROACHIEVEMENTS_API_KEY=${RETROACHIEVEMENTS_API_KEY} + - STEAMGRIDDB_API_KEY=${STEAMGRIDDB_API_KEY} + - HASHEOUS_API_ENABLED=true + - PLAYMATCH_API_ENABLED=true + - OIDC_ENABLED=true + - OIDC_PROVIDER=Gibs Auth + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID} + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET} + - OIDC_REDIRECT_URI=https://romm.gbrown.org/api/oauth/openid + - OIDC_SERVER_APPLICATION_URL=https://auth.gbrown.org/application/o/romm + #- ENABLE_RESCAN_ON_FILESYSTEM_CHANGE=true + volumes: + - ./volumes/romm/resources:/romm/resources # Resources fetched from IGDB (covers, screenshots, etc.) + - ./volumes/romm/redis_data:/redis-data # Cached data for background tasks + - /home/gib/Public/Server_Backup/ROMs:/romm/library # Your game library + - ./volumes/romm/assests:/romm/assets # Uploaded saves, states, etc. + - ./volumes/romm/config:/romm/config # Path where config.yml is stored + #ports: [8080:8080] + depends_on: + romm-db: + condition: service_healthy + restart: true + + romm-db: + image: mariadb:latest + container_name: romm-db + hostname: romm-db + networks: [nginx-bridge] + restart: unless-stopped + labels: [com.centurylinklabs.watchtower.enable=true] + environment: + - MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD} + - MARIADB_DATABASE=romm + - MARIADB_USER=gib + - MARIADB_PASSWORD=${MARIADB_PASSWORD} + volumes: [./volumes/mysql:/var/lib/mysql] + healthcheck: + test: [CMD, healthcheck.sh, --connect, --innodb_initialized] + start_period: 30s + start_interval: 10s + interval: 10s + timeout: 5s + retries: 5 diff --git a/server/home/Romm/podman-romm.service b/server/home/Romm/podman-romm.service new file mode 100644 index 0000000..d3004b1 --- /dev/null +++ b/server/home/Romm/podman-romm.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Romm +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Romm +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Scrutiny/compose.yml b/server/home/Scrutiny/compose.yml new file mode 100644 index 0000000..d0b62ed --- /dev/null +++ b/server/home/Scrutiny/compose.yml @@ -0,0 +1,59 @@ +# Scrutiny — SMART disk health monitoring with historical trending. +# +# Omnibus image bundles the web UI, InfluxDB and the metrics collector in one +# container, which suits a single host. +# +# Needs raw device access: SYS_RAWIO for SMART ioctls on the SATA disks, and +# SYS_ADMIN additionally for NVMe. /run/udev is mounted read-only so scrutiny +# can resolve device metadata (model, serial, WWN). +# +# Devices listed explicitly rather than using privileged mode: +# sda-sdd 4x WDC WD40EFPX (4TB) +# sde-sdg 3x TOSHIBA MG08ACA14TE (14TB) +# sdh 1x WUH721414ALE601 (14TB) +# nvme0n1 WDS100T3X0C (1TB) +# nvme1n1 Samsung 990 PRO 2TB (root/LUKS) +# sdi (USB Mass Storage) is deliberately omitted. +# +# If a disk is added or replaced, add it to the devices: list and recreate. + +networks: + nginx-bridge: + external: true + +services: + scrutiny: + image: ghcr.io/analogj/scrutiny:master-omnibus + container_name: scrutiny + hostname: scrutiny + domainname: scrutiny.gbrown.org + networks: ['nginx-bridge'] + #ports: + #- "8080:8080" + environment: + - TZ=America/New_York + - SCRUTINY_WEB_INFLUXDB_RETENTION_POLICY=true + cap_add: + - SYS_RAWIO + - SYS_ADMIN + devices: + - /dev/sda:/dev/sda + - /dev/sdb:/dev/sdb + - /dev/sdc:/dev/sdc + - /dev/sdd:/dev/sdd + - /dev/sde:/dev/sde + - /dev/sdf:/dev/sdf + - /dev/sdg:/dev/sdg + - /dev/sdh:/dev/sdh + - /dev/nvme0n1:/dev/nvme0n1 + - /dev/nvme1n1:/dev/nvme1n1 + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/config:/opt/scrutiny/config + - ./volumes/influxdb:/opt/scrutiny/influxdb + - /run/udev:/run/udev:ro + - /etc/localtime:/etc/localtime:ro + tty: true + stdin_open: true + restart: unless-stopped diff --git a/server/home/Scrutiny/podman-scrutiny.service b/server/home/Scrutiny/podman-scrutiny.service new file mode 100644 index 0000000..34781af --- /dev/null +++ b/server/home/Scrutiny/podman-scrutiny.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Scrutiny +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Scrutiny +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Sentry/.env.example b/server/home/Sentry/.env.example new file mode 100644 index 0000000..cca3534 --- /dev/null +++ b/server/home/Sentry/.env.example @@ -0,0 +1,24 @@ +COMPOSE_PROJECT_NAME=CHANGE_ME +COMPOSE_PROFILES=CHANGE_ME +SENTRY_EVENT_RETENTION_DAYS=CHANGE_ME +LAUNCHPAD_RPC_SHARED_SECRET=CHANGE_ME +SENTRY_BIND=CHANGE_ME +SENTRY_TASKWORKER_CONCURRENCY=CHANGE_ME +SENTRY_IMAGE=CHANGE_ME +SNUBA_IMAGE=CHANGE_ME +RELAY_IMAGE=CHANGE_ME +SYMBOLICATOR_IMAGE=CHANGE_ME +TASKBROKER_IMAGE=CHANGE_ME +VROOM_IMAGE=CHANGE_ME +UPTIME_CHECKER_IMAGE=CHANGE_ME +LAUNCHPAD_IMAGE=CHANGE_ME +HEALTHCHECK_INTERVAL=CHANGE_ME +HEALTHCHECK_TIMEOUT=CHANGE_ME +HEALTHCHECK_RETRIES=CHANGE_ME +HEALTHCHECK_START_PERIOD=CHANGE_ME +HEALTHCHECK_FILE_INTERVAL=CHANGE_ME +HEALTHCHECK_FILE_TIMEOUT=CHANGE_ME +HEALTHCHECK_FILE_RETRIES=CHANGE_ME +HEALTHCHECK_FILE_START_PERIOD=CHANGE_ME +SENTRY_MAIL_HOST=CHANGE_ME +SENTRY_SYSTEM_SECRET_KEY=CHANGE_ME diff --git a/server/home/Sentry/UPSTREAM b/server/home/Sentry/UPSTREAM new file mode 100644 index 0000000..251060d --- /dev/null +++ b/server/home/Sentry/UPSTREAM @@ -0,0 +1,6 @@ +checkout: ~/Server/Sentry/ +repo: https://github.com/getsentry/self-hosted +ref: tag 26.9.0 +commit: 667094ad0b11bb27e6380fc757754c66516b59b4 + +docker-compose.yml and everything else tracked upstream is unchanged; sentry/config.yml and sentry/sentry.conf.py are the local config install.sh seeds from the examples. diff --git a/server/home/Sentry/podman-sentry.service b/server/home/Sentry/podman-sentry.service new file mode 100644 index 0000000..3555bfc --- /dev/null +++ b/server/home/Sentry/podman-sentry.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Sentry +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Sentry +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Sentry/sentry/config.yml b/server/home/Sentry/sentry/config.yml new file mode 100644 index 0000000..aed87c9 --- /dev/null +++ b/server/home/Sentry/sentry/config.yml @@ -0,0 +1,150 @@ +# While a lot of configuration in Sentry can be changed via the UI, for all +# new-style config (as of 8.0) you can also declare values here in this file +# to enforce defaults or to ensure they cannot be changed via the UI. For more +# information see the Sentry documentation. + +############### +# Mail Server # +############### + +# mail.backend: 'smtp' # Use dummy if you want to disable email entirely +mail.host: 'smtp.usesend.gbrown.org' +mail.port: 587 +mail.username: 'usesend' +mail.password: CHANGE_ME +# NOTE: `mail.use-tls` and `mail.use-ssl` are mutually exclusive and should not +# appear at the same time. Only uncomment one of them. +mail.use-tls: true +# mail.use-ssl: false + +# NOTE: The following 2 configs (mail.from and mail.list-namespace) are set +# through SENTRY_MAIL_HOST in sentry.conf.py so remove those first if +# you want your values in this file to be effective! + +# The email address to send on behalf of +mail.from: 'Sentry Admin ' +# mail.from: 'System Administrator ' + +# The mailing list namespace for emails sent by this Sentry server. +# This should be a domain you own (often the same domain as the domain +# part of the `mail.from` configuration parameter value) or `localhost`. +mail.list-namespace: 'sentry.gbrown.org' + +# If you'd like to configure email replies, enable this. +# mail.enable-replies: true + +# When email-replies are enabled, this value is used in the Reply-To header +# mail.reply-hostname: '' + +# If you're using mailgun for inbound mail, set your API key and configure a +# route to forward to /api/hooks/mailgun/inbound/ +# Also don't forget to set `mail.enable-replies: true` above. +# mail.mailgun-api-key: '' + +################### +# System Settings # +################### + +# This is the main URL prefix where Sentry can be accessed. +# Sentry will use this to create links to its different parts of the web UI. +# This is most helpful if you are using an external reverse proxy. +system.url-prefix: https://sentry.gbrown.org + +# Most of the time, this should NOT be changed. It's used for communication +# between containers. `web` is the container's name, and `9000` is the +# default port opened by the Sentry backend (this is NOT the public port). +# +# If you want to change the publicly exposed domain or port, you should change +# `system.url-prefix` above instead, along with `SENTRY_BIND` in `.env` file. +# Also see https://develop.sentry.dev/self-hosted/#productionalizing. +system.internal-url-prefix: 'http://web:9000' + +# If this file ever becomes compromised, it's important to generate a new key. +# Changing this value will result in all current sessions being invalidated. +# A new key can be generated with `$ sentry config generate-secret-key` +# +# If you are using SENTRY_SYSTEM_SECRET_KEY that is being set on your `.env` or `.env.custom` file, +# you should remove this line below as it won't be used anyway. + +# The ``redis.clusters`` setting is used, unsurprisingly, to configure Redis +# clusters. These clusters can be then referred to by name when configuring +# backends such as the cache, digests, or TSDB backend. +# redis.clusters: +# default: +# hosts: +# 0: +# host: 127.0.0.1 +# port: 6379 + +################ +# File storage # +################ + +# Uploaded media uses these `filestore` settings. The available +# backends are either `filesystem` or `s3`. + +filestore.backend: 'filesystem' +filestore.options: + location: '/data/files' +dsym.cache-path: '/data/dsym-cache' +releasefile.cache-path: '/data/releasefile-cache' + +# filestore.backend: 's3' +# filestore.options: +# bucket_name: 's3-bucket-name' + +filestore.profiles-backend: 's3' +filestore.profiles-options: + bucket_acl: "private" + default_acl: "private" + access_key: CHANGE_ME + secret_key: CHANGE_ME + bucket_name: "profiles" + region_name: "us-east-1" + endpoint_url: "http://seaweedfs:8333" + addressing_style: "path" + signature_version: "s3v4" + +symbolicator.enabled: true +symbolicator.options: + url: "http://symbolicator:3021" + +transaction-events.force-disable-internal-project: true + +###################### +# GitHub Integration # +###################### + +# Refer to https://develop.sentry.dev/integrations/github/ for setup instructions. + +# github-login.extended-permissions: ['repo'] +# github-app.id: GITHUB_APP_ID +# github-app.name: 'GITHUB_APP_NAME' +# github-app.client-id: 'GITHUB_CLIENT_ID' + +##################### +# Slack Integration # +##################### + +# Refer to https://develop.sentry.dev/integrations/slack/ for setup instructions. + +# slack.client-id: <'client id'> +## If legacy-app is True use verification-token instead of signing-secret + + +####################### +# Discord Integration # +####################### + +# Refer to https://develop.sentry.dev/integrations/discord/ + +# discord.application-id: "" +# discord.public-key: "" + +############### +# Google Auth # +############### + +# Refer to https://develop.sentry.dev/self-hosted/sso/#google-auth + +# auth-google.client-id: "" diff --git a/server/home/Sentry/sentry/sentry.conf.py b/server/home/Sentry/sentry/sentry.conf.py new file mode 100644 index 0000000..34f3769 --- /dev/null +++ b/server/home/Sentry/sentry/sentry.conf.py @@ -0,0 +1,572 @@ +# This file is just Python, with a touch of Django which means +# you can inherit and tweak settings to your hearts content. + +from sentry.conf.server import * # NOQA + +BYTE_MULTIPLIER = 1024 +UNITS = ("K", "M", "G") + + +def unit_text_to_bytes(text): + unit = text[-1].upper() + power = UNITS.index(unit) + 1 + return float(text[:-1]) * (BYTE_MULTIPLIER**power) + + +# Generously adapted from pynetlinux: https://github.com/rlisagor/pynetlinux/blob/e3f16978855c6649685f0c43d4c3fcf768427ae5/pynetlinux/ifconfig.py#L197-L223 +def get_internal_network(): + import ctypes + import fcntl + import math + import socket + import struct + + iface = b"eth0" + sockfd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + ifreq = struct.pack(b"16sH14s", iface, socket.AF_INET, b"\x00" * 14) + + try: + ip = struct.unpack( + b"!I", struct.unpack(b"16sH2x4s8x", fcntl.ioctl(sockfd, 0x8915, ifreq))[2] + )[0] + netmask = socket.ntohl( + struct.unpack(b"16sH2xI8x", fcntl.ioctl(sockfd, 0x891B, ifreq))[2] + ) + except IOError: + return () + base = socket.inet_ntoa(struct.pack(b"!I", ip & netmask)) + netmask_bits = 32 - int(round(math.log(ctypes.c_uint32(~netmask).value + 1, 2), 1)) + return "{0:s}/{1:d}".format(base, netmask_bits) + + +INTERNAL_SYSTEM_IPS = (get_internal_network(),) + + +DATABASES = { + "default": { + "ENGINE": "sentry.db.postgres", + "NAME": "postgres", + "USER": "postgres", + "PASSWORD": "", + "HOST": "pgbouncer", + "PORT": "", + } +} + +# If you're expecting any kind of real traffic on Sentry, we highly recommend +# configuring the CACHES and Redis settings + +########### +# General # +########### + +# Instruct Sentry that this install intends to be run by a single organization +# and thus various UI optimizations should be enabled. +SENTRY_SINGLE_ORGANIZATION = True + +# Sentry event retention days specifies how long events are retained in the database. +# This should be set on your `.env` or `.env.custom` file, instead of modifying +# the value here. +# NOTE: The longer the days, the more disk space is required. +SENTRY_OPTIONS["system.event-retention-days"] = int( + env("SENTRY_EVENT_RETENTION_DAYS", "90") +) + +# The secret key is being used for various cryptographic operations, such as +# generating a CSRF token, session token, and registering Relay instances. +# The secret key value should be set on your `.env` or `.env.custom` file +# instead of modifying the value here. +# +# If the key ever becomes compromised, it's important to generate a new key. +# Changing this value will result in all current sessions being invalidated. +# A new key can be generated with `$ sentry config generate-secret-key` +if env("SENTRY_SYSTEM_SECRET_KEY"): + SENTRY_OPTIONS["system.secret-key"] = env("SENTRY_SYSTEM_SECRET_KEY", "") + +# Self-hosted Sentry infamously has a lot of Docker containers required to make +# all the features work. Oftentimes, users don't use the full feature set that +# requires all the containers. This is a way to enable only the error monitoring +# feature which also reduces the amount of containers required to run Sentry. +# +# To make Sentry work with all features, set `COMPOSE_PROFILES` to `feature-complete` +# in your `.env` file. To enable only the error monitoring feature, set +# `COMPOSE_PROFILES` to `errors-only`. +# +# See https://develop.sentry.dev/self-hosted/optional-features/errors-only/ +SENTRY_SELF_HOSTED_ERRORS_ONLY = env("COMPOSE_PROFILES") != "feature-complete" + +# When running in an air-gapped environment, set this to True to entirely disable +# external network calls and features that require Internet connectivity. +# +# Setting the value to False while running in an air-gapped environment will +# cause some containers to raise exceptions. One known example is fetching +# AI model prices from various public APIs. +SENTRY_AIR_GAP = False + +# As of 25.9.0 (September 2025 release), Sentry enforces tighter restrictions +# of allowed IP addresses for outgoing requests. This is to prevent +# accidentally leaking sensitive information to third parties. +# +# By default, Sentry will not allow requests to private IP addresses. +# You can override this by configuring the allowed IP addresses here. +# Below is the default value, which is a list of IP addresses that are +# considered "private" and "reserved". +# +# SENTRY_DISALLOWED_IPS: tuple[str, ...] = ( +# # https://en.wikipedia.org/wiki/Reserved_IP_addresses#IPv4 +# "0.0.0.0/8", +# "10.0.0.0/8", +# "100.64.0.0/10", +# "127.0.0.0/8", +# "169.254.0.0/16", +# "172.16.0.0/12", +# "192.0.0.0/29", +# "192.0.2.0/24", +# "192.88.99.0/24", +# "192.168.0.0/16", +# "198.18.0.0/15", +# "198.51.100.0/24", +# "224.0.0.0/4", +# "240.0.0.0/4", +# "255.255.255.255/32", +# # https://en.wikipedia.org/wiki/IPv6#IPv4-mapped_IPv6_addresses +# # Subnets match the IPv4 subnets above +# "::ffff:0:0/104", +# "::ffff:a00:0/104", +# "::ffff:6440:0/106", +# "::ffff:7f00:0/104", +# "::ffff:a9fe:0/112", +# "::ffff:ac10:0/108", +# "::ffff:c000:0/125", +# "::ffff:c000:200/120", +# "::ffff:c058:6300/120", +# "::ffff:c0a8:0/112", +# "::ffff:c612:0/111", +# "::ffff:c633:6400/120", +# "::ffff:e000:0/100", +# "::ffff:f000:0/100", +# "::ffff:ffff:ffff/128", +# # https://en.wikipedia.org/wiki/Reserved_IP_addresses#IPv6 +# "::1/128", +# "::ffff:0:0/96", +# "64:ff9b::/96", +# "64:ff9b:1::/48", +# "100::/64", +# "2001:0000::/32", +# "2001:20::/28", +# "2001:db8::/32", +# "2002::/16", +# "fc00::/7", +# "fe80::/10", +# "ff00::/8", +# ) + +################ +# Node Storage # +################ + +# Sentry uses an abstraction layer called "node storage" to store raw events. +# Previously, it used PostgreSQL as the backend, but this didn't scale for +# high-throughput environments. Read more about this in the documentation: +# https://develop.sentry.dev/backend/application-domains/nodestore/ +# +# Through this setting, you can use the provided blob storage or +# your own S3-compatible API from your infrastructure. +# Other backend implementations for node storage developed by the community +# are available in public GitHub repositories. + +SENTRY_NODESTORE = "sentry_nodestore_s3.S3PassthroughDjangoNodeStorage" +SENTRY_NODESTORE_OPTIONS = { + "compression": True, + "endpoint_url": "http://seaweedfs:8333", + "bucket_path": "nodestore", + "bucket_name": "nodestore", + "region_name": "us-east-1", + "aws_access_key_id": "sentry", + "aws_secret_access_key": "sentry", +} + +######### +# Redis # +######### + +# Generic Redis configuration used as defaults for various things including: +# Buffers, Quotas, TSDB + +SENTRY_OPTIONS["redis.clusters"] = { + "default": { + "hosts": {0: {"host": "redis", "password": "", "port": "6379", "db": "0"}} + } +} + +######### +# Cache # +######### + +# Sentry currently utilizes two separate mechanisms. While CACHES is not a +# requirement, it will optimize several high throughput patterns. + +CACHES = { + "default": { + "BACKEND": "sentry.cache.backends.reconnectingmemcache.ReconnectingMemcache", + "LOCATION": ["memcached:11211"], + "TIMEOUT": 3600, + "OPTIONS": {"ignore_exc": True, "reconnect_age": 300}, + } +} + +# A primary cache is required for things such as processing events +SENTRY_CACHE = "sentry.cache.redis.RedisCache" + +DEFAULT_KAFKA_OPTIONS = { + "bootstrap.servers": "kafka:9092", + "message.max.bytes": 50000000, + "socket.timeout.ms": 1000, +} + +SENTRY_EVENTSTREAM = "sentry.eventstream.kafka.KafkaEventStream" +SENTRY_EVENTSTREAM_OPTIONS = {"producer_configuration": DEFAULT_KAFKA_OPTIONS} + +KAFKA_CLUSTERS["default"] = DEFAULT_KAFKA_OPTIONS + +############### +# Rate Limits # +############### + +# Rate limits apply to notification handlers and are enforced per-project +# automatically. + +SENTRY_RATELIMITER = "sentry.ratelimits.redis.RedisRateLimiter" + +################## +# Update Buffers # +################## + +# Buffers (combined with queueing) act as an intermediate layer between the +# database and the storage API. They will greatly improve efficiency on large +# numbers of the same events being sent to the API in a short amount of time. +# (read: if you send any kind of real data to Sentry, you should enable buffers) + +SENTRY_BUFFER = "sentry.buffer.redis.RedisBuffer" + +########## +# Quotas # +########## + +# Quotas allow you to rate limit individual projects or the Sentry install as +# a whole. + +SENTRY_QUOTAS = "sentry.quotas.redis.RedisQuota" + +######## +# TSDB # +######## + +# The TSDB is used for building charts as well as making things like per-rate +# alerts possible. + +SENTRY_TSDB = "sentry.tsdb.redissnuba.RedisSnubaTSDB" + +######### +# SNUBA # +######### + +SENTRY_SEARCH = "sentry.search.snuba.EventsDatasetSnubaSearchBackend" +SENTRY_SEARCH_OPTIONS = {} +SENTRY_TAGSTORE_OPTIONS = {} + +########### +# Digests # +########### + +# The digest backend powers notification summaries. + +SENTRY_DIGESTS = "sentry.digests.backends.redis.RedisBackend" + +############## +# Web Server # +############## + +SENTRY_WEB_HOST = "0.0.0.0" +SENTRY_WEB_PORT = 9000 +SENTRY_WEB_OPTIONS = { + "http": "%s:%s" % (SENTRY_WEB_HOST, SENTRY_WEB_PORT), + "protocol": "uwsgi", + # This is needed in order to prevent https://github.com/getsentry/sentry/blob/c6f9660e37fcd9c1bbda8ff4af1dcfd0442f5155/src/sentry/services/http.py#L70 + "uwsgi-socket": None, + "so-keepalive": True, + # Keep this between 15s-75s as that's what Relay supports + "http-keepalive": 15, + "http-chunked-input": True, + # the number of web workers + "workers": 3, + "threads": 4, + "memory-report": False, + # The `harakiri` option terminates requests that take longer than the + # defined amount of time (in seconds) which can help avoid stuck workers + # caused by GIL issues or deadlocks. + # Ensure nginx `proxy_read_timeout` configuration (default: 90) + # on your `nginx.conf` file to be at least 5 seconds longer than this. + # "harakiri": 85, + # Some stuff so uwsgi will cycle workers sensibly + "max-requests": 100000, + "max-requests-delta": 500, + "max-worker-lifetime": 86400, + # Duplicate options from sentry default just so we don't get + # bit by sentry changing a default value that we depend on. + "thunder-lock": True, + "log-x-forwarded-for": False, + "buffer-size": 32768, + "limit-post": 209715200, + "disable-logging": True, + "reload-on-rss": 600, + "ignore-sigpipe": True, + "ignore-write-errors": True, + "disable-write-exception": True, +} + +########### +# SSL/TLS # +########### + +# If you're using a reverse SSL proxy, you should enable the X-Forwarded-Proto +# header and enable the settings below + +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +USE_X_FORWARDED_HOST = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SOCIAL_AUTH_REDIRECT_IS_HTTPS = True + +# End of SSL/TLS settings + +######## +# Mail # +######## + +SENTRY_OPTIONS["mail.list-namespace"] = env("SENTRY_MAIL_HOST", "localhost") +SENTRY_OPTIONS["mail.from"] = f"sentry@{SENTRY_OPTIONS['mail.list-namespace']}" + +############ +# Features # +############ + +# Sentry uses feature flags to enable certain features. Some features may +# require additional configuration or containers. To learn more about how +# Sentry uses feature flags, see https://develop.sentry.dev/backend/application-domains/feature-flags/ +# +# The features listed here are stable and generally available on SaaS. +# To enable preview features, see https://develop.sentry.dev/self-hosted/configuration/#enabling-preview-features + +SENTRY_FEATURES["projects:sample-events"] = False +SENTRY_FEATURES.update( + { + feature: True + for feature in ( + "organizations:discover", + "organizations:global-views", + "organizations:issue-views", + "organizations:integrations-issue-basic", + "organizations:integrations-issue-sync", + "organizations:invite-members", + "organizations:sso-basic", + "organizations:sso-saml2", + "organizations:advanced-search", + "organizations:issue-platform", + "organizations:monitors", + "organizations:dashboards-mep", + "organizations:mep-rollout-flag", + "organizations:dashboards-rh-widget", + "organizations:dynamic-sampling", + "organizations:workflow-engine-ui", + "organizations:workflow-engine-rule-serializers", + "projects:custom-inbound-filters", + "projects:data-forwarding", + "projects:discard-groups", + "projects:plugins", + "projects:rate-limits", + "projects:servicehooks", + ) + # Performance/Tracing/Spans related flags + + ( + "organizations:performance-view", + "organizations:span-stats", + "organizations:visibility-explore-view", + "organizations:visibility-explore-range-high", + "organizations:transaction-metrics-extraction", + "organizations:indexed-spans-extraction", + "organizations:insights-entry-points", + "organizations:insights-initial-modules", + "organizations:insights-addon-modules", + "organizations:insights-modules-use-eap", + "organizations:starfish-mobile-appstart", + "projects:span-metrics-extraction", + "projects:span-metrics-extraction-addons", + ) + # Session Replay related flags + + ( + "organizations:session-replay", + ) + # User Feedback related flags + + ( + "organizations:user-feedback-ui", + ) + # Profiling related flags + + ( + "organizations:profiling", + "organizations:profiling-view", + ) + # Continuous Profiling related flags + + ( + "organizations:continuous-profiling", + "organizations:continuous-profiling-stats", + ) + # Uptime Monitoring related flags + + ( + "organizations:uptime", + "organizations:uptime-create-issues", + ) + # Logs related flags + + ( + "organizations:ourlogs-enabled", + "organizations:ourlogs-ingestion", + "organizations:ourlogs-stats", + "organizations:ourlogs-replay-ui", + ) + # Metrics related flags + + ( + "organizations:tracemetrics-enabled", + "organizations:tracemetrics-alerts", + "organizations:tracemetrics-ingestion", + "organizations:tracemetrics-equations-in-alerts", + "organizations:tracemetrics-equations-in-explore", + "organizations:tracemetrics-multi-metric-selection-in-dashboards", + "organizations:tracemetrics-units-ui", + "organizations:tracemetrics-stats-bytes-ui", + "organizations:tracemetrics-pii-scrubbing-ui", + ) + # Transaction widgets/alerts/saved queries migration UI flags + + ( + "organizations:discover-saved-queries-deprecation", + "organizations:expose-migrated-discover-queries", + "organizations:performance-transaction-deprecation-banner", + ) + } +) + +####################### +# MaxMind Integration # +####################### + +GEOIP_PATH_MMDB = "/geoip/GeoLite2-City.mmdb" + +######################### +# Bitbucket Integration # +######################### + +# BITBUCKET_CONSUMER_KEY = 'YOUR_BITBUCKET_CONSUMER_KEY' +# BITBUCKET_CONSUMER_SECRET = 'YOUR_BITBUCKET_CONSUMER_SECRET' + +############################################## +# Content Security Policy settings +############################################## + +# CSP_REPORT_URI = "https://{your-sentry-installation}/api/{csp-project}/security/?sentry_key={sentry-key}" +CSP_REPORT_ONLY = True + +# optional extra permissions +# https://django-csp.readthedocs.io/en/latest/configuration.html +# CSP_SCRIPT_SRC += ["example.com"] + +############################ +# Sentry Endpoint Settings # +############################ + +# If your Sentry installation has different hostnames for ingestion and web UI, +# in which your web UI is accessible via private corporate network, yet your +# ingestion hostname is accessible from the public internet, you can uncomment +# this following options in order to have the ingestion hostname rendered +# correctly on the SDK configuration UI. +# +# SENTRY_ENDPOINT = "https://sentry.ingest.example.com" + +################# +# CSRF Settings # +################# + +# Since version 24.1.0, Sentry migrated to Django 4 which contains stricter CSRF protection. +# If you are accessing Sentry from multiple domains behind a reverse proxy, you should set +# this to match your IPs/domains. Ports should be included if you are using custom ports. +# https://docs.djangoproject.com/en/4.2/ref/settings/#std-setting-CSRF_TRUSTED_ORIGINS + +CSRF_TRUSTED_ORIGINS = ["https://sentry.gbrown.org", "http://192.168.0.43:9000", "http://127.0.0.1:9000", "http://localhost:9000", 'https://sentry.gibbyb.com'] + +################# +# JS SDK Loader # +################# + +# Configure Sentry JS SDK bundle URL template for Loader Scripts. +# Learn more about the Loader Scripts: https://docs.sentry.io/platforms/javascript/install/loader/ +# If you wish to host your own JS SDK bundles, set `SETUP_JS_SDK_ASSETS` environment variable to `1` +# on your `.env` or `.env.custom` file. Then, replace the value below with your own public URL. +# For example: "https://sentry.example.com/js-sdk/%s/bundle%s.min.js" +# +# By default, the previous JS SDK assets version will be pruned during upgrades, if you wish +# to keep the old assets, set `SETUP_JS_SDK_KEEP_OLD_ASSETS` environment variable to any value on +# your `.env` or `.env.custom` file. The files should only be a few KBs, and this might be useful +# if you're using it directly like a CDN instead of using the loader script. +JS_SDK_LOADER_DEFAULT_SDK_URL = "https://browser.sentry-cdn.com/%s/bundle%s.min.js" + +######### +# Spans # +######### + +# Segments are processed by a taskworker task rather than the dedicated +# process-segments Kafka consumer, so there is no such consumer in +# docker-compose.yml. This is set explicitly (rather than relying on the default +# in sentry) because self-hosted and sentry are released on different cadences. +SENTRY_OPTIONS["spans.buffer.process-segments-task-rollout-rate"] = 1.0 + +##################### +# Insights Settings # +##################### + +# Since version 24.3.0, Insights features are available on self-hosted. For Requests module, +# there are scrubbing logic done on Relay to prevent high cardinality of stored HTTP hosts. +# However in self-hosted scenario, the amount of stored HTTP hosts might be consistent, +# and you may have allow list of hosts that you want to keep. Uncomment the following line +# to allow specific hosts. It might be IP addresses or domain names (without `http://` or `https://`). + +# SENTRY_OPTIONS["relay.span-normalization.allowed_hosts"] = ["example.com", "192.168.10.1"] + +############## +# Monitoring # +############## + +# By default, Sentry uses dummy statsd monitoring backend that is a no-op. +# If you have a statsd server, you can utilize that to monitor self-hosted +# Sentry for "sentry"-related containers. + +SENTRY_STATSD_ADDR = env("SENTRY_STATSD_ADDR") +if SENTRY_STATSD_ADDR: + host, _, port = SENTRY_STATSD_ADDR.partition(":") + port = int(port or 8125) + SENTRY_METRICS_BACKEND = 'sentry.metrics.statsd.StatsdMetricsBackend' + SENTRY_METRICS_OPTIONS: dict[str, Any] = { + 'host': host, + 'port': port, + } +# SENTRY_METRICS_SAMPLE_RATE = 1.0 # Adjust this to your needs, default is 1.0 +# SENTRY_METRICS_PREFIX = "sentry." # Adjust this to your needs, default is "sentry." + +################################# +# Self-monitoring (internal DSN) # +################################# + +# Sentry reports its own errors to the "internal" project (id 1). By default it +# looks the key up in Postgres at startup, which fails in taskworker and cleanup +# child processes ("Sentry SDK not initialized: no DSN available"), so those ran +# without self-monitoring. Pinning the DSN skips the lookup. It goes through the +# stack's own nginx rather than the public hostname. The key is the internal +# project's public key, which is not a secret. Update it if that key is rotated. +SENTRY_SDK_CONFIG["sentry_mirror_dsn"] = "http://d6f5825aae7007c9ae5d3f9174cb54b5@nginx/1" diff --git a/server/home/Slides/infisical/compose.yml b/server/home/Slides/infisical/compose.yml new file mode 100644 index 0000000..270ad05 --- /dev/null +++ b/server/home/Slides/infisical/compose.yml @@ -0,0 +1,23 @@ +services: + infisical-slides: + image: tangramor/slidev:latest + container_name: infisical-slides + hostname: infisical-slides + domainname: infisical-presentation.gbrown.org + user: node + networks: + - nginx-bridge + # ports: + #- "3030:3030" + # environment: + # - NPM_MIRROR="https://registry.npmirror.com + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./data:/slidev + tty: true + restart: unless-stopped + +networks: + nginx-bridge: + external: true diff --git a/server/home/Slides/infisical/podman-slides-infisical.service b/server/home/Slides/infisical/podman-slides-infisical.service new file mode 100644 index 0000000..ebd2505 --- /dev/null +++ b/server/home/Slides/infisical/podman-slides-infisical.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Slides/infisical +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Slides/infisical +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Watchtower/compose.yml b/server/home/Watchtower/compose.yml new file mode 100644 index 0000000..d2deab4 --- /dev/null +++ b/server/home/Watchtower/compose.yml @@ -0,0 +1,22 @@ +services: + watchtower: + image: nickfedor/watchtower:latest + container_name: watchtower + hostname: watchtower.gib + networks: + - nginx-bridge + labels: + com.centurylinklabs.watchtower.enable: true + environment: + - WATCHTOWER_LABEL_ENABLE=true + - TZ=America/New_York + - WATCHTOWER_DISABLE_CONTAINERS=nextcloud-aio-borgbackup,nextcloud-aio-watchtower,nextcloud-aio-collabora,nextcloud-aio-talk,nextcloud-aio-database,nextcloud-aio-redis,nextcloud-aio-clamav,nextcloud-aio-fulltextsearch,nextcloud-aio-talk-recording,nextcloud-aio-imaginary,nextcloud-aio-docker-socket-proxy,nextcloud-aio-nextcloud,nextcloud-aio-notify-push,nextcloud-aio-apache,nextcloud-aio-mastercontainer + - WATCHTOWER_SCHEDULE=0 0 4 * * * + volumes: + - /var/run/docker.sock:/var/run/docker.sock + tty: true + restart: unless-stopped + +networks: + nginx-bridge: + external: true diff --git a/server/home/Watchtower/podman-watchtower.service b/server/home/Watchtower/podman-watchtower.service new file mode 100644 index 0000000..9f41c09 --- /dev/null +++ b/server/home/Watchtower/podman-watchtower.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Watchtower +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Watchtower +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/Whisper/compose.yml b/server/home/Whisper/compose.yml new file mode 100644 index 0000000..fcb57f9 --- /dev/null +++ b/server/home/Whisper/compose.yml @@ -0,0 +1,70 @@ +services: + whisper: + image: lscr.io/linuxserver/faster-whisper:gpu + container_name: whisper + hostname: whisper + domainname: whisper.gbrown.org + networks: + - nginx-bridge + ports: + - "10300:10300" + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/New_York + - WHISPER_MODEL=distil-small.en + - WHISPER_BEAM=2 + - WHISPER_LANG=en + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/whisper:/config + devices: + - /dev/dri:/dev/dri + restart: unless-stopped + tty: true + security_opt: ['seccomp:unconfined','label:disable'] + runtime: nvidia + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + + piper: + image: lscr.io/linuxserver/piper:gpu + container_name: piper + hostname: piper + domainname: piper.gbrown.org + networks: + - nginx-bridge + ports: + - "10200:10200" + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/New_York + - PIPER_VOICE=en_US-libritts-high + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volumes/piper:/config + devices: + - /dev/dri:/dev/dri + restart: unless-stopped + tty: true + security_opt: ['seccomp:unconfined','label:disable'] + runtime: nvidia + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + +networks: + nginx-bridge: + external: true diff --git a/server/home/Whisper/podman-whisper.service b/server/home/Whisper/podman-whisper.service new file mode 100644 index 0000000..bf0bcde --- /dev/null +++ b/server/home/Whisper/podman-whisper.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: Whisper +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Whisper +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/home/nextcloud/compose.yml b/server/home/nextcloud/compose.yml new file mode 100644 index 0000000..25c7dd7 --- /dev/null +++ b/server/home/nextcloud/compose.yml @@ -0,0 +1,54 @@ +services: + nextcloud-aio-mastercontainer: + image: ghcr.io/nextcloud-releases/all-in-one:latest + container_name: nextcloud-aio-mastercontainer + volumes: + - nextcloud_aio_mastercontainer:/mnt/docker-aio-config + - /var/run/docker.sock:/var/run/docker.sock:ro + network_mode: bridge + ports: + - 8070:8080 + environment: + APACHE_PORT: 11000 + APACHE_ADDITIONAL_NETWORK: nginx-bridge + # https://github.com/nextcloud/all-in-one#how-to-adjust-borgs-retention-policy + BORG_RETENTION_POLICY: --keep-within=7d --keep-weekly=4 --keep-monthly=6 + # Host path for data directory. Can't change after initial run! + NEXTCLOUD_DATADIR: /home/gib/Public/Nextcloud/Server + # https://github.com/nextcloud/all-in-one#how-to-allow-the-nextcloud-container-to-access-directories-on-the-host + # NEXTCLOUD_MOUNT: /mnt/ + # https://github.com/nextcloud/all-in-one#how-to-adjust-the-upload-limit-for-nextcloud + NEXTCLOUD_UPLOAD_LIMIT: 32G + # https://github.com/nextcloud/all-in-one#how-to-adjust-the-max-execution-time-for-nextcloud + NEXTCLOUD_MAX_TIME: 3600 + # https://github.com/nextcloud/all-in-one#how-to-adjust-the-php-memory-limit-for-nextcloud + NEXTCLOUD_MEMORY_LIMIT: 4096M + # https://github.com/nextcloud/all-in-one#how-to-trust-user-defined-certification-authorities-ca + # NEXTCLOUD_TRUSTED_CACERTS_DIR: /path/to/my/cacerts + # https://github.com/nextcloud/all-in-one#how-to-change-the-nextcloud-apps-that-are-installed-on-the-first-startup + NEXTCLOUD_STARTUP_APPS: deck twofactor_totp calendar contacts notes + NEXTCLOUD_ENABLE_NVIDIA_GPU: true + SKIP_DOMAIN_VALIDATION: true + # TALK_PORT: 3478 + # WATCHTOWER_DOCKER_SOCKET_PATH: /var/run/docker.sock + security_opt: ["seccomp:unconfined", "label:disable"] + devices: ["/dev/dri:/dev/dri"] + init: true + restart: unless-stopped + tty: true + runtime: nvidia + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + +volumes: + nextcloud_aio_mastercontainer: + name: nextcloud_aio_mastercontainer # CANT CHANGE + +networks: + nginx-bridge: + external: true diff --git a/server/home/nextcloud/podman-nextcloud.service b/server/home/nextcloud/podman-nextcloud.service new file mode 100644 index 0000000..1d5f972 --- /dev/null +++ b/server/home/nextcloud/podman-nextcloud.service @@ -0,0 +1,18 @@ +[Unit] +Description=Podman Compose: nextcloud +After=network-online.target podman.socket +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/nextcloud +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/Adminer/compose.yml b/server/vps/Adminer/compose.yml similarity index 100% rename from server/containers/Adminer/compose.yml rename to server/vps/Adminer/compose.yml diff --git a/server/containers/Adminer/podman-adminer.service b/server/vps/Adminer/podman-adminer.service similarity index 100% rename from server/containers/Adminer/podman-adminer.service rename to server/vps/Adminer/podman-adminer.service diff --git a/server/containers/Agentchat/compose.yml b/server/vps/Agentchat/compose.yml similarity index 100% rename from server/containers/Agentchat/compose.yml rename to server/vps/Agentchat/compose.yml diff --git a/server/containers/Agentchat/podman-agentchat.service b/server/vps/Agentchat/podman-agentchat.service similarity index 100% rename from server/containers/Agentchat/podman-agentchat.service rename to server/vps/Agentchat/podman-agentchat.service diff --git a/server/containers/Authentik/.env.example b/server/vps/Authentik/.env.example similarity index 87% rename from server/containers/Authentik/.env.example rename to server/vps/Authentik/.env.example index 78cb142..a232f8b 100644 --- a/server/containers/Authentik/.env.example +++ b/server/vps/Authentik/.env.example @@ -18,3 +18,8 @@ AUTHENTIK_EMAIL__USE_SSL=false AUTHENTIK_EMAIL__TIMEOUT=10 # Email address authentik will send from, should have a correct @domain AUTHENTIK_EMAIL__FROM=admin@gbrown.org + +# Also set on the live machine +AUTHENTIK_IMAGE=CHANGE_ME +PG_DB=CHANGE_ME +PG_USER=CHANGE_ME diff --git a/server/containers/Authentik/compose.yml b/server/vps/Authentik/compose.yml similarity index 100% rename from server/containers/Authentik/compose.yml rename to server/vps/Authentik/compose.yml diff --git a/server/containers/Authentik/podman-authentik.service b/server/vps/Authentik/podman-authentik.service similarity index 93% rename from server/containers/Authentik/podman-authentik.service rename to server/vps/Authentik/podman-authentik.service index d21d446..ddf4043 100644 --- a/server/containers/Authentik/podman-authentik.service +++ b/server/vps/Authentik/podman-authentik.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: Authentik After=network-online.target podman.socket podman-postgresql.service Wants=network-online.target podman-postgresql.service diff --git a/server/containers/Beszel/compose.yml b/server/vps/Beszel/compose.yml similarity index 88% rename from server/containers/Beszel/compose.yml rename to server/vps/Beszel/compose.yml index ac73a25..b4f53bb 100644 --- a/server/containers/Beszel/compose.yml +++ b/server/vps/Beszel/compose.yml @@ -35,6 +35,11 @@ services: # No published port: NPM proxies to http://beszel:8090 over nginx-bridge. environment: - TZ=America/New_York + # Login is SSO-only through Authentik (OIDC provider "Gibs Auth", app slug + # beszel). The OAuth config lives in data.db (users collection options), + # not here. The PocketBase superuser panel at /_/ keeps its own password, + # and is the way back in if Authentik is down: flip this to false there. + - DISABLE_PASSWORD_AUTH=true # Lets the hub reach a host-network agent. Under rootless podman this resolves to # the nginx-bridge gateway (172.18.0.1). Add the VPS in the hub UI with host # "host.docker.internal", port 45876. diff --git a/server/containers/Beszel/podman-beszel.service b/server/vps/Beszel/podman-beszel.service similarity index 92% rename from server/containers/Beszel/podman-beszel.service rename to server/vps/Beszel/podman-beszel.service index 9e3a959..36582ce 100644 --- a/server/containers/Beszel/podman-beszel.service +++ b/server/vps/Beszel/podman-beszel.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: Beszel After=network-online.target podman.socket Wants=network-online.target diff --git a/server/vps/BillTracker/.env.example b/server/vps/BillTracker/.env.example new file mode 100644 index 0000000..ca6245e --- /dev/null +++ b/server/vps/BillTracker/.env.example @@ -0,0 +1,14 @@ +INSTANCE_NAME=CHANGE_ME +INSTANCE_SECRET=CHANGE_ME +POSTGRES_URL=CHANGE_ME +CONVEX_CLOUD_ORIGIN=CHANGE_ME +CONVEX_SITE_ORIGIN=CHANGE_ME +DISABLE_BEACON=CHANGE_ME +REDACT_LOGS_TO_CLIENT=CHANGE_ME +DO_NOT_REQUIRE_SSL=CHANGE_ME +MCP_SECRET=CHANGE_ME +CONVEX_BACKEND_IMAGE=CHANGE_ME +CONVEX_DASHBOARD_IMAGE=CHANGE_ME +MCP_PUBLIC_URL=CHANGE_ME +NEXT_PUBLIC_CONVEX_URL=CHANGE_ME +NEXT_PUBLIC_SITE_URL=CHANGE_ME diff --git a/server/vps/BillTracker/compose.yml b/server/vps/BillTracker/compose.yml new file mode 100644 index 0000000..45c72d1 --- /dev/null +++ b/server/vps/BillTracker/compose.yml @@ -0,0 +1,69 @@ +name: bill-tracker + +networks: + nginx-bridge: + external: true + +services: + bill-tracker-next: + image: git.gbrown.org/gib/bill-tracker-next:latest + container_name: bill-tracker-next + hostname: bill-tracker-next + domainname: billtracker.gbrown.org + networks: [nginx-bridge] + environment: + NODE_ENV: production + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://billtracker.gbrown.org} + NEXT_PUBLIC_CONVEX_URL: ${NEXT_PUBLIC_CONVEX_URL:-https://api.billtracker.gbrown.org} + restart: unless-stopped + + bill-tracker-mcp: + image: git.gbrown.org/gib/bill-tracker-mcp:latest + container_name: bill-tracker-mcp + hostname: bill-tracker-mcp + domainname: mcp.billtracker.gbrown.org + networks: [nginx-bridge] + volumes: [./data/mcp:/data:Z] + environment: + NODE_ENV: production + MCP_PUBLIC_URL: ${MCP_PUBLIC_URL:-https://mcp.billtracker.gbrown.org} + MCP_SECRET: ${MCP_SECRET:?Set MCP_SECRET in the VPS .env} + BILL_TRACKER_CONVEX_URL: ${NEXT_PUBLIC_CONVEX_URL:-https://api.billtracker.gbrown.org} + restart: unless-stopped + + bill-tracker-backend: + image: ${CONVEX_BACKEND_IMAGE:-ghcr.io/get-convex/convex-backend@sha256:1f2044e3eac463ac78973b136c0baf72d4ada602611d853d6f99f280e29e0a98} + container_name: bill-tracker-backend + hostname: bill-tracker-backend + domainname: api.billtracker.gbrown.org + networks: [nginx-bridge] + volumes: [./data/convex:/convex/data:Z] + environment: + INSTANCE_NAME: ${INSTANCE_NAME:-bill_tracker} + INSTANCE_SECRET: ${INSTANCE_SECRET:?Set INSTANCE_SECRET in the VPS .env} + POSTGRES_URL: ${POSTGRES_URL:?Set POSTGRES_URL in the VPS .env} + CONVEX_CLOUD_ORIGIN: https://api.billtracker.gbrown.org + CONVEX_SITE_ORIGIN: https://convex.billtracker.gbrown.org + DISABLE_BEACON: 'true' + REDACT_LOGS_TO_CLIENT: 'true' + DO_NOT_REQUIRE_SSL: 'true' + restart: unless-stopped + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:3210/version'] + interval: 5s + start_period: 10s + stop_grace_period: 10s + stop_signal: SIGINT + + bill-tracker-dashboard: + image: ${CONVEX_DASHBOARD_IMAGE:-ghcr.io/get-convex/convex-dashboard@sha256:284a2638e0c1a4ec0c2327d8219776f3a426ca5824b81686ae4d9454dc0ce8ed} + container_name: bill-tracker-dashboard + hostname: bill-tracker-dashboard + domainname: dashboard.billtracker.gbrown.org + networks: [nginx-bridge] + environment: + NEXT_PUBLIC_DEPLOYMENT_URL: https://api.billtracker.gbrown.org + depends_on: + bill-tracker-backend: + condition: service_healthy + restart: unless-stopped diff --git a/server/vps/BillTracker/podman-bill-tracker.service b/server/vps/BillTracker/podman-bill-tracker.service new file mode 100644 index 0000000..8cdd3ce --- /dev/null +++ b/server/vps/BillTracker/podman-bill-tracker.service @@ -0,0 +1,21 @@ +[Unit] +Description=Podman Compose: Bill Tracker +RequiresMountsFor=/home/gib/Media +After=network-online.target podman.socket podman-postgresql.service +Wants=network-online.target podman-postgresql.service +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/BillTracker +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=900 +TimeoutStopSec=90 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/containers/CalCom/.env.example b/server/vps/CalCom/.env.example similarity index 100% rename from server/containers/CalCom/.env.example rename to server/vps/CalCom/.env.example diff --git a/server/containers/CalCom/compose.yml b/server/vps/CalCom/compose.yml similarity index 94% rename from server/containers/CalCom/compose.yml rename to server/vps/CalCom/compose.yml index eaf0a42..200e5b0 100644 --- a/server/containers/CalCom/compose.yml +++ b/server/vps/CalCom/compose.yml @@ -7,7 +7,7 @@ # BEFORE FIRST START, create its role and database on the SHARED postgres (this stack # no longer ships its own): # podman exec -i postgresql psql -U npm <<'SQL' -# CREATE ROLE calcom LOGIN PASSWORD ''; +# CREATE ROLE calcom LOGIN PASSWORD '617408b6790b546811cc38d0353ea506'; # CREATE DATABASE calcom OWNER calcom; # SQL # Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql. @@ -52,7 +52,7 @@ services: - EMAIL_SERVER_HOST=CHANGE_ME - EMAIL_SERVER_PORT=587 - EMAIL_SERVER_USER=CHANGE_ME - - EMAIL_SERVER_PASSWORD=${EMAIL_SERVER_PASSWORD} + - EMAIL_SERVER_PASSWORD=CHANGE_ME labels: com.centurylinklabs.watchtower.enable: "true" tty: true diff --git a/server/containers/CalCom/podman-calcom.service b/server/vps/CalCom/podman-calcom.service similarity index 100% rename from server/containers/CalCom/podman-calcom.service rename to server/vps/CalCom/podman-calcom.service diff --git a/server/containers/ConvexMonorepo/.env.example b/server/vps/ConvexMonorepo/.env.example similarity index 96% rename from server/containers/ConvexMonorepo/.env.example rename to server/vps/ConvexMonorepo/.env.example index fe79c63..fc99f26 100644 --- a/server/containers/ConvexMonorepo/.env.example +++ b/server/vps/ConvexMonorepo/.env.example @@ -57,3 +57,8 @@ DO_NOT_REQUIRE_SSL=true #ACTIONS_USER_TIMEOUT_SECS= #RUST_LOG= #RUST_BACKTRACE= + +# Also set on the live machine +BACKEND_PORT=CHANGE_ME +PORT=CHANGE_ME +SITE_PROXY_PORT=CHANGE_ME diff --git a/server/containers/ConvexMonorepo/compose.yml b/server/vps/ConvexMonorepo/compose.yml similarity index 100% rename from server/containers/ConvexMonorepo/compose.yml rename to server/vps/ConvexMonorepo/compose.yml diff --git a/server/containers/ConvexMonorepo/podman-convexmonorepo.service b/server/vps/ConvexMonorepo/podman-convexmonorepo.service similarity index 100% rename from server/containers/ConvexMonorepo/podman-convexmonorepo.service rename to server/vps/ConvexMonorepo/podman-convexmonorepo.service diff --git a/server/containers/Documenso/.env.example b/server/vps/Documenso/.env.example similarity index 100% rename from server/containers/Documenso/.env.example rename to server/vps/Documenso/.env.example diff --git a/server/containers/Documenso/compose.yml b/server/vps/Documenso/compose.yml similarity index 94% rename from server/containers/Documenso/compose.yml rename to server/vps/Documenso/compose.yml index 31bd67c..96eaa31 100644 --- a/server/containers/Documenso/compose.yml +++ b/server/vps/Documenso/compose.yml @@ -9,7 +9,7 @@ # 1. Create its role and database on the SHARED postgres (this stack no longer # ships its own): # podman exec -i postgresql psql -U npm <<'SQL' -# CREATE ROLE documenso LOGIN PASSWORD ''; +# CREATE ROLE documenso LOGIN PASSWORD 'e2bf60b34aff8ce259e5ed42da46c9c6'; # CREATE DATABASE documenso OWNER documenso; # SQL # Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql so a @@ -60,7 +60,7 @@ services: - NEXT_PRIVATE_SMTP_HOST=CHANGE_ME - NEXT_PRIVATE_SMTP_PORT=587 - NEXT_PRIVATE_SMTP_USERNAME=CHANGE_ME - - NEXT_PRIVATE_SMTP_PASSWORD=${NEXT_PRIVATE_SMTP_PASSWORD} + - NEXT_PRIVATE_SMTP_PASSWORD=CHANGE_ME - NEXT_PRIVATE_SMTP_FROM_NAME=Documenso - NEXT_PRIVATE_SMTP_FROM_ADDRESS=documenso@gbrown.org labels: diff --git a/server/containers/Documenso/podman-documenso.service b/server/vps/Documenso/podman-documenso.service similarity index 100% rename from server/containers/Documenso/podman-documenso.service rename to server/vps/Documenso/podman-documenso.service diff --git a/server/vps/Gbrown_Agent/.env.example b/server/vps/Gbrown_Agent/.env.example new file mode 100644 index 0000000..70518ea --- /dev/null +++ b/server/vps/Gbrown_Agent/.env.example @@ -0,0 +1 @@ +AGENT_INGEST_SECRET=CHANGE_ME diff --git a/server/vps/Gbrown_Agent/compose.yml b/server/vps/Gbrown_Agent/compose.yml new file mode 100644 index 0000000..6539ffd --- /dev/null +++ b/server/vps/Gbrown_Agent/compose.yml @@ -0,0 +1,43 @@ +# Hetzner VPS (junior.gib, rootless podman): ~/Server/Gbrown_Agent/compose.yml. +# Follows ~/Server/AGENTS.md. AGENT_INGEST_SECRET lives in ./.env (never +# committed). Started by podman-gbrown-agent.service, never by hand. +# +# SELinux: +# - The podman socket is mounted with no relabel flag, ever (AGENTS.md §6). +# - Reading it needs `label:disable`, the same exception portainer and uptime +# have as container-management tooling. +# - Because labeling is off, the NPM logs mount also takes no :z or :Z. +# NPM mounts its data dir with :Z (a private MCS pair), so relabeling it here +# would lock NPM out of its own data. +networks: + nginx-bridge: + external: true + +services: + gbrown-agent: + image: git.gbrown.org/gib/gbrown-agent:latest + container_name: gbrown-agent + hostname: gbrown-agent + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - HOST_ID=vps + - INGEST_URL=https://convex.gbrown.org/ingest + - AGENT_INGEST_SECRET=${AGENT_INGEST_SECRET} + # Reach Convex over WireGuard, not Cloudflare: pin the hostname to the + # Home Server's proxy (192.168.1.2, routed via the `Home` interface). The + # URL stays https with the real hostname, so SNI and the certificate check + # are unchanged. + extra_hosts: + - 'convex.gbrown.org:192.168.1.2' + # The image runs as uid 1000 (bun). keep-id maps it to gib, who owns the + # podman socket and the NPM logs (AGENTS.md §3.2). Never `user: 1000:1000`. + userns_mode: 'keep-id:uid=1000,gid=1000' + volumes: + - /run/user/1000/podman/podman.sock:/var/run/docker.sock # no :Z, ever + - /home/gib/Server/Nginx_Proxy_Manager/volumes/data/logs:/npm-logs:ro + security_opt: ['label:disable', 'no-new-privileges:true'] + read_only: true + cap_drop: [ALL] + restart: unless-stopped + stop_grace_period: 10s diff --git a/server/vps/Gbrown_Agent/podman-gbrown-agent.service b/server/vps/Gbrown_Agent/podman-gbrown-agent.service new file mode 100644 index 0000000..2a65b5a --- /dev/null +++ b/server/vps/Gbrown_Agent/podman-gbrown-agent.service @@ -0,0 +1,27 @@ +# Rootless user unit for the telemetry agent on junior.gib, from +# ~/Server/podman-example.service. Install (as gib, never sudo): +# cp podman-gbrown-agent.service ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now podman-gbrown-agent.service +# Then add gbrown-agent to the lists in ~/Server/verify-rootless.sh, and to +# update-containers.sh if it should follow nightly image updates. +[Unit] +Description=Podman Compose: gbrown telemetry agent +After=network-online.target podman.socket +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=3 + +[Service] +Type=oneshot +WorkingDirectory=%h/Server/Gbrown_Agent +ExecStart=/usr/bin/podman compose up -d +ExecStop=/usr/bin/podman compose down +RemainAfterExit=yes +TimeoutStartSec=300 +TimeoutStopSec=60 +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/server/vps/Gitea/.env.example b/server/vps/Gitea/.env.example new file mode 100644 index 0000000..6de7b3f --- /dev/null +++ b/server/vps/Gitea/.env.example @@ -0,0 +1 @@ +USESEND_API_KEY=CHANGE_ME diff --git a/server/vps/Gitea/compose.yml b/server/vps/Gitea/compose.yml new file mode 100644 index 0000000..3db01d8 --- /dev/null +++ b/server/vps/Gitea/compose.yml @@ -0,0 +1,41 @@ +networks: + nginx-bridge: + external: true +services: + gitea: + image: docker.io/gitea/gitea:latest + container_name: gitea + hostname: gitea + domainname: git.gbrown.org + networks: + - nginx-bridge + ports: + - '2222:22' + environment: + - TZ=America/New_York + - USER_UID=1001 + - USER_GID=1003 + # ── Mailer ──────────────────────────────────────────────────────────────── + # The image's entrypoint runs `environment-to-ini`, so GITEA__section__KEY + # vars are written into /data/gitea/conf/app.ini on every start. This block + # is therefore the source of truth -- hand-editing app.ini gets overwritten + # on the next restart. It also means PASSWD lands in app.ini in plaintext + # inside the volume; that is how Gitea works, not a misconfiguration. + # + # PROTOCOL=smtp+starttls matches port 587. The relay refuses AUTH until + # STARTTLS succeeds, so plain `smtp` would fail with 538. + - GITEA__mailer__ENABLED=true + - GITEA__mailer__PROTOCOL=smtp+starttls + - GITEA__mailer__SMTP_ADDR=smtp.usesend.gbrown.org + - GITEA__mailer__SMTP_PORT=587 + - GITEA__mailer__USER=usesend + - GITEA__mailer__PASSWD=${USESEND_API_KEY:?set USESEND_API_KEY in Gitea/.env} + - GITEA__mailer__FROM=Gitea + labels: + com.centurylinklabs.watchtower.enable: 'true' + volumes: + - ./volume:/data:Z + - /home/gib/Media/gitea-packages:/data/gitea/packages:Z + - ./ssh:/data/git/.ssh:Z + tty: true + restart: unless-stopped diff --git a/server/containers/Gitea/podman-gitea.service b/server/vps/Gitea/podman-gitea.service similarity index 100% rename from server/containers/Gitea/podman-gitea.service rename to server/vps/Gitea/podman-gitea.service diff --git a/server/containers/ISponsorBlockTV/compose.yml b/server/vps/ISponsorBlockTV/compose.yml similarity index 100% rename from server/containers/ISponsorBlockTV/compose.yml rename to server/vps/ISponsorBlockTV/compose.yml diff --git a/server/containers/ISponsorBlockTV/podman-isponsorblocktv.service b/server/vps/ISponsorBlockTV/podman-isponsorblocktv.service similarity index 92% rename from server/containers/ISponsorBlockTV/podman-isponsorblocktv.service rename to server/vps/ISponsorBlockTV/podman-isponsorblocktv.service index b490e11..e466803 100644 --- a/server/containers/ISponsorBlockTV/podman-isponsorblocktv.service +++ b/server/vps/ISponsorBlockTV/podman-isponsorblocktv.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: iSponsorBlockTV After=network-online.target podman.socket Wants=network-online.target diff --git a/server/containers/Infisical/.env.example b/server/vps/Infisical/.env.example similarity index 98% rename from server/containers/Infisical/.env.example rename to server/vps/Infisical/.env.example index a3a44d4..2c40401 100644 --- a/server/containers/Infisical/.env.example +++ b/server/vps/Infisical/.env.example @@ -140,3 +140,6 @@ KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN=false # ClickHouse (optional) for audit log storage # CLICKHOUSE_URL=http://infisical:infisical@clickhouse:8123/infisical + +# Also set on the live machine +CLIENT_GITLAB_LOGIN_URL=CHANGE_ME diff --git a/server/containers/Infisical/compose.yml b/server/vps/Infisical/compose.yml similarity index 100% rename from server/containers/Infisical/compose.yml rename to server/vps/Infisical/compose.yml diff --git a/server/containers/Infisical/podman-infisical.service b/server/vps/Infisical/podman-infisical.service similarity index 93% rename from server/containers/Infisical/podman-infisical.service rename to server/vps/Infisical/podman-infisical.service index 82e1ffe..94fcc27 100644 --- a/server/containers/Infisical/podman-infisical.service +++ b/server/vps/Infisical/podman-infisical.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: Infisical After=network-online.target podman.socket podman-postgresql.service Wants=network-online.target podman-postgresql.service diff --git a/server/containers/LashAddict/.env.example b/server/vps/LashAddict/.env.example similarity index 92% rename from server/containers/LashAddict/.env.example rename to server/vps/LashAddict/.env.example index 2ff7038..9bf6bd2 100644 --- a/server/containers/LashAddict/.env.example +++ b/server/vps/LashAddict/.env.example @@ -62,3 +62,11 @@ POSTGRES_CONTAINER_NAME=lashaddict-postgres POSTGRES_USER=gib POSTGRES_PASSWORD=CHANGE_ME POSTGRES_DB=lashaddict-payload + +# Also set on the live machine +NEXT_PUBLIC_POSTHOG_KEY=CHANGE_ME +NEXT_PUBLIC_POSTHOG_HOST=CHANGE_ME +NEXT_PUBLIC_PLAUSIBLE_SRC=CHANGE_ME +BACKEND_PORT=CHANGE_ME +PORT=CHANGE_ME +SITE_PROXY_PORT=CHANGE_ME diff --git a/server/containers/LashAddict/compose.yml b/server/vps/LashAddict/compose.yml similarity index 82% rename from server/containers/LashAddict/compose.yml rename to server/vps/LashAddict/compose.yml index cd6e2a3..9ccbf34 100644 --- a/server/containers/LashAddict/compose.yml +++ b/server/vps/LashAddict/compose.yml @@ -6,6 +6,12 @@ services: image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest container_name: ${NEXT_CONTAINER_NAME} hostname: ${NEXT_CONTAINER_NAME} + # Upstream DNS pinned to Cloudflare: Hetzner resolvers held a stale + # NXDOMAIN for api.lashaddictstudio.com for hours after it was created + # (2026-09-30). Container names still resolve through aardvark-dns. + dns: + - 1.1.1.1 + - 1.0.0.1 domainname: ${NEXT_DOMAIN} networks: - ${NETWORK:-nginx-bridge} @@ -29,6 +35,11 @@ services: lashaddict-backend: image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest} container_name: ${BACKEND_CONTAINER_NAME:-lashaddict-backend} + # Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend + # fetches its own auth discovery URL, which Hetzner resolvers failed. + dns: + - 1.1.1.1 + - 1.0.0.1 hostname: ${BACKEND_CONTAINER_NAME:-lashaddict-backend} domainname: ${BACKEND_DOMAIN:-lashaddict.gbrown.org} networks: @@ -58,6 +69,11 @@ services: lashaddict-dashboard: image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest} container_name: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard} + # Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend + # fetches its own auth discovery URL, which Hetzner resolvers failed. + dns: + - 1.1.1.1 + - 1.0.0.1 hostname: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard} domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-lashaddict.gbrown.org}} networks: diff --git a/server/containers/LashAddict/podman-lashaddict.service b/server/vps/LashAddict/podman-lashaddict.service similarity index 100% rename from server/containers/LashAddict/podman-lashaddict.service rename to server/vps/LashAddict/podman-lashaddict.service diff --git a/server/containers/N8n/.env.example b/server/vps/N8n/.env.example similarity index 100% rename from server/containers/N8n/.env.example rename to server/vps/N8n/.env.example diff --git a/server/containers/N8n/compose.yml b/server/vps/N8n/compose.yml similarity index 100% rename from server/containers/N8n/compose.yml rename to server/vps/N8n/compose.yml diff --git a/server/containers/N8n/podman-n8n.service b/server/vps/N8n/podman-n8n.service similarity index 92% rename from server/containers/N8n/podman-n8n.service rename to server/vps/N8n/podman-n8n.service index 3b6b46d..0f98cb2 100644 --- a/server/containers/N8n/podman-n8n.service +++ b/server/vps/N8n/podman-n8n.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: N8n After=network-online.target podman-postgresql.service Wants=network-online.target diff --git a/server/containers/Nginx_Proxy_Manager/.env.example b/server/vps/Nginx_Proxy_Manager/.env.example similarity index 100% rename from server/containers/Nginx_Proxy_Manager/.env.example rename to server/vps/Nginx_Proxy_Manager/.env.example diff --git a/server/containers/Nginx_Proxy_Manager/compose.yml b/server/vps/Nginx_Proxy_Manager/compose.yml similarity index 96% rename from server/containers/Nginx_Proxy_Manager/compose.yml rename to server/vps/Nginx_Proxy_Manager/compose.yml index 10f6285..ecf4bd8 100644 --- a/server/containers/Nginx_Proxy_Manager/compose.yml +++ b/server/vps/Nginx_Proxy_Manager/compose.yml @@ -37,7 +37,6 @@ services: #networks: ['nginx-bridge'] #environment: #POSTGRES_USER: 'npm' - #POSTGRES_PASSWORD: '' #POSTGRES_DB: 'npm' #labels: #com.centurylinklabs.watchtower.enable: 'true' diff --git a/server/containers/Nginx_Proxy_Manager/podman-nginx-proxy-manager.service b/server/vps/Nginx_Proxy_Manager/podman-nginx-proxy-manager.service similarity index 93% rename from server/containers/Nginx_Proxy_Manager/podman-nginx-proxy-manager.service rename to server/vps/Nginx_Proxy_Manager/podman-nginx-proxy-manager.service index 8bd09df..f249f9c 100644 --- a/server/containers/Nginx_Proxy_Manager/podman-nginx-proxy-manager.service +++ b/server/vps/Nginx_Proxy_Manager/podman-nginx-proxy-manager.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: Nginx Proxy Manager After=network-online.target podman-postgresql.service Wants=network-online.target diff --git a/server/vps/Penpot/.env.example b/server/vps/Penpot/.env.example new file mode 100644 index 0000000..b99717b --- /dev/null +++ b/server/vps/Penpot/.env.example @@ -0,0 +1,7 @@ +PENPOT_SECRET_KEY=CHANGE_ME +PENPOT_DATABASE_PASSWORD=CHANGE_ME + +# Also set on the live machine +PENPOT_SMTP_PASSWORD=CHANGE_ME +PENPOT_OIDC_CLIENT_ID=CHANGE_ME +PENPOT_OIDC_CLIENT_SECRET=CHANGE_ME diff --git a/server/vps/Penpot/compose.yml b/server/vps/Penpot/compose.yml new file mode 100644 index 0000000..e69ce90 --- /dev/null +++ b/server/vps/Penpot/compose.yml @@ -0,0 +1,183 @@ +# Penpot — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12. +# +# First started 2026-09-21. Role + database created on the SHARED postgres, and the +# same lines added to PostgreSQL/initdb/00-roles-and-databases.sql.template (note the +# .template suffix -- the plain .sql path this header used to cite does not exist). +# Managed by: systemctl --user enable --now podman-penpot.service +# +# Image tags are PINNED and watchtower is disabled on purpose. Penpot runs schema +# migrations on boot and has shipped breaking major upgrades (1.x -> 2.x needed manual +# steps), so it is a bad candidate for unattended updates. Bump the tags deliberately, +# and back up the assets volume WITH the database -- a pg_dump alone will not restore. +# +# tty/stdin_open are false on every service here. A TTY makes tools in an entrypoint +# think they are interactive; usesend hung silently on exactly that (2026-09-19). +# +# NOTE: the home version used postgres:15-alpine with --data-checksums. The shared +# instance is postgres 17 without checksums. Penpot does not care, and since there is +# no existing data this is not a migration — but if you ever DO need checksums, they +# can only be set at initdb time for the whole cluster. +# +# Redis stays local to this stack: Penpot uses it for transient session and +# notification state only, so it is not worth centralising. +# +# Rootless adaptations from the home version: +# - Dropped its private postgres:15-alpine; uses the shared instance (AGENTS.md §2). +# - PENPOT_DATABASE_URI repointed from penpot-db to the shared postgresql. +# - :Z on the assets bind mount. It is shared between frontend and backend, so it +# uses LOWERCASE :z -- uppercase would give each container a private MCS category +# and the second to start would lock the first out (AGENTS.md §6). +# - /etc/localtime mounts removed in favour of TZ (AGENTS.md §8). +# +# Public sign-up is off (disable-registration). Accounts come from Authentik SSO +# instead: enable-oidc-registration creates a profile on first login. +# +# NPM: proxy penpot.gbrown.org -> http://penpot-frontend:8080 +# +# MCP (added 2026-09-23): `enable-mcp` on the FRONTEND makes its entrypoint write +# /mcp/stream, /mcp/sse and /mcp/ws locations proxying to penpot-mcp:4401 (HTTP) and +# :4402 (plugin websocket), so NPM needs no extra config. The flag is also on the +# backend, matching upstream, where it gates per-user MCP keys (Account > Integrations). +# Claude Code connects to https://penpot.gbrown.org/mcp/stream?userToken=. + +networks: + nginx-bridge: + external: true + +services: + penpot-frontend: + image: docker.io/penpotapp/frontend:2.17.2 + container_name: penpot-frontend + hostname: penpot-frontend + domainname: penpot.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - PENPOT_FLAGS=disable-registration disable-login-with-password enable-login-with-oidc enable-oidc-registration enable-smtp enable-mcp + labels: + com.centurylinklabs.watchtower.enable: "false" + volumes: + - ./volumes/assets:/opt/data/assets:z + depends_on: + - penpot-backend + - penpot-exporter + - penpot-mcp + tty: false + stdin_open: false + restart: unless-stopped + + penpot-backend: + image: docker.io/penpotapp/backend:2.17.2 + container_name: penpot-backend + hostname: penpot-backend + domainname: penpot.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + # Heap deliberately UNCAPPED (Gib's call, 2026-09-21): the JVM takes ~25% of + # host RAM, measuring ~2.3GB resident. Box has 16GB and the priority is + # responsiveness, not footprint. If it gets tight, Penpot moves to the home + # server rather than getting squeezed here. Set JVM_OPTS=-Xmx to cap. + - PENPOT_FLAGS=disable-registration disable-login-with-password enable-login-with-oidc enable-oidc-registration enable-smtp enable-prepl-server enable-mcp + - PENPOT_PUBLIC_URI=https://penpot.gbrown.org + # ── SMTP via the UseSend stack on this same host ────────────────────── + # The proxy ADVERTISES "AUTH LOGIN PLAIN" before STARTTLS but actually refuses + # it ("538 Must issue a STARTTLS command first"), so TLS is mandatory. It is + # reached by the smtp.usesend.gbrown.org network alias rather than its + # container name so the wildcard cert's hostname check passes. + # Username is the proxy's SMTP_AUTH_USERNAME; password is a UseSend API key. + # Max message size is 10MB. + - PENPOT_SMTP_HOST=smtp.usesend.gbrown.org + - PENPOT_SMTP_PORT=587 + - PENPOT_SMTP_USERNAME=usesend + - PENPOT_SMTP_PASSWORD=${PENPOT_SMTP_PASSWORD:?set this in .env -- a UseSend API key} + - PENPOT_SMTP_TLS=true + - PENPOT_SMTP_SSL=false + - PENPOT_SMTP_DEFAULT_FROM=Penpot + - PENPOT_SMTP_DEFAULT_REPLY_TO=Penpot + - PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY} + # Shared postgres, not a private container + - PENPOT_DATABASE_URI=postgresql://postgresql/penpot + - PENPOT_DATABASE_USERNAME=penpot + - PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD} + # Penpot defaults to min-size=60 AND max-size=60 -- it eagerly opens 60 + # connections and never releases them. On the SHARED postgres (max_connections + # = 100) that silently ate 60% of the cluster's slots and other apps started + # getting "remaining connection slots are reserved for SUPERUSER". Undocumented + # in Penpot's config guide; the numbers are visible in its startup log line + # `app.db - initialize connection pool ... min-size=60, max-size=60`. + - PENPOT_DATABASE_MIN_POOL_SIZE=4 + - PENPOT_DATABASE_MAX_POOL_SIZE=12 + - PENPOT_REDIS_URI=redis://penpot-redis/0 + - PENPOT_OBJECTS_STORAGE_BACKEND=fs + - PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets + - PENPOT_TELEMETRY_ENABLED=false + # ── SSO via Authentik (app slug "penpot", any Authentik user) ──────── + # Login is SSO-only (disable-login-with-password). Existing profiles match + # by email, and enable-oidc-registration creates a profile on first SSO + # login, while disable-registration keeps the public sign-up form off. + # Authority must keep its trailing slash; Penpot appends the discovery path. + - PENPOT_OIDC_BASE_URI=https://auth.gbrown.org/application/o/penpot/ + - PENPOT_OIDC_CLIENT_ID=${PENPOT_OIDC_CLIENT_ID:?set this in .env} + - PENPOT_OIDC_CLIENT_SECRET=${PENPOT_OIDC_CLIENT_SECRET:?set this in .env} + - PENPOT_OIDC_SCOPES=openid profile email + labels: + com.centurylinklabs.watchtower.enable: "false" + volumes: + - ./volumes/assets:/opt/data/assets:z + depends_on: + - penpot-redis + tty: false + stdin_open: false + restart: unless-stopped + + penpot-mcp: + # Upstream image defaults: `node index.js --multi-user`, listening on 0.0.0.0. + # No volumes and no published ports; only the frontend nginx talks to it. + image: docker.io/penpotapp/mcp:2.17.2 + container_name: penpot-mcp + hostname: penpot-mcp + domainname: penpot.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + labels: + com.centurylinklabs.watchtower.enable: "false" + tty: false + stdin_open: false + restart: unless-stopped + + penpot-exporter: + image: docker.io/penpotapp/exporter:2.17.2 + container_name: penpot-exporter + hostname: penpot-exporter + domainname: penpot.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + - PENPOT_PUBLIC_URI=http://penpot-frontend:8080 + # REQUIRED and missing from the original port: the exporter validates a + # :secret-key at boot and crash-loops without it. Must match the backend. + - PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY} + - PENPOT_REDIS_URI=redis://penpot-redis/0 + labels: + com.centurylinklabs.watchtower.enable: "false" + depends_on: + - penpot-redis + tty: false + stdin_open: false + restart: unless-stopped + + penpot-redis: + image: docker.io/library/redis:7-alpine + container_name: penpot-redis + hostname: penpot-redis + domainname: penpot.gbrown.org + networks: ['nginx-bridge'] + environment: + - TZ=America/New_York + labels: + com.centurylinklabs.watchtower.enable: "false" + tty: false + stdin_open: false + restart: unless-stopped diff --git a/server/containers/Penpot/podman-penpot.service b/server/vps/Penpot/podman-penpot.service similarity index 100% rename from server/containers/Penpot/podman-penpot.service rename to server/vps/Penpot/podman-penpot.service diff --git a/server/containers/Portainer/compose.yml b/server/vps/Portainer/compose.yml similarity index 100% rename from server/containers/Portainer/compose.yml rename to server/vps/Portainer/compose.yml diff --git a/server/containers/Portainer/podman-portainer.service b/server/vps/Portainer/podman-portainer.service similarity index 92% rename from server/containers/Portainer/podman-portainer.service rename to server/vps/Portainer/podman-portainer.service index 837e52d..1143219 100644 --- a/server/containers/Portainer/podman-portainer.service +++ b/server/vps/Portainer/podman-portainer.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: Portainer After=network-online.target Wants=network-online.target diff --git a/server/containers/PostgreSQL/.env.example b/server/vps/PostgreSQL/.env.example similarity index 100% rename from server/containers/PostgreSQL/.env.example rename to server/vps/PostgreSQL/.env.example diff --git a/server/containers/PostgreSQL/compose.yml b/server/vps/PostgreSQL/compose.yml similarity index 100% rename from server/containers/PostgreSQL/compose.yml rename to server/vps/PostgreSQL/compose.yml diff --git a/server/containers/PostgreSQL/podman-postgresql.service b/server/vps/PostgreSQL/podman-postgresql.service similarity index 92% rename from server/containers/PostgreSQL/podman-postgresql.service rename to server/vps/PostgreSQL/podman-postgresql.service index f27370a..59c839e 100644 --- a/server/containers/PostgreSQL/podman-postgresql.service +++ b/server/vps/PostgreSQL/podman-postgresql.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: PostgreSQL After=network-online.target Wants=network-online.target diff --git a/server/vps/Rustdesk/.env.example b/server/vps/Rustdesk/.env.example new file mode 100644 index 0000000..8b8894c --- /dev/null +++ b/server/vps/Rustdesk/.env.example @@ -0,0 +1,4 @@ +RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME + +# Also set on the live machine +RUSTDESK_API_RUSTDESK_KEY=CHANGE_ME diff --git a/server/containers/Rustdesk/compose.yml b/server/vps/Rustdesk/compose.yml similarity index 98% rename from server/containers/Rustdesk/compose.yml rename to server/vps/Rustdesk/compose.yml index 217d7ba..aef10a6 100644 --- a/server/containers/Rustdesk/compose.yml +++ b/server/vps/Rustdesk/compose.yml @@ -72,7 +72,7 @@ services: - RUSTDESK_API_RUSTDESK_ID_SERVER=relay.gbrown.org - RUSTDESK_API_RUSTDESK_RELAY_SERVER=relay.gbrown.org - RUSTDESK_API_RUSTDESK_API_SERVER=https://rustdesk.gbrown.org - - RUSTDESK_API_RUSTDESK_KEY=WLgvHhau6aa5nDPQutTHeQBpIrOEb8aPXByVBWQwkKc= + - RUSTDESK_API_RUSTDESK_KEY=${RUSTDESK_API_RUSTDESK_KEY} - RUSTDESK_API_RUSTDESK_JWT_KEY=${RUSTDESK_API_RUSTDESK_JWT_KEY} - RUSTDESK_API_LANG=en - RUSTDESK_API_APP_DISABLE_PWD_LOGIN=true diff --git a/server/containers/Rustdesk/podman-rustdesk.service b/server/vps/Rustdesk/podman-rustdesk.service similarity index 92% rename from server/containers/Rustdesk/podman-rustdesk.service rename to server/vps/Rustdesk/podman-rustdesk.service index 3e899a6..61621e7 100644 --- a/server/containers/Rustdesk/podman-rustdesk.service +++ b/server/vps/Rustdesk/podman-rustdesk.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: Rustdesk After=network-online.target podman.socket Wants=network-online.target diff --git a/server/containers/Spoon/.env.example b/server/vps/Spoon/.env.example similarity index 98% rename from server/containers/Spoon/.env.example rename to server/vps/Spoon/.env.example index 62400da..b302a17 100644 --- a/server/containers/Spoon/.env.example +++ b/server/vps/Spoon/.env.example @@ -64,3 +64,6 @@ SPOON_AGENT_WORKER_ID=production-worker SPOON_AGENT_WORKER_HTTP_PORT="3921" SPOON_AGENT_WORKER_INTERNAL_TOKEN=CHANGE_ME SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921 + +# Also set on the live machine +PORT=CHANGE_ME diff --git a/server/containers/Spoon/compose.yml b/server/vps/Spoon/compose.yml similarity index 100% rename from server/containers/Spoon/compose.yml rename to server/vps/Spoon/compose.yml diff --git a/server/containers/Spoon/podman-spoon.service b/server/vps/Spoon/podman-spoon.service similarity index 100% rename from server/containers/Spoon/podman-spoon.service rename to server/vps/Spoon/podman-spoon.service diff --git a/server/containers/StPeteIT/.env.example b/server/vps/StPeteIT/.env.example similarity index 95% rename from server/containers/StPeteIT/.env.example rename to server/vps/StPeteIT/.env.example index 16ae33a..1a3eb67 100644 --- a/server/containers/StPeteIT/.env.example +++ b/server/vps/StPeteIT/.env.example @@ -56,3 +56,8 @@ POSTGRES_URL=CHANGE_ME #ACTIONS_USER_TIMEOUT_SECS= #RUST_LOG= #RUST_BACKTRACE= + +# Also set on the live machine +BACKEND_PORT=CHANGE_ME +PORT=CHANGE_ME +SITE_PROXY_PORT=CHANGE_ME diff --git a/server/containers/StPeteIT/compose.yml b/server/vps/StPeteIT/compose.yml similarity index 100% rename from server/containers/StPeteIT/compose.yml rename to server/vps/StPeteIT/compose.yml diff --git a/server/containers/StPeteIT/podman-stpeteit.service b/server/vps/StPeteIT/podman-stpeteit.service similarity index 100% rename from server/containers/StPeteIT/podman-stpeteit.service rename to server/vps/StPeteIT/podman-stpeteit.service diff --git a/server/containers/Tools/compose.yml b/server/vps/Tools/compose.yml similarity index 66% rename from server/containers/Tools/compose.yml rename to server/vps/Tools/compose.yml index 4504afa..b336ef8 100644 --- a/server/containers/Tools/compose.yml +++ b/server/vps/Tools/compose.yml @@ -19,7 +19,7 @@ services: container_name: convertx hostname: convertx domainname: convert.tools.gbrown.org - networks: ['nginx-bridge'] + networks: ["nginx-bridge"] environment: - TZ=America/New_York - ALLOW_UNAUTHENTICATED=true @@ -37,7 +37,7 @@ services: container_name: it-tools hostname: it-tools domainname: tools.gbrown.org - networks: ['nginx-bridge'] + networks: ["nginx-bridge"] environment: - TZ=America/New_York labels: @@ -45,33 +45,12 @@ services: tty: true restart: unless-stopped - pdf-tools: - image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest - container_name: pdf-tools - hostname: pdf-tools - domainname: pdf.tools.gbrown.org - networks: ['nginx-bridge'] - environment: - - TZ=America/New_York - - LANGS=en_US - - DOCKER_ENABLE_SECURITY=true - volumes: - - ./volumes/pdf-tools/trainingData:/usr/share/tessdata:Z - - ./volumes/pdf-tools/extraConfigs:/configs:Z - - ./volumes/pdf-tools/customFiles:/customFiles:Z - - ./volumes/pdf-tools/logs:/logs/:Z - - ./volumes/pdf-tools/pipeline:/pipeline/:Z - labels: - com.centurylinklabs.watchtower.enable: "true" - tty: true - restart: unless-stopped - bento-pdf: image: ghcr.io/alam00000/bentopdf:latest container_name: bento-pdf hostname: bento-pdf domainname: bento.tools.gbrown.org - networks: ['nginx-bridge'] + networks: ["nginx-bridge"] environment: - TZ=America/New_York - PUID=1000 @@ -82,7 +61,28 @@ services: tty: true restart: unless-stopped healthcheck: - test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:8080'] + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080"] interval: 30s timeout: 10s retries: 3 + + # pdf-tools: + # image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest + # container_name: pdf-tools + # hostname: pdf-tools + # domainname: pdf.tools.gbrown.org + # networks: ["nginx-bridge"] + # environment: + # - TZ=America/New_York + # - LANGS=en_US + # - DOCKER_ENABLE_SECURITY=true + # volumes: + # - ./volumes/pdf-tools/trainingData:/usr/share/tessdata:Z + # - ./volumes/pdf-tools/extraConfigs:/configs:Z + # - ./volumes/pdf-tools/customFiles:/customFiles:Z + # - ./volumes/pdf-tools/logs:/logs/:Z + # - ./volumes/pdf-tools/pipeline:/pipeline/:Z + # labels: + # com.centurylinklabs.watchtower.enable: "true" + # tty: true + # restart: unless-stopped diff --git a/server/containers/Tools/podman-tools.service b/server/vps/Tools/podman-tools.service similarity index 92% rename from server/containers/Tools/podman-tools.service rename to server/vps/Tools/podman-tools.service index 169256d..d4362c3 100644 --- a/server/containers/Tools/podman-tools.service +++ b/server/vps/Tools/podman-tools.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: Tools After=network-online.target podman.socket Wants=network-online.target diff --git a/server/containers/Uptime/compose.yml b/server/vps/Uptime/compose.yml similarity index 100% rename from server/containers/Uptime/compose.yml rename to server/vps/Uptime/compose.yml diff --git a/server/containers/Uptime/podman-uptime.service b/server/vps/Uptime/podman-uptime.service similarity index 92% rename from server/containers/Uptime/podman-uptime.service rename to server/vps/Uptime/podman-uptime.service index 274780a..2436a6a 100644 --- a/server/containers/Uptime/podman-uptime.service +++ b/server/vps/Uptime/podman-uptime.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: Uptime After=network-online.target Wants=network-online.target diff --git a/server/containers/UseSend/.env.example b/server/vps/UseSend/.env.example similarity index 87% rename from server/containers/UseSend/.env.example rename to server/vps/UseSend/.env.example index ab7e9fd..afd265f 100644 --- a/server/containers/UseSend/.env.example +++ b/server/vps/UseSend/.env.example @@ -20,3 +20,7 @@ SMTP_HOST=smtp.usesend.gbrown.org SMTP_USER=usesend USESEND_PORT=3000 USESEND_URL=https://usesend.gbrown.org + +# Also set on the live machine +AWS_ACCESS_KEY_ID=CHANGE_ME +AWS_SECRET_ACCESS_KEY=CHANGE_ME diff --git a/server/vps/UseSend/compose.yml b/server/vps/UseSend/compose.yml new file mode 100644 index 0000000..c2a8f23 --- /dev/null +++ b/server/vps/UseSend/compose.yml @@ -0,0 +1,131 @@ +name: usesend +networks: + nginx-bridge: + external: true +services: + redis: + image: redis:7 + container_name: usesend-redis + hostname: usesend-redis + networks: + - nginx-bridge + env_file: .env + labels: + com.centurylinklabs.watchtower.enable: 'true' + volumes: + - ./volumes/redis:/data:Z + command: + - redis-server + - --maxmemory-policy + - noeviction + tty: true + stdin_open: true + restart: unless-stopped + minio: + image: minio/minio + container_name: usesend-storage + hostname: usesend-storage + networks: + - nginx-bridge + env_file: .env + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + labels: + com.centurylinklabs.watchtower.enable: 'true' + volumes: + - ./volumes/minio:/data:Z + entrypoint: sh + command: -c 'mkdir -p /data/unsend && minio server /data --console-address ':9001' --address ':9002'' + tty: true + stdin_open: true + restart: unless-stopped + smtp-server: + image: usesend/smtp-proxy:latest + container_name: usesend-smtp-server + hostname: usesend-smtp + networks: + nginx-bridge: + # The proxy REQUIRES STARTTLS before it will accept AUTH, so clients must + # reach it by a name the certificate actually covers. The wildcard is + # *.gbrown.org, which matches ONE label -- hence smtp-usesend, not + # smtp.usesend. + # + # Resolution: podman's aardvark-dns answers this name authoritatively for + # containers ON nginx-bridge, handing back the container IP. NPM is not + # involved at all -- it only reverse-proxies HTTP(S), and SMTP ports are + # never published to the host, so the proxy is unreachable from outside. + # + # NOTE: there IS a wildcard *.gbrown.org record in Cloudflare, so this name + # also resolves publicly (to Cloudflare, which serves nothing for it). The + # consequence: if this container is STOPPED, aardvark-dns stops answering, + # the lookup falls through to that public record, and clients dial + # Cloudflare on 587 and hang until timeout instead of failing fast. + aliases: + # Both names resolve to this container and BOTH validate against npm-43, + # which carries *.gbrown.org and *.usesend.gbrown.org. smtp.usesend is the + # name UseSend's own UI advertises and is the target to standardise on; + # smtp-usesend stays until every consumer has been moved across. + - smtp-usesend.gbrown.org + - smtp.usesend.gbrown.org + env_file: .env + environment: + - SMTP_AUTH_USERNAME=${SMTP_USER:?err} + - USESEND_BASE_URL=${USESEND_URL:?err} + # The image's built-in default cert cannot complete a TLS 1.3 handshake at all + # ("no suitable signature algorithm"), which silently made STARTTLS -- and so + # ALL inbound SMTP -- impossible. These point it at the real wildcard cert. + - USESEND_API_CERT_PATH=/certs/fullchain.pem + - USESEND_API_KEY_PATH=/certs/privkey.pem + ports: + # Bound to the WireGuard address ONLY, never 0.0.0.0 (AGENTS.md §13.2). This is an + # authenticated relay; putting it on the public interface would invite the internet to + # brute-force it, and contradicts the Cloudflare-only posture in §7. + # The `Home` wg interface sits in firewalld's `trusted` zone, so no rule is needed. + # Rootless can bind these because net.ipv4.ip_unprivileged_port_start = 80. + # Consumers on the home server reach it as smtp-usesend.gbrown.org (UniFi DNS maps + # that name to 192.168.2.2) so the *.gbrown.org cert still validates by hostname. + - "192.168.2.2:587:587" + - "192.168.2.2:465:465" + volumes: + # Copied from NPM's letsencrypt store, NOT bind-mounted from it: NPM mounts that + # tree with :Z (private SELinux category), so a second consumer would fight it. + # TRADE-OFF: copies go stale. Refresh them when NPM renews (see certs/README). + - ./certs:/certs:ro,Z + labels: + com.centurylinklabs.watchtower.enable: 'true' + tty: true + stdin_open: true + restart: unless-stopped + usesend: + image: usesend/usesend:latest + container_name: usesend + hostname: usesend + domainname: ${USESEND_URL:?err} + networks: + - nginx-bridge + env_file: .env + environment: + - COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + - PORT=${USESEND_PORT:-3000} + - DATABASE_URL=${DATABASE_URL:?err} + - NEXTAUTH_URL=${USESEND_URL:?err} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?err} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:?err} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:?err} + - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err} + - GITHUB_ID=${GITHUB_ID:?err} + - GITHUB_SECRET=${GITHUB_SECRET:?err} + - REDIS_URL=${REDIS_URL:?err} + - NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false} + - API_RATE_LIMIT=${API_RATE_LIMIT:-1} + - SMTP_HOST=${SMTP_HOST:-smtp.usesend.gbrown.org} + - SMTP_USER=${SMTP_USER:-usesend} + labels: + com.centurylinklabs.watchtower.enable: 'true' + depends_on: + redis: + condition: service_started + tty: false + stdin_open: false + restart: unless-stopped diff --git a/server/containers/UseSend/podman-usesend.service b/server/vps/UseSend/podman-usesend.service similarity index 100% rename from server/containers/UseSend/podman-usesend.service rename to server/vps/UseSend/podman-usesend.service diff --git a/server/vps/Vaultwarden/.env.example b/server/vps/Vaultwarden/.env.example new file mode 100644 index 0000000..5caebea --- /dev/null +++ b/server/vps/Vaultwarden/.env.example @@ -0,0 +1,6 @@ +ADMIN_TOKEN=CHANGE_ME + +# Also set on the live machine +USESEND_API_KEY=CHANGE_ME +SSO_CLIENT_ID=CHANGE_ME +SSO_CLIENT_SECRET=CHANGE_ME diff --git a/server/vps/Vaultwarden/compose.yml b/server/vps/Vaultwarden/compose.yml new file mode 100644 index 0000000..6e32f80 --- /dev/null +++ b/server/vps/Vaultwarden/compose.yml @@ -0,0 +1,77 @@ +# Vaultwarden — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/vaultwarden, +# staged 2026-08-12. +# +# This is the live instance serving vault.gbrown.org. The home server copy is retired. +# +# Rootless adaptations: +# - :Z on the data mount (SELinux Enforcing). +# - /etc/localtime mount dropped in favour of TZ (AGENTS.md §8). +# - No published port; NPM proxies to http://vaultwarden:80 over nginx-bridge. +# - Image starts as root and drops privileges internally, so no userns_mode needed. +# +# ADMIN_TOKEN lives in the mode-600 .env (which ~/Server/backup copies, so restores +# have it). volume/config.json also holds admin_token and OVERRIDES the env value; +# saving settings in the /admin panel writes it there. Keep the two in sync. + +networks: + nginx-bridge: + external: true + +services: + vaultwarden: + image: docker.io/vaultwarden/server:latest + container_name: vaultwarden + hostname: vaultwarden + domainname: vault.gbrown.org + networks: + - nginx-bridge + environment: + - TZ=America/New_York + - DOMAIN=https://vault.gbrown.org + - LOGIN_RATELIMIT_MAX_BURST=10 + - LOGIN_RATELIMIT_SECONDS=60 + - WEB_VAULT_ENABLED=true + - SIGNUPS_ALLOWED=false + - WEBSOCKET_ENABLED=true + - ADMIN_TOKEN=${ADMIN_TOKEN:?set ADMIN_TOKEN in .env} + # ── SSO via Authentik (app slug "vaultwarden", admin group only) ───── + # SSO replaces the email step only. The vault is still unlocked with the + # master password, because that is what the vault is encrypted with. + # SSO_ONLY stays off so password login keeps working for everyone. + # Existing accounts link by email, and Authentik must send + # email_verified=true for that (its "email_custom" scope mapping does). + # The authority needs its trailing slash. + - SSO_ENABLED=true + - SSO_ONLY=false + - SSO_AUTHORITY=https://auth.gbrown.org/application/o/vaultwarden/ + - SSO_SCOPES=openid profile email offline_access + - SSO_CLIENT_ID=${SSO_CLIENT_ID:?set SSO_CLIENT_ID in .env} + - SSO_CLIENT_SECRET=${SSO_CLIENT_SECRET:?set SSO_CLIENT_SECRET in .env} + # ── SMTP via the UseSend proxy on this host ─────────────────────────── + # These used to live in volume/config.json (written by the admin panel). + # config.json OVERRIDES env, so those keys were removed from it; leaving + # them would silently win over everything below. + # + # Reached by the smtp.usesend.gbrown.org network alias, not the container + # name, so the *.gbrown.org wildcard cert passes hostname verification -- + # which is why ACCEPT_INVALID_* can stay false. + # + # FROM must be on a UseSend-verified domain. Bare gbrown.org is NOT + # verified; vault.gbrown.org is. + - SMTP_HOST=smtp.usesend.gbrown.org + - SMTP_PORT=587 + - SMTP_SECURITY=starttls + - SMTP_USERNAME=usesend + - SMTP_PASSWORD=${USESEND_API_KEY:?set USESEND_API_KEY in .env} + - SMTP_FROM=admin@vault.gbrown.org + - SMTP_FROM_NAME=Gib's Vault Admin + - SMTP_TIMEOUT=15 + - SMTP_ACCEPT_INVALID_CERTS=false + - SMTP_ACCEPT_INVALID_HOSTNAMES=false + - SMTP_EMBED_IMAGES=true + labels: + com.centurylinklabs.watchtower.enable: "true" + volumes: + - ./volume:/data:Z + tty: true + restart: unless-stopped diff --git a/server/containers/Vaultwarden/podman-vaultwarden.service b/server/vps/Vaultwarden/podman-vaultwarden.service similarity index 92% rename from server/containers/Vaultwarden/podman-vaultwarden.service rename to server/vps/Vaultwarden/podman-vaultwarden.service index c373993..25960a7 100644 --- a/server/containers/Vaultwarden/podman-vaultwarden.service +++ b/server/vps/Vaultwarden/podman-vaultwarden.service @@ -1,5 +1,4 @@ [Unit] -RequiresMountsFor=/home/gib/Media Description=Podman Compose: Vaultwarden After=network-online.target podman.socket Wants=network-online.target diff --git a/tests/server/compose-secrets-contract b/tests/server/compose-secrets-contract index e0095c6..eb1c047 100755 --- a/tests/server/compose-secrets-contract +++ b/tests/server/compose-secrets-contract @@ -8,7 +8,7 @@ # secrets as ${VAR} interpolations resolved from the .env beside them on # the machine; .env.example names the variables with CHANGE_ME in place # of every value that matters. -# 2. .gitignore keeps .env and data/ out of server/containers/ even when a +# 2. .gitignore keeps .env and data/ out of server/vps/ and server/home/ even when a # cutover or a mistake puts one there. The live files belong in # ~/Server//, outside the checkout entirely -- the ignore is a # seatbelt, and a seatbelt that got deleted should fail loudly. @@ -71,10 +71,14 @@ fi # check-ignore consults the real gitignore for a path that need not exist, so # this asserts the rule rather than the current absence of violations. -git -C "$repo_dir" check-ignore -q 'server/containers/AnyService/.env' \ - || note '.gitignore no longer covers .env under server/containers/' -git -C "$repo_dir" check-ignore -q 'server/containers/AnyService/data/dump.sql' \ - || note '.gitignore no longer covers data/ under server/containers/' +for tree in vps home; do + git -C "$repo_dir" check-ignore -q "server/$tree/AnyService/.env" \ + || note ".gitignore no longer covers .env under server/$tree/" + git -C "$repo_dir" check-ignore -q "server/$tree/AnyService/data/dump.sql" \ + || note ".gitignore no longer covers data/ under server/$tree/" + git -C "$repo_dir" check-ignore -q "server/$tree/Nested/Service/volumes/db/file" \ + || note ".gitignore no longer covers volumes/ under server/$tree/" +done # ── 3. No .env is tracked ──────────────────────────────────────────────────── diff --git a/tests/server/containers-shape-contract b/tests/server/containers-shape-contract index 5f9a9b9..a9224a8 100755 --- a/tests/server/containers-shape-contract +++ b/tests/server/containers-shape-contract @@ -16,7 +16,9 @@ set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -containers_dir="$repo_dir/server/containers" +# The VPS catalog is what panama-server installs from. server/home/ shares the +# layout but is not installable yet, so it is held to the secrets contract only. +containers_dir="$repo_dir/server/vps" findings=() note() { findings+=("$1"); }