From 913a57f8a6002613bf74d91f1deed1190d90c83a Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 6 Oct 2026 10:52:47 -0400 Subject: [PATCH] Snapshot both servers: the VPS catalog moves to server/vps, the home server joins as server/home The catalog in server/containers had drifted from the VPS. Eleven compose files and twelve units no longer matched what runs, and two running services, Gbrown_Agent and BillTracker, were missing. server/vps is now a copy of the live files, and panama-server reads from it. server/home holds the home server's services in the same shape. They still run under Docker, so the podman units there are drafts for a later move, and nothing installs from that tree. PostHog, Sentry and Plausible are checkouts of upstream repositories. Only their local files are kept, next to an UPSTREAM file naming the repository and the exact commit. Every secret in both trees is a ${VAR} reference named in .env.example, or CHANGE_ME in files that are not compose files. The live servers still hold some of those values inline, so a live file can differ from its copy on those lines alone. Existing .env.example files keep their comments, with new keys appended at the end. The ignore rules and the secrets contract now cover both trees, plus the volume/ and volumes/ directories the home server uses. --- .gitignore | 12 +- bin/panama-server | 6 +- server/README.md | 31 +- server/containers/Gitea/compose.yml | 25 - server/containers/Penpot/.env.example | 2 - server/containers/Penpot/compose.yml | 120 --- server/containers/Rustdesk/.env.example | 1 - server/containers/UseSend/compose.yml | 88 -- server/containers/Vaultwarden/.env.example | 1 - server/containers/Vaultwarden/compose.yml | 55 -- server/home/Agents/Beszel/compose.yml | 48 ++ .../Beszel/podman-agents-beszel.service | 18 + server/home/Agents/Gitea/.env.example | 1 + server/home/Agents/Gitea/compose.yml | 153 ++++ .../Agents/Gitea/podman-agents-gitea.service | 18 + server/home/Agents/Portainer/compose.yml | 39 + .../Portainer/podman-agents-portainer.service | 18 + server/home/Frigate/.env.example | 2 + server/home/Frigate/compose.yml | 79 ++ server/home/Frigate/podman-frigate.service | 18 + server/home/Gbrown/.env.example | 16 + server/home/Gbrown/compose.yml | 219 +++++ server/home/Gbrown/podman-gbrown.service | 18 + server/home/Home/compose.yml | 24 + server/home/Home/podman-home.service | 18 + server/home/Immich/.env.example | 7 + server/home/Immich/compose.yml | 86 ++ server/home/Immich/podman-immich.service | 18 + server/home/Media/.env.example | 9 + server/home/Media/compose.yml | 429 ++++++++++ server/home/Media/podman-media.service | 18 + .../home/Minecraft/Bedrock/Adrian/compose.yml | 28 + .../podman-minecraft-bedrock-adrian.service | 18 + server/home/Minecraft/Java/compose.yml | 76 ++ .../Java/podman-minecraft-java.service | 18 + server/home/Nginx/.env.example | 2 + server/home/Nginx/compose.yml | 55 ++ server/home/Nginx/podman-nginx.service | 18 + server/home/OpenWebUI/.env.example | 4 + server/home/OpenWebUI/compose.yml | 146 ++++ .../home/OpenWebUI/podman-openwebui.service | 18 + server/home/Plausible/.env.example | 8 + server/home/Plausible/UPSTREAM | 6 + server/home/Plausible/compose.yml | 131 +++ .../home/Plausible/podman-plausible.service | 18 + server/home/PostHog/.env.example | 29 + server/home/PostHog/README.md | 391 +++++++++ server/home/PostHog/UPSTREAM | 6 + .../home/PostHog/bin/apply-local-patches.py | 57 ++ server/home/PostHog/bin/upgrade | 48 ++ server/home/PostHog/compose.override.yml | 99 +++ server/home/PostHog/compose.yml | 767 ++++++++++++++++++ server/home/PostHog/compose/local-patches.py | 72 ++ server/home/PostHog/compose/start | 5 + .../PostHog/compose/temporal-django-worker | 3 + server/home/PostHog/compose/wait | 19 + server/home/PostHog/compose/worker | 3 + server/home/PostHog/podman-posthog.service | 18 + server/home/Romm/.env.example | 10 + server/home/Romm/compose.yml | 66 ++ server/home/Romm/podman-romm.service | 18 + server/home/Scrutiny/compose.yml | 59 ++ server/home/Scrutiny/podman-scrutiny.service | 18 + server/home/Sentry/.env.example | 24 + server/home/Sentry/UPSTREAM | 6 + server/home/Sentry/podman-sentry.service | 18 + server/home/Sentry/sentry/config.yml | 150 ++++ server/home/Sentry/sentry/sentry.conf.py | 572 +++++++++++++ server/home/Slides/infisical/compose.yml | 23 + .../infisical/podman-slides-infisical.service | 18 + server/home/Watchtower/compose.yml | 22 + .../home/Watchtower/podman-watchtower.service | 18 + server/home/Whisper/compose.yml | 70 ++ server/home/Whisper/podman-whisper.service | 18 + server/home/nextcloud/compose.yml | 54 ++ .../home/nextcloud/podman-nextcloud.service | 18 + .../{containers => vps}/Adminer/compose.yml | 0 .../Adminer/podman-adminer.service | 0 .../{containers => vps}/Agentchat/compose.yml | 0 .../Agentchat/podman-agentchat.service | 0 .../Authentik/.env.example | 5 + .../{containers => vps}/Authentik/compose.yml | 0 .../Authentik/podman-authentik.service | 1 - server/{containers => vps}/Beszel/compose.yml | 5 + .../Beszel/podman-beszel.service | 1 - server/vps/BillTracker/.env.example | 14 + server/vps/BillTracker/compose.yml | 69 ++ .../BillTracker/podman-bill-tracker.service | 21 + .../{containers => vps}/CalCom/.env.example | 0 server/{containers => vps}/CalCom/compose.yml | 4 +- .../CalCom/podman-calcom.service | 0 .../ConvexMonorepo/.env.example | 5 + .../ConvexMonorepo/compose.yml | 0 .../podman-convexmonorepo.service | 0 .../Documenso/.env.example | 0 .../{containers => vps}/Documenso/compose.yml | 4 +- .../Documenso/podman-documenso.service | 0 server/vps/Gbrown_Agent/.env.example | 1 + server/vps/Gbrown_Agent/compose.yml | 43 + .../Gbrown_Agent/podman-gbrown-agent.service | 27 + server/vps/Gitea/.env.example | 1 + server/vps/Gitea/compose.yml | 41 + .../Gitea/podman-gitea.service | 0 .../ISponsorBlockTV/compose.yml | 0 .../podman-isponsorblocktv.service | 1 - .../Infisical/.env.example | 3 + .../{containers => vps}/Infisical/compose.yml | 0 .../Infisical/podman-infisical.service | 1 - .../LashAddict/.env.example | 8 + .../LashAddict/compose.yml | 16 + .../LashAddict/podman-lashaddict.service | 0 server/{containers => vps}/N8n/.env.example | 0 server/{containers => vps}/N8n/compose.yml | 0 .../N8n/podman-n8n.service | 1 - .../Nginx_Proxy_Manager/.env.example | 0 .../Nginx_Proxy_Manager/compose.yml | 1 - .../podman-nginx-proxy-manager.service | 1 - server/vps/Penpot/.env.example | 7 + server/vps/Penpot/compose.yml | 183 +++++ .../Penpot/podman-penpot.service | 0 .../{containers => vps}/Portainer/compose.yml | 0 .../Portainer/podman-portainer.service | 1 - .../PostgreSQL/.env.example | 0 .../PostgreSQL/compose.yml | 0 .../PostgreSQL/podman-postgresql.service | 1 - server/vps/Rustdesk/.env.example | 4 + .../{containers => vps}/Rustdesk/compose.yml | 2 +- .../Rustdesk/podman-rustdesk.service | 1 - server/{containers => vps}/Spoon/.env.example | 3 + server/{containers => vps}/Spoon/compose.yml | 0 .../Spoon/podman-spoon.service | 0 .../{containers => vps}/StPeteIT/.env.example | 5 + .../{containers => vps}/StPeteIT/compose.yml | 0 .../StPeteIT/podman-stpeteit.service | 0 server/{containers => vps}/Tools/compose.yml | 50 +- .../Tools/podman-tools.service | 1 - server/{containers => vps}/Uptime/compose.yml | 0 .../Uptime/podman-uptime.service | 1 - .../{containers => vps}/UseSend/.env.example | 4 + server/vps/UseSend/compose.yml | 131 +++ .../UseSend/podman-usesend.service | 0 server/vps/Vaultwarden/.env.example | 6 + server/vps/Vaultwarden/compose.yml | 77 ++ .../Vaultwarden/podman-vaultwarden.service | 1 - tests/server/compose-secrets-contract | 14 +- tests/server/containers-shape-contract | 4 +- 146 files changed, 5258 insertions(+), 354 deletions(-) delete mode 100644 server/containers/Gitea/compose.yml delete mode 100644 server/containers/Penpot/.env.example delete mode 100644 server/containers/Penpot/compose.yml delete mode 100644 server/containers/Rustdesk/.env.example delete mode 100644 server/containers/UseSend/compose.yml delete mode 100644 server/containers/Vaultwarden/.env.example delete mode 100644 server/containers/Vaultwarden/compose.yml create mode 100644 server/home/Agents/Beszel/compose.yml create mode 100644 server/home/Agents/Beszel/podman-agents-beszel.service create mode 100644 server/home/Agents/Gitea/.env.example create mode 100644 server/home/Agents/Gitea/compose.yml create mode 100644 server/home/Agents/Gitea/podman-agents-gitea.service create mode 100644 server/home/Agents/Portainer/compose.yml create mode 100644 server/home/Agents/Portainer/podman-agents-portainer.service create mode 100644 server/home/Frigate/.env.example create mode 100644 server/home/Frigate/compose.yml create mode 100644 server/home/Frigate/podman-frigate.service create mode 100644 server/home/Gbrown/.env.example create mode 100644 server/home/Gbrown/compose.yml create mode 100644 server/home/Gbrown/podman-gbrown.service create mode 100644 server/home/Home/compose.yml create mode 100644 server/home/Home/podman-home.service create mode 100644 server/home/Immich/.env.example create mode 100644 server/home/Immich/compose.yml create mode 100644 server/home/Immich/podman-immich.service create mode 100644 server/home/Media/.env.example create mode 100644 server/home/Media/compose.yml create mode 100644 server/home/Media/podman-media.service create mode 100644 server/home/Minecraft/Bedrock/Adrian/compose.yml create mode 100644 server/home/Minecraft/Bedrock/Adrian/podman-minecraft-bedrock-adrian.service create mode 100644 server/home/Minecraft/Java/compose.yml create mode 100644 server/home/Minecraft/Java/podman-minecraft-java.service create mode 100644 server/home/Nginx/.env.example create mode 100644 server/home/Nginx/compose.yml create mode 100644 server/home/Nginx/podman-nginx.service create mode 100644 server/home/OpenWebUI/.env.example create mode 100644 server/home/OpenWebUI/compose.yml create mode 100644 server/home/OpenWebUI/podman-openwebui.service create mode 100644 server/home/Plausible/.env.example create mode 100644 server/home/Plausible/UPSTREAM create mode 100644 server/home/Plausible/compose.yml create mode 100644 server/home/Plausible/podman-plausible.service create mode 100644 server/home/PostHog/.env.example create mode 100644 server/home/PostHog/README.md create mode 100644 server/home/PostHog/UPSTREAM create mode 100755 server/home/PostHog/bin/apply-local-patches.py create mode 100755 server/home/PostHog/bin/upgrade create mode 100644 server/home/PostHog/compose.override.yml create mode 100644 server/home/PostHog/compose.yml create mode 100644 server/home/PostHog/compose/local-patches.py create mode 100755 server/home/PostHog/compose/start create mode 100755 server/home/PostHog/compose/temporal-django-worker create mode 100755 server/home/PostHog/compose/wait create mode 100755 server/home/PostHog/compose/worker create mode 100644 server/home/PostHog/podman-posthog.service create mode 100644 server/home/Romm/.env.example create mode 100644 server/home/Romm/compose.yml create mode 100644 server/home/Romm/podman-romm.service create mode 100644 server/home/Scrutiny/compose.yml create mode 100644 server/home/Scrutiny/podman-scrutiny.service create mode 100644 server/home/Sentry/.env.example create mode 100644 server/home/Sentry/UPSTREAM create mode 100644 server/home/Sentry/podman-sentry.service create mode 100644 server/home/Sentry/sentry/config.yml create mode 100644 server/home/Sentry/sentry/sentry.conf.py create mode 100644 server/home/Slides/infisical/compose.yml create mode 100644 server/home/Slides/infisical/podman-slides-infisical.service create mode 100644 server/home/Watchtower/compose.yml create mode 100644 server/home/Watchtower/podman-watchtower.service create mode 100644 server/home/Whisper/compose.yml create mode 100644 server/home/Whisper/podman-whisper.service create mode 100644 server/home/nextcloud/compose.yml create mode 100644 server/home/nextcloud/podman-nextcloud.service rename server/{containers => vps}/Adminer/compose.yml (100%) rename server/{containers => vps}/Adminer/podman-adminer.service (100%) rename server/{containers => vps}/Agentchat/compose.yml (100%) rename server/{containers => vps}/Agentchat/podman-agentchat.service (100%) rename server/{containers => vps}/Authentik/.env.example (87%) rename server/{containers => vps}/Authentik/compose.yml (100%) rename server/{containers => vps}/Authentik/podman-authentik.service (93%) rename server/{containers => vps}/Beszel/compose.yml (88%) rename server/{containers => vps}/Beszel/podman-beszel.service (92%) create mode 100644 server/vps/BillTracker/.env.example create mode 100644 server/vps/BillTracker/compose.yml create mode 100644 server/vps/BillTracker/podman-bill-tracker.service rename server/{containers => vps}/CalCom/.env.example (100%) rename server/{containers => vps}/CalCom/compose.yml (94%) rename server/{containers => vps}/CalCom/podman-calcom.service (100%) rename server/{containers => vps}/ConvexMonorepo/.env.example (96%) rename server/{containers => vps}/ConvexMonorepo/compose.yml (100%) rename server/{containers => vps}/ConvexMonorepo/podman-convexmonorepo.service (100%) rename server/{containers => vps}/Documenso/.env.example (100%) rename server/{containers => vps}/Documenso/compose.yml (94%) rename server/{containers => vps}/Documenso/podman-documenso.service (100%) create mode 100644 server/vps/Gbrown_Agent/.env.example create mode 100644 server/vps/Gbrown_Agent/compose.yml create mode 100644 server/vps/Gbrown_Agent/podman-gbrown-agent.service create mode 100644 server/vps/Gitea/.env.example create mode 100644 server/vps/Gitea/compose.yml rename server/{containers => vps}/Gitea/podman-gitea.service (100%) rename server/{containers => vps}/ISponsorBlockTV/compose.yml (100%) rename server/{containers => vps}/ISponsorBlockTV/podman-isponsorblocktv.service (92%) rename server/{containers => vps}/Infisical/.env.example (98%) rename server/{containers => vps}/Infisical/compose.yml (100%) rename server/{containers => vps}/Infisical/podman-infisical.service (93%) rename server/{containers => vps}/LashAddict/.env.example (92%) rename server/{containers => vps}/LashAddict/compose.yml (82%) rename server/{containers => vps}/LashAddict/podman-lashaddict.service (100%) rename server/{containers => vps}/N8n/.env.example (100%) rename server/{containers => vps}/N8n/compose.yml (100%) rename server/{containers => vps}/N8n/podman-n8n.service (92%) rename server/{containers => vps}/Nginx_Proxy_Manager/.env.example (100%) rename server/{containers => vps}/Nginx_Proxy_Manager/compose.yml (96%) rename server/{containers => vps}/Nginx_Proxy_Manager/podman-nginx-proxy-manager.service (93%) create mode 100644 server/vps/Penpot/.env.example create mode 100644 server/vps/Penpot/compose.yml rename server/{containers => vps}/Penpot/podman-penpot.service (100%) rename server/{containers => vps}/Portainer/compose.yml (100%) rename server/{containers => vps}/Portainer/podman-portainer.service (92%) rename server/{containers => vps}/PostgreSQL/.env.example (100%) rename server/{containers => vps}/PostgreSQL/compose.yml (100%) rename server/{containers => vps}/PostgreSQL/podman-postgresql.service (92%) create mode 100644 server/vps/Rustdesk/.env.example rename server/{containers => vps}/Rustdesk/compose.yml (98%) rename server/{containers => vps}/Rustdesk/podman-rustdesk.service (92%) rename server/{containers => vps}/Spoon/.env.example (98%) rename server/{containers => vps}/Spoon/compose.yml (100%) rename server/{containers => vps}/Spoon/podman-spoon.service (100%) rename server/{containers => vps}/StPeteIT/.env.example (95%) rename server/{containers => vps}/StPeteIT/compose.yml (100%) rename server/{containers => vps}/StPeteIT/podman-stpeteit.service (100%) rename server/{containers => vps}/Tools/compose.yml (66%) rename server/{containers => vps}/Tools/podman-tools.service (92%) rename server/{containers => vps}/Uptime/compose.yml (100%) rename server/{containers => vps}/Uptime/podman-uptime.service (92%) rename server/{containers => vps}/UseSend/.env.example (87%) create mode 100644 server/vps/UseSend/compose.yml rename server/{containers => vps}/UseSend/podman-usesend.service (100%) create mode 100644 server/vps/Vaultwarden/.env.example create mode 100644 server/vps/Vaultwarden/compose.yml rename server/{containers => vps}/Vaultwarden/podman-vaultwarden.service (92%) 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"); }