Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffad7e56ea | ||
|
|
34bc1663ed | ||
|
|
f4fca1797f | ||
|
|
913a57f8a6 | ||
|
|
a664cf68f5 | ||
|
|
a493fa5584 | ||
|
|
eb0056ac94 | ||
|
|
58a64d6cee | ||
|
|
933c197060 | ||
|
|
de3591a25b | ||
|
|
88032490c0 | ||
|
|
fd24b69039 | ||
|
|
dfc009e422 | ||
|
|
1843c0495a | ||
|
|
43ae5bb53b | ||
|
|
05054afdc9 | ||
|
|
9022847971 | ||
|
|
9b06e9e24b | ||
|
|
59da2bb2fc | ||
|
|
5ebecd59bb | ||
|
|
501f7306ed | ||
|
|
130137485e | ||
|
|
5ebebc8aa2 | ||
|
|
85160ffef2 | ||
|
|
59192143c5 | ||
|
|
2f2e3b88e8 | ||
|
|
9156a7e05c | ||
|
|
39a2e8e9ca | ||
|
|
6206565d95 | ||
|
|
cceb7a707b | ||
|
|
c1bbc69c8a | ||
|
|
fa8b14e05e | ||
|
|
b5832fc94a | ||
|
|
99156442b5 | ||
|
|
4c27203214 | ||
|
|
c71d6c8799 | ||
|
|
cb305f6662 | ||
|
|
1ee42f2cb6 | ||
|
|
1ae3825fda |
No files matched your search
@@ -9,7 +9,7 @@ You are editing a running desktop, not a codebase that gets deployed later. Ever
|
||||
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
|
||||
drives every rule below.
|
||||
|
||||
Read before large work, in this order:
|
||||
Before large work, read these in order:
|
||||
|
||||
- `README.md` — layout, the `panama` command, how installing and updating work
|
||||
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
|
||||
@@ -62,7 +62,8 @@ Every executable contract under `tests/` is classified in `tests/contracts.manif
|
||||
`PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr.
|
||||
Successful stdout stays quiet. Successful stderr is surfaced as a warning.
|
||||
- Contracts run directly too: `tests/setup/interview-contract`.
|
||||
- After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate:
|
||||
- After changing `PreferenceSchema.qml` or
|
||||
`config/dot/quickshell/services/SettingsRoutes.qml`, regenerate:
|
||||
`config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and
|
||||
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
|
||||
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
|
||||
@@ -183,8 +183,8 @@ currently parked (dir present, unit not active) — imported anyway, noted as
|
||||
parked in the catalog.
|
||||
|
||||
**Excluded:** Watchtower (retired — replaced by the update timer after it took
|
||||
gitea down; its dir stays on junior until cleanup), Completeuphoria,
|
||||
sierraandtyler, Bang (source checkouts deployed by CI from their own repos —
|
||||
gitea down; its dir stays on junior until cleanup), the app source
|
||||
checkouts such as sierraandtyler and Bang (deployed by CI from their own repos —
|
||||
Panama owning a second copy of their compose would drift), `ci/`, `logs/`,
|
||||
and junior's loose scripts other than `update-containers.sh` (§4).
|
||||
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/panama
|
||||
+11
-5
@@ -3,15 +3,21 @@
|
||||
/config/bash/.env.lock
|
||||
# Server secrets and state never enter the repo: live .env files belong in
|
||||
# ~/Server/<Name>/ (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
|
||||
/config/old
|
||||
# MCP bearer tokens. The server list in user/agents/mcp/servers is tracked
|
||||
# and names which variable carries each token; the tokens themselves are
|
||||
# machine-local, because this repo is public.
|
||||
/user/agents/mcp/env
|
||||
# Ignore Wireguard config of course!
|
||||
/config/wg/**
|
||||
# Ignore Neovim lazy-lock file
|
||||
|
||||
@@ -102,13 +102,13 @@ in order, without stopping again:
|
||||
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
|
||||
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
|
||||
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
|
||||
| `link-skills` | Links the agent skills in `skills/` into `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
|
||||
| `link-skills` | Links the agent skills in `skills/` into `~/.agents/skills` and `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
|
||||
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
|
||||
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
||||
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
||||
| `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for |
|
||||
| `install-hardware` | NVIDIA, Secure Boot enrollment, Fedora's extras, firmware — each only if it was asked for. Last, because enrollment and firmware are consumed at the next boot |
|
||||
| `setup-server` | Server role only: linger, unprivileged ports from 80, firewalld (80/443/81), the `nginx-bridge` network, and the nightly image-update timer |
|
||||
| `setup-server` | Server role only: linger, unprivileged ports from 80, firewalld (80/443/81, opened only where nothing already grants them), the `nginx-bridge` network, and the nightly image-update timer |
|
||||
| `link-server` | Server role only: creates `~/Server` and refreshes the links of whatever services this machine has enabled |
|
||||
|
||||
A server runs the shared stages plus its own two, and skips the rest —
|
||||
@@ -235,10 +235,13 @@ server/ The server role: compose services (one directory per
|
||||
service), the nightly image updater, and its units. See
|
||||
server/README.md
|
||||
skills/ Agent skills for operating this desktop, linked into
|
||||
~/.claude/skills
|
||||
~/.agents/skills and ~/.claude/skills
|
||||
setup/
|
||||
apps/ Applications built from source, one file each
|
||||
lib/ Shared by more than one stage; the extras catalog reader
|
||||
keys/ Pinned signing keys, for publishers that ship no fetchable
|
||||
one; setup/keys/README.md records where each came from
|
||||
lib/ Shared by more than one stage; the extras catalog reader,
|
||||
the machine role, the verified ChatGPT repository
|
||||
packages/ One package per line; extras/ holds the optional categories
|
||||
scripts/ Run in order by ./install
|
||||
tests/ Contracts. See below
|
||||
@@ -247,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work
|
||||
|
||||
## Tests
|
||||
|
||||
188 of them, under `tests/`. `tests/contracts.manifest` classifies every
|
||||
192 of them, under `tests/`. `tests/contracts.manifest` classifies every
|
||||
contract by the capabilities it needs. Run the hermetic set, or grant a
|
||||
specific external capability when automation needs it:
|
||||
|
||||
|
||||
+8
-2
@@ -134,7 +134,6 @@ ${BOLD}Examples:${RESET}
|
||||
$PROGRAM upgrade
|
||||
$PROGRAM apps
|
||||
$PROGRAM app
|
||||
$PROGRAM app chatgpt-desktop
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -451,11 +450,16 @@ CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network p
|
||||
|
||||
contract_paths() {
|
||||
local candidate
|
||||
# The manifest is kept in byte order, so both the discovery sort and the
|
||||
# comparison below have to be byte order too. A UTF-8 collation folds the
|
||||
# punctuation away -- `calendar_agenda_bridge_test.py` sorts before
|
||||
# `calendar-agenda-helper-contract` under en_US and after it under C -- and
|
||||
# a gate that passes or fails on the machine's LANG is not a gate.
|
||||
while IFS= read -r candidate; do
|
||||
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
|
||||
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
|
||||
done < <(find "$PANAMA_DIR/tests" -type f \
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
|
||||
}
|
||||
|
||||
contract_manifest_entries() {
|
||||
@@ -478,6 +482,8 @@ validate_contract_manifest() {
|
||||
require_contract_manifest || return 1
|
||||
|
||||
local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST"
|
||||
# Byte order, for the same reason contract_paths sorts in it.
|
||||
local LC_ALL=C
|
||||
local line capabilities path extra previous_comment="" previous_was_comment=0
|
||||
local previous_path="" capability discovered
|
||||
local -a capability_list=() findings=()
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# panama-server – the compose services a server machine runs.
|
||||
#
|
||||
# The repository carries the catalog: server/containers/<Name>/ holds a
|
||||
# The repository carries the catalog: server/vps/<Name>/ holds a
|
||||
# compose.yml, a podman-<name>.service user unit, and an .env.example naming
|
||||
# what the service needs told. The machine carries the rest: ~/Server/<Name>/
|
||||
# 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 <<EOF
|
||||
${BOLD}$PROGRAM${RESET} – manage the compose services in server/containers/
|
||||
${BOLD}$PROGRAM${RESET} – manage the compose services in server/vps/
|
||||
|
||||
${BOLD}Usage:${RESET}
|
||||
$PROGRAM list Every service in the catalog, and its state here
|
||||
|
||||
@@ -45,65 +45,6 @@ checkout_command() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Git's index hints are performance promises, not trust evidence. In
|
||||
# particular, assume-unchanged and skip-worktree can make porcelain status
|
||||
# report a clean checkout whose files no longer match HEAD. Compare every
|
||||
# tracked blob and Git mode with the verified commit before handing control to
|
||||
# any file in the worktree.
|
||||
checkout_matches_verified_commit() (
|
||||
local checkout="$1" listing="" entry metadata mode type expected path actual
|
||||
local link_target_with_sentinel link_target
|
||||
|
||||
trap '[[ -z "$listing" ]] || rm -f -- "$listing"' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
listing="$(mktemp -u -t panama-boot-tree.XXXXXX)" || exit 1
|
||||
umask 077
|
||||
if ! (set -o noclobber; : >"$listing") 2>/dev/null; then
|
||||
listing=""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checkout_command git -C "$checkout" ls-tree -rz --full-tree \
|
||||
"$PANAMA_BOOT_REVISION" >"$listing" || exit 1
|
||||
while IFS= read -r -d '' entry; do
|
||||
[[ "$entry" == *$'\t'* ]] || exit 1
|
||||
metadata="${entry%%$'\t'*}"
|
||||
path="${entry#*$'\t'}"
|
||||
read -r mode type expected <<<"$metadata"
|
||||
[[ "$type" == blob && -n "$path" && "$path" != /* ]] || exit 1
|
||||
|
||||
case "$mode" in
|
||||
100644) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
|
||||
&& ! -x "$checkout/$path" ]] || exit 1 ;;
|
||||
100755) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
|
||||
&& -x "$checkout/$path" ]] || exit 1 ;;
|
||||
120000)
|
||||
[[ -L "$checkout/$path" ]] || exit 1
|
||||
# hash-object given a pathname follows a symlink. Git's 120000 blob is
|
||||
# the link text itself, including any trailing newlines, so preserve
|
||||
# those bytes with a sentinel and hash stdin instead.
|
||||
link_target_with_sentinel="$(
|
||||
readlink -n -- "$checkout/$path" && printf .
|
||||
)" || exit 1
|
||||
[[ "$link_target_with_sentinel" == *. ]] || exit 1
|
||||
link_target="${link_target_with_sentinel%.}"
|
||||
actual="$(
|
||||
printf '%s' "$link_target" \
|
||||
| checkout_command git -C "$checkout" hash-object --stdin
|
||||
)" || exit 1
|
||||
[[ "$actual" == "$expected" ]] || exit 1
|
||||
continue
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
|
||||
actual="$(checkout_command git -C "$checkout" hash-object --no-filters -- "$path")" \
|
||||
|| exit 1
|
||||
[[ "$actual" == "$expected" ]] || exit 1
|
||||
done <"$listing"
|
||||
)
|
||||
|
||||
prepare_panama_checkout() {
|
||||
local checkout="$1" actual_head checkout_status
|
||||
|
||||
@@ -169,30 +110,6 @@ for arg in "$@"; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Keep the worktree comparison at the last possible boundary. Checkout
|
||||
# preparation may invoke several commands and return to the caller; performing
|
||||
# the byte/mode/link check here ensures a change in that interval is rejected
|
||||
# before any tracked file is executed.
|
||||
verified_install_handoff() {
|
||||
local use_tty="$1"
|
||||
if ! checkout_matches_verified_commit "$PANAMA_PATH"; then
|
||||
echo "boot: checkout files do not match PANAMA_BOOT_REVISION" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -n "$BOOTSTRAP_USER" ]]; then
|
||||
if (( use_tty )); then
|
||||
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
|
||||
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
||||
fi
|
||||
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
|
||||
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
||||
fi
|
||||
if (( use_tty )); then
|
||||
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
||||
fi
|
||||
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
||||
}
|
||||
|
||||
# The public bootstrap contract runs this branch as an ordinary user with a
|
||||
# stubbed root identity. Keep its filesystem adapter unavailable to a real root
|
||||
# shell so it cannot redirect a real installation by accident.
|
||||
@@ -554,7 +471,7 @@ if [[ "$(id -u)" -eq 0 ]]; then
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "Installing git, which the clone needs"
|
||||
dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
|
||||
dnf install -y git
|
||||
fi
|
||||
|
||||
# Create or advance the checkout as the target user. A root-owned .git in a
|
||||
@@ -564,14 +481,15 @@ if [[ "$(id -u)" -eq 0 ]]; then
|
||||
prepare_panama_checkout "$PANAMA_PATH"
|
||||
|
||||
echo "Handing off to install as $username"
|
||||
verified_install_handoff 1
|
||||
exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \
|
||||
"$PANAMA_PATH/install" --server </dev/tty
|
||||
fi
|
||||
|
||||
# git is the one dependency the clone itself needs. Everything else -- gum
|
||||
# included -- is bootstrapped by `install`.
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "Installing git, which the clone needs"
|
||||
sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
|
||||
sudo dnf install -y git
|
||||
fi
|
||||
|
||||
prepare_panama_checkout "$PANAMA_PATH"
|
||||
@@ -582,8 +500,7 @@ prepare_panama_checkout "$PANAMA_PATH"
|
||||
# so itself.
|
||||
# The probe actually opens /dev/tty rather than testing -r: a process with no
|
||||
# controlling terminal passes -r and then fails the redirect.
|
||||
handoff_tty=0
|
||||
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
|
||||
handoff_tty=1
|
||||
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
||||
fi
|
||||
verified_install_handoff "$handoff_tty"
|
||||
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|
||||
+3
-2
@@ -22,6 +22,7 @@ alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mo
|
||||
alias nlc='nvim leetcode.nvim'
|
||||
alias clauded='claude --allow-dangerously-skip-permissions'
|
||||
alias claudedr='claude --allow-dangerously-skip-permissions --resume'
|
||||
alias klaude='CLAUDE_CONFIG_DIR="$HOME/.klaude" claude'
|
||||
alias commit-repo='git add -A && git commit -m "Update stuff" && git push'
|
||||
|
||||
# Docker Shortcuts
|
||||
@@ -36,9 +37,9 @@ alias fprint-on='sudo authselect enable-feature with-fingerprint'
|
||||
alias fprint-off='sudo authselect disable-feature with-fingerprint'
|
||||
|
||||
# File system
|
||||
alias ls='eza -lh --group-directories-first --icons'
|
||||
alias ls='eza -lh --group-directories-first --icons=auto'
|
||||
alias lsa='ls -a'
|
||||
alias lt='eza --tree --level=2 --long --icons --git'
|
||||
alias lt='eza --tree --level=2 --long --icons=auto --git'
|
||||
alias lta='lt -a'
|
||||
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
|
||||
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
|
||||
|
||||
+17
-2
@@ -16,8 +16,23 @@ unset PANAMA_ENV
|
||||
# Editor used by CLI
|
||||
export EDITOR="nvim"
|
||||
export SUDO_EDITOR="$EDITOR"
|
||||
export SSH_ASKPASS=/usr/bin/ksshaskpass
|
||||
export SSH_ASKPASS_REQUIRE=prefer
|
||||
# A graphical askpass, and only where there is a session to draw it in.
|
||||
# SSH_ASKPASS_REQUIRE=prefer means ssh and git reach for the dialog BEFORE the
|
||||
# terminal prompt, so on a headless machine the prompt that would have worked
|
||||
# is skipped in favour of one that cannot open: `git push` then fails reading
|
||||
# a username rather than asking for it. The desktop keeps the dialog; a
|
||||
# server, a serial console and a plain SSH session fall back to the terminal.
|
||||
if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ] && [ -x /usr/bin/ksshaskpass ]; then
|
||||
export SSH_ASKPASS=/usr/bin/ksshaskpass
|
||||
export SSH_ASKPASS_REQUIRE=prefer
|
||||
else
|
||||
# Cleared, not merely left unset. An askpass inherited from a parent that
|
||||
# did have a display -- an agent's shell, a forwarded environment, a login
|
||||
# that predates this guard -- is exactly as unusable here as one this file
|
||||
# set itself, and declining to add it does nothing about a value already in
|
||||
# the environment.
|
||||
unset SSH_ASKPASS SSH_ASKPASS_REQUIRE
|
||||
fi
|
||||
|
||||
# Define Paths
|
||||
export CARGO_PATH="$HOME/.cargo"
|
||||
|
||||
@@ -244,10 +244,20 @@ local shipped_mode = "4500x3000@60"
|
||||
local shipped_scale = 1.5
|
||||
local shipped_transform = 0
|
||||
|
||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
|
||||
-- HBR3, so this relies on DSC. If the display fails to light up or falls back to
|
||||
-- a lower mode, drop this to 8 first.
|
||||
local shipped_bitdepth = 10
|
||||
-- 8-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
|
||||
-- HBR3 and reliant on DSC, and this panel's link is marginal: every modeset
|
||||
-- retrains it and blanks the screen. 8bpc keeps headroom on the link.
|
||||
--
|
||||
-- Related: directScanoutPolicy is 0 in Panama settings (2026-09-13). With
|
||||
-- scanout on, a fullscreen game whose buffer depth differs from the desktop
|
||||
-- (games ship both 8- and 10-bit swapchains) makes Hyprland change the output
|
||||
-- format, and on amdgpu a format change is a full modeset. Compositing always
|
||||
-- keeps the format fixed, so the link never retrains mid-game.
|
||||
--
|
||||
-- vrrPolicy is also 0 there. VRR on this panel loses sync and blacks out
|
||||
-- (seen on GNOME in July 2026 and again here); a 60Hz panel gains little
|
||||
-- from it anyway.
|
||||
local shipped_bitdepth = 8
|
||||
|
||||
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
|
||||
local shipped_cm = "auto"
|
||||
|
||||
@@ -125,6 +125,16 @@ hl.window_rule({
|
||||
no_dim = true,
|
||||
})
|
||||
|
||||
-- Steam games never get true fullscreen. Everything Hyprland does only for a
|
||||
-- real fullscreen window (direct scanout, VRR, tearing, auto HDR) makes this
|
||||
-- panel's marginal DP link retrain and blank (2026-09-13). "1 2" keeps the
|
||||
-- window maximized internally while the game believes it is fullscreen, which
|
||||
-- is what borderless windowed looks like from the game's side.
|
||||
hl.window_rule({
|
||||
match = { class = "^steam_app_\\d+$" },
|
||||
fullscreen_state = "1 2",
|
||||
})
|
||||
|
||||
-- Steam itself is a normal window, but its transient popups are a mess.
|
||||
hl.window_rule({
|
||||
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
|
||||
@@ -269,16 +279,28 @@ hl.layer_rule({
|
||||
ignore_alpha = 0.2,
|
||||
})
|
||||
|
||||
-- Overview, capture and local screen-reading UI dim the desktop behind them.
|
||||
-- Overview and local screen-reading UI dim the desktop behind them.
|
||||
hl.layer_rule({
|
||||
name = "qs-overlay",
|
||||
match = { namespace = "^qs-(overview|capture|screen-intelligence)$" },
|
||||
match = { namespace = "^qs-(overview|screen-intelligence)$" },
|
||||
blur = true,
|
||||
ignore_alpha = 0.4,
|
||||
dim_around = true,
|
||||
no_screen_share = true,
|
||||
})
|
||||
|
||||
-- The screenshot picker paints an opaque frozen frame edge to edge, so blur and
|
||||
-- dim would be compositor work on pixels nobody sees. no_anim is load-bearing:
|
||||
-- services/Capture.qml unmaps the picker and runs grim 90ms later, and the
|
||||
-- 200ms layersOut fade would otherwise put the half-faded picker, dimming and
|
||||
-- all, into every screenshot.
|
||||
hl.layer_rule({
|
||||
name = "qs-capture",
|
||||
match = { namespace = "^qs-capture$" },
|
||||
no_anim = true,
|
||||
no_screen_share = true,
|
||||
})
|
||||
|
||||
-- Notification toasts. Blurred like every other shell surface -- without this
|
||||
-- the cards are a near-transparent fill sitting directly on the wallpaper and
|
||||
-- read as washed out rather than as glass.
|
||||
|
||||
@@ -1805,7 +1805,7 @@ Singleton {
|
||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
|
||||
"codex-desktop", "md.obsidian.Obsidian",
|
||||
"chatgpt", "md.obsidian.Obsidian",
|
||||
"com.obsproject.Studio", "steam"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -27,9 +27,13 @@ PanelWindow {
|
||||
right: true
|
||||
}
|
||||
|
||||
// Fullscreen overlays must not reserve space, or every window on the
|
||||
// workspace gets resized as the picker opens and closes.
|
||||
exclusiveZone: 0
|
||||
// Ignore, and nothing else: reserve no space (or every window on the
|
||||
// workspace resizes as the picker opens and closes) and respect nobody's
|
||||
// (or the window starts under the bar, the full-output freeze frame is
|
||||
// squeezed into a shorter box, and every selection lands one bar-height
|
||||
// off in the real capture). Never pair this with exclusiveZone; see
|
||||
// tests/quickshell/exclusion-idiom-contract.
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
|
||||
@@ -57,7 +57,6 @@ PanelWindow {
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
|
||||
|
||||
@@ -55,7 +55,6 @@ PanelWindow {
|
||||
color: "transparent"
|
||||
|
||||
// A dock that reserved space would not be intellihiding.
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
|
||||
|
||||
@@ -40,7 +40,6 @@ PanelWindow {
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
|
||||
|
||||
@@ -44,7 +44,6 @@ PanelWindow {
|
||||
// Reserve nothing and respect nothing: the glow is drawn over the whole
|
||||
// output including under the bar and the dock, which is what makes it
|
||||
// visible from wherever the eyes happen to be.
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ PanelWindow {
|
||||
screen: root.modelData
|
||||
anchors.bottom: true
|
||||
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
implicitWidth: root.desiredWidth
|
||||
implicitHeight: 64
|
||||
|
||||
@@ -22,7 +22,6 @@ PanelWindow {
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
|
||||
|
||||
@@ -34,11 +34,23 @@ Item {
|
||||
visible: root.rowCount > 0
|
||||
implicitHeight: rows.implicitHeight
|
||||
|
||||
// Probing I2C takes on the order of a second, so it waits until the panel
|
||||
// is actually on screen rather than running at shell startup. Monitors do
|
||||
// not come and go, so once is enough.
|
||||
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
||||
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
||||
// Probing I2C takes on the order of a second, so it waits until the quick
|
||||
// settings panel is actually on screen rather than running at shell
|
||||
// startup. Monitors do not come and go, so once is enough.
|
||||
//
|
||||
// The trigger is the panel opening, not this item becoming visible: on a
|
||||
// machine with no backlight, `visible` stays false until a scan has found
|
||||
// a DDC display, so keying the scan off it would never scan at all.
|
||||
Connections {
|
||||
target: ShellState
|
||||
function onQuickSettingsOpenChanged(): void { root.scanIfOpen(); }
|
||||
}
|
||||
Component.onCompleted: root.scanIfOpen()
|
||||
|
||||
function scanIfOpen(): void {
|
||||
if (ShellState.quickSettingsOpen && !Brightness.scanned)
|
||||
Brightness.refresh();
|
||||
}
|
||||
|
||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||
Process {
|
||||
|
||||
@@ -22,7 +22,6 @@ Variants {
|
||||
implicitWidth: 260
|
||||
implicitHeight: 172
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
mask: Region {}
|
||||
|
||||
|
||||
@@ -383,6 +383,13 @@ Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits
|
||||
0 on syntax and runtime errors too. The only trustworthy signal that a write
|
||||
landed is reading the value back.
|
||||
|
||||
**`exclusionMode: ExclusionMode.Ignore` goes alone.** Quickshell's `exclusiveZone`
|
||||
setter flips `exclusionMode` back to `Normal` as a side effect, so a window that
|
||||
declares both is at the mercy of which property the QML engine applies last.
|
||||
The 2026-09-14 Qt update changed that order and every full-screen overlay slid
|
||||
under the bar. `tests/quickshell/exclusion-idiom-contract` fails any file that
|
||||
pairs them.
|
||||
|
||||
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
|
||||
and it ranges from a half-screen split to the full display. `SliderRow` stacks
|
||||
its control under the label below 520px. Test narrow.
|
||||
|
||||
@@ -78,7 +78,6 @@ PanelWindow {
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-welcome"
|
||||
|
||||
@@ -341,7 +341,10 @@ esac'
|
||||
property bool record: false
|
||||
property bool intelligence: false
|
||||
// A handful of frames at 60Hz, enough for the compositor to recomposite
|
||||
// the output without the overlay on it.
|
||||
// the output without the overlay on it. Only enough because the
|
||||
// qs-capture layer rule in hypr/rules.lua sets no_anim: with the
|
||||
// default 200ms layersOut fade the half-faded picker would still be
|
||||
// on screen when grim reads it.
|
||||
interval: 90
|
||||
onTriggered: {
|
||||
const r = commitDelay.rect;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env zsh
|
||||
# The Mac mini's shell. macOS stays on zsh: its bash is 3.2, and
|
||||
# config/bash/shell leans on Linux-only tools (stat -c, ksshaskpass, the nvm
|
||||
# profile script), so this is a small zsh counterpart rather than a port.
|
||||
# Linked to ~/.zshrc. Homebrew's PATH comes from ~/.zprofile (brew shellenv).
|
||||
export PANAMA_PATH="$HOME/.local/share/Panama"
|
||||
|
||||
export EDITOR="nvim"
|
||||
export SUDO_EDITOR="$EDITOR"
|
||||
|
||||
export BUN_INSTALL="$HOME/.bun"
|
||||
export PATH="$HOME/.local/bin:$BUN_INSTALL/bin:$PANAMA_PATH/bin:$PATH"
|
||||
|
||||
# History shared across sessions, so tmux panes see each other's commands.
|
||||
HISTFILE="$HOME/.zsh_history"
|
||||
HISTSIZE=50000
|
||||
SAVEHIST=50000
|
||||
setopt SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE
|
||||
|
||||
# Emacs keys, like bash's readline: ctrl-a/e/r work as they do on Linux.
|
||||
bindkey -e
|
||||
|
||||
autoload -Uz compinit && compinit
|
||||
[ -s "$BUN_INSTALL/_bun" ] && source "$BUN_INSTALL/_bun"
|
||||
|
||||
# Same SSH behaviour as config/bash/shell: an SSH login lands in a tmux
|
||||
# session that survives a dropped connection. PANAMA_SSH_TMUX=off opts out.
|
||||
if [[ -n "$SSH_CONNECTION" && -z "$TMUX" && -o interactive &&
|
||||
"${PANAMA_SSH_TMUX:-on}" != "off" ]] && command -v tmux >/dev/null 2>&1; then
|
||||
exec tmux new-session -A -s main
|
||||
fi
|
||||
|
||||
alias c="clear"
|
||||
alias n="nvim"
|
||||
alias n.="nvim ."
|
||||
alias ..="cd .."
|
||||
alias ...="cd ../.."
|
||||
alias ....="cd ../../.."
|
||||
alias letscode="ranger ~/Documents/Documents/Code"
|
||||
alias commit-repo='git add -A && git commit -m "Update stuff" && git push'
|
||||
alias ls='eza -lh --group-directories-first --icons=auto'
|
||||
alias lsa='ls -a'
|
||||
alias lt='eza --tree --level=2 --long --icons=auto --git'
|
||||
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
|
||||
compress() { tar -czf "${1%/}.tar.gz" "${1%/}"; }
|
||||
alias decompress="tar -xzf"
|
||||
|
||||
# Xcode cannot sign from an SSH session while the login keychain is locked
|
||||
# (errSecInternalComponent). Run this once per session before a build.
|
||||
alias unlock-keychain='security unlock-keychain ~/Library/Keychains/login.keychain-db'
|
||||
|
||||
eval "$(zoxide init zsh)"
|
||||
source <(fzf --zsh)
|
||||
eval "$(oh-my-posh init zsh --config "$PANAMA_PATH/config/dot/ohmyposh/gib.omp.json")"
|
||||
@@ -71,7 +71,7 @@ source "$PANAMA_PATH/bin/ascii"
|
||||
# time. On an upgrade it is worth running only when its package lists or
|
||||
# reviewed installer trust inputs changed, so this hashes them and remembers
|
||||
# the result. The framed, sorted stream includes top-level package files, the
|
||||
# package-stage adapter, the provenance helper, and regular provenance files;
|
||||
# package-stage adapter, every helper it sources, and regular provenance files;
|
||||
# both relative paths and bytes are part of the state.
|
||||
#
|
||||
# A content hash rather than a git range, because Panama is developed in place:
|
||||
@@ -88,75 +88,42 @@ source "$PANAMA_PATH/bin/ascii"
|
||||
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
|
||||
PACKAGES_HASH="$STATE_DIR/packages-hash"
|
||||
|
||||
_collect_package_inputs() {
|
||||
local destination="$1" raw="${1}.raw"
|
||||
[[ -d "$PANAMA_PATH/setup/packages" \
|
||||
&& ! -L "$PANAMA_PATH/setup/packages" \
|
||||
&& -d "$PANAMA_PATH/setup/provenance" \
|
||||
&& ! -L "$PANAMA_PATH/setup/provenance" ]] || return 1
|
||||
{
|
||||
printf '%s\0' \
|
||||
"$PANAMA_PATH/setup/scripts/install-packages" \
|
||||
"$PANAMA_PATH/setup/lib/artifact-provenance" \
|
||||
"$PANAMA_PATH/setup/lib/extras-catalog" \
|
||||
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
|
||||
# extras/ is deliberately excluded. A symlink or other non-directory
|
||||
# object at this level is still an input error, not something discovery may
|
||||
# silently omit.
|
||||
find "$PANAMA_PATH/setup/packages" -mindepth 1 -maxdepth 1 \
|
||||
! -type d -print0 || exit 1
|
||||
find "$PANAMA_PATH/setup/provenance" -mindepth 1 \
|
||||
! -type d -print0 || exit 1
|
||||
} >"$raw" || return 1
|
||||
LC_ALL=C sort -z "$raw" >"$destination"
|
||||
hash_packages() {
|
||||
local file relative size fixed_input digest
|
||||
|
||||
for fixed_input in \
|
||||
"$PANAMA_PATH/setup/scripts/install-packages" \
|
||||
"$PANAMA_PATH/setup/lib/artifact-provenance" \
|
||||
"$PANAMA_PATH/setup/lib/chatgpt-package" \
|
||||
"$PANAMA_PATH/setup/lib/extras-catalog" \
|
||||
"$PANAMA_PATH/setup/lib/machine-role"; do
|
||||
[[ -f "$fixed_input" && ! -L "$fixed_input" && -r "$fixed_input" ]] || return 1
|
||||
done
|
||||
|
||||
digest="$(
|
||||
{
|
||||
find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 || exit 1
|
||||
printf '%s\0' \
|
||||
"$PANAMA_PATH/setup/scripts/install-packages" \
|
||||
"$PANAMA_PATH/setup/lib/artifact-provenance" \
|
||||
"$PANAMA_PATH/setup/lib/chatgpt-package" \
|
||||
"$PANAMA_PATH/setup/lib/extras-catalog" \
|
||||
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
|
||||
find "$PANAMA_PATH/setup/provenance" -type f -print0 || exit 1
|
||||
} | LC_ALL=C sort -z | while IFS= read -r -d '' file; do
|
||||
relative="${file#"$PANAMA_PATH"/}"
|
||||
size="$(wc -c <"$file")" || exit 1
|
||||
printf '%s\0%s\0' "$relative" "$size" || exit 1
|
||||
cat -- "$file" || exit 1
|
||||
printf '\0' || exit 1
|
||||
done | sha256sum | cut -d' ' -f1
|
||||
)" || return 1
|
||||
printf '%s\n' "$digest"
|
||||
}
|
||||
|
||||
_write_package_manifest() {
|
||||
local inputs="$1" destination="$2" file relative digest
|
||||
: >"$destination" || return 1
|
||||
while IFS= read -r -d '' file; do
|
||||
[[ -f "$file" && ! -L "$file" && -r "$file" ]] || return 1
|
||||
relative="${file#"$PANAMA_PATH"/}"
|
||||
[[ "$relative" != "$file" ]] || return 1
|
||||
digest="$(sha256sum -- "$file" | awk '{ print $1 }')" || return 1
|
||||
[[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1
|
||||
printf '%s\0%s\0' "$relative" "$digest" >>"$destination" || return 1
|
||||
done <"$inputs"
|
||||
}
|
||||
|
||||
# Read every input twice from the same enumerated set. A file or path that
|
||||
# changes while the snapshot is built cannot produce a receipt.
|
||||
hash_packages() (
|
||||
local work="" inputs_before inputs_after manifest_before manifest_after
|
||||
trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
work="$(mktemp -u -d -t panama-packages-hash.XXXXXX)" || exit 1
|
||||
if ! mkdir -m 700 -- "$work"; then
|
||||
work=""
|
||||
exit 1
|
||||
fi
|
||||
inputs_before="$work/inputs-before"
|
||||
inputs_after="$work/inputs-after"
|
||||
manifest_before="$work/manifest-before"
|
||||
manifest_after="$work/manifest-after"
|
||||
|
||||
_collect_package_inputs "$inputs_before" || exit 1
|
||||
_write_package_manifest "$inputs_before" "$manifest_before" || exit 1
|
||||
_collect_package_inputs "$inputs_after" || exit 1
|
||||
cmp -s -- "$inputs_before" "$inputs_after" || exit 1
|
||||
_write_package_manifest "$inputs_after" "$manifest_after" || exit 1
|
||||
cmp -s -- "$manifest_before" "$manifest_after" || exit 1
|
||||
sha256sum -- "$manifest_before" | awk '{ print $1 }'
|
||||
)
|
||||
|
||||
PACKAGE_START_HASH=""
|
||||
|
||||
packages_needed() {
|
||||
local current_hash recorded_hash
|
||||
local current_hash="$1" recorded_hash
|
||||
|
||||
current_hash="$(hash_packages)" || return 2
|
||||
PACKAGE_START_HASH="$current_hash"
|
||||
(( FORCE_PACKAGES )) && return 0
|
||||
(( UPGRADE )) || return 0
|
||||
[[ -r "$PACKAGES_HASH" ]] || return 0
|
||||
@@ -167,41 +134,52 @@ packages_needed() {
|
||||
# Written only after the stage succeeds, mirroring the rule panama-migrate
|
||||
# documents for its markers: a step that did not complete has not happened, and
|
||||
# recording it as done hides it forever.
|
||||
record_packages_hash() (
|
||||
local expected_hash="$1" current_hash temporary_hash=""
|
||||
trap '[[ -z "$temporary_hash" ]] || rm -f -- "$temporary_hash"' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
[[ "$expected_hash" =~ ^[0-9a-f]{64}$ ]] || return 1
|
||||
record_packages_hash() {
|
||||
local starting_hash="$1" current_hash temporary_hash
|
||||
current_hash="$(hash_packages)" || return 1
|
||||
[[ "$current_hash" == "$expected_hash" ]] || return 1
|
||||
[[ "$current_hash" == "$starting_hash" ]] || return 1
|
||||
mkdir -p "$STATE_DIR"
|
||||
temporary_hash="$(mktemp -u "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
|
||||
umask 077
|
||||
if ! (set -o noclobber; : >"$temporary_hash") 2>/dev/null; then
|
||||
temporary_hash=""
|
||||
temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
|
||||
if printf '%s\n' "$starting_hash" >"$temporary_hash"; then
|
||||
mv -f -- "$temporary_hash" "$PACKAGES_HASH"
|
||||
else
|
||||
rm -f -- "$temporary_hash"
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$expected_hash" >"$temporary_hash" || return 1
|
||||
mv -f -- "$temporary_hash" "$PACKAGES_HASH" || return 1
|
||||
temporary_hash=""
|
||||
)
|
||||
}
|
||||
|
||||
# Repository trust is checked before the installer can reach its bootstrap DNF.
|
||||
# Status 78 is reserved for a trust-root failure and is propagated unchanged so
|
||||
# no later stage, especially install-hardware, can invoke DNF with that repo.
|
||||
# Status 78 is reserved for a trust-root failure. It suppresses every stage that
|
||||
# opens DNF -- install-hardware included, which would otherwise pull drivers
|
||||
# through the very repository in doubt -- and the run still exits 78 at the end.
|
||||
#
|
||||
# It suppresses nothing else. Linking dotfiles, skills and user content reads no
|
||||
# repository and installs no package, and a machine whose Terra is in question
|
||||
# still wants its configuration. Refusing the safe work because the unsafe work
|
||||
# is unavailable does not make the machine safer, it just leaves the machine
|
||||
# unconfigured with no way to fix itself. Exiting here instead meant link-skills
|
||||
# never ran on a machine whose Terra was merely unadopted, so ~/.claude/skills
|
||||
# stayed the whole-directory symlink it had been before skills were linked one
|
||||
# by one, and not one shipped skill was reachable.
|
||||
TERRA_TRUST_FAILURE_STATUS=78
|
||||
DNF_STAGES=(install-packages change-settings install-hardware)
|
||||
package_trust_refused=0
|
||||
|
||||
stage_opens_dnf() {
|
||||
local candidate="$1" dnf_stage
|
||||
for dnf_stage in "${DNF_STAGES[@]}"; do
|
||||
[[ "$candidate" == "$dnf_stage" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
trust_preflight="$PANAMA_PATH/setup/scripts/install-packages"
|
||||
if [[ ! -x "$trust_preflight" ]]; then
|
||||
printf 'install: package repository trust preflight is unavailable\n' >&2
|
||||
exit "$TERRA_TRUST_FAILURE_STATUS"
|
||||
fi
|
||||
if "$trust_preflight" --trust-preflight; then
|
||||
:
|
||||
else
|
||||
trust_status=$?
|
||||
package_trust_refused=1
|
||||
elif ! "$trust_preflight" --trust-preflight; then
|
||||
printf 'install: package repository trust preflight failed\n' >&2
|
||||
exit "$trust_status"
|
||||
package_trust_refused=1
|
||||
fi
|
||||
|
||||
# ── The interview ────────────────────────────────────────────────────────────
|
||||
@@ -221,7 +199,7 @@ fi
|
||||
# Gated exactly like the interview itself: under --upgrade no questions are
|
||||
# asked, so nothing here is used, and a machine that cannot install gum must
|
||||
# not have that stop an upgrade that never needed it.
|
||||
if (( ! UPGRADE )); then
|
||||
if (( ! UPGRADE && ! package_trust_refused )); then
|
||||
bootstrap=()
|
||||
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
|
||||
# The probe tools serve only the hardware questions, which a server is never
|
||||
@@ -236,8 +214,7 @@ if (( ! UPGRADE )); then
|
||||
fi
|
||||
if (( ${#bootstrap[@]} > 0 )); then
|
||||
echo "Installing what the setup questions are built on: ${bootstrap[*]}"
|
||||
sudo dnf install -y --repo=fedora --repo=updates \
|
||||
--from-repo=fedora,updates "${bootstrap[@]}" >/dev/null || {
|
||||
sudo dnf install -y "${bootstrap[@]}" >/dev/null || {
|
||||
echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2
|
||||
exit 1
|
||||
}
|
||||
@@ -300,12 +277,7 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
|
||||
# is unset and each stage takes the empty-answer path it already documents --
|
||||
# which is why this is a flag rather than a rewrite of seven stage scripts.
|
||||
if (( ! UPGRADE )); then
|
||||
PANAMA_ANSWERS="$(mktemp -u -t panama-answers.XXXXXX)" || exit 1
|
||||
umask 077
|
||||
if ! (set -o noclobber; : >"$PANAMA_ANSWERS") 2>/dev/null; then
|
||||
PANAMA_ANSWERS=""
|
||||
exit 1
|
||||
fi
|
||||
PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)"
|
||||
export PANAMA_ANSWERS
|
||||
|
||||
if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then
|
||||
@@ -371,7 +343,7 @@ fi
|
||||
if [[ "$PANAMA_ROLE" == server ]]; then
|
||||
STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity)
|
||||
else
|
||||
STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware)
|
||||
STAGES=(install-packages link-dotfiles link-skills link-user link-mcp change-settings link-vicinae-scripts setup-identity install-hardware)
|
||||
fi
|
||||
|
||||
# The two an upgrade drops. Both exist only to act on interview answers, and
|
||||
@@ -394,11 +366,16 @@ for stage in "${STAGES[@]}"; do
|
||||
script="$PANAMA_PATH/setup/scripts/$stage"
|
||||
[[ -x "$script" ]] || continue
|
||||
printf '\n=== %s ===\n' "$stage"
|
||||
if (( package_trust_refused )) && stage_opens_dnf "$stage"; then
|
||||
echo "Skipped: the package repository trust check refused package work."
|
||||
continue
|
||||
fi
|
||||
if [[ "$stage" == install-packages ]]; then
|
||||
package_start_hash=""
|
||||
package_state_status=0
|
||||
packages_needed || package_state_status=$?
|
||||
package_start_hash="$PACKAGE_START_HASH"
|
||||
package_start_hash="$(hash_packages)" || package_state_status=2
|
||||
if (( package_state_status == 0 )); then
|
||||
packages_needed "$package_start_hash" || package_state_status=$?
|
||||
fi
|
||||
if (( package_state_status == 1 )); then
|
||||
echo "The package lists have not changed since the last run; skipping."
|
||||
echo "Run with --packages to install them anyway."
|
||||
@@ -418,9 +395,12 @@ for stage in "${STAGES[@]}"; do
|
||||
fi
|
||||
else
|
||||
stage_status=$?
|
||||
# A configuration change between the preflight and this stage. Suppress the
|
||||
# remaining DNF stages, keep the safe ones, and carry the status to the end.
|
||||
if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then
|
||||
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
|
||||
exit "$stage_status"
|
||||
package_trust_refused=1
|
||||
continue
|
||||
fi
|
||||
failed+=("$stage")
|
||||
printf '!!! %s failed\n' "$stage" >&2
|
||||
@@ -446,8 +426,16 @@ done
|
||||
# written for -- and baselining would skip every one of them forever. Every
|
||||
# migration is self-guarding and a no-op where it does not apply, so running
|
||||
# them is the safe direction.
|
||||
#
|
||||
# Held back when package work was refused. A migration is free to run a DNF
|
||||
# transaction -- the ChatGPT package replacement does exactly that -- so the
|
||||
# repositories have to be trustworthy before any of them is allowed to run.
|
||||
# They are not marked applied either, so the next run still has them pending.
|
||||
migrate="$PANAMA_PATH/bin/panama-migrate"
|
||||
if [[ -x "$migrate" ]]; then
|
||||
if (( package_trust_refused )) && [[ -x "$migrate" ]]; then
|
||||
printf '\n=== migrations ===\n'
|
||||
echo "Skipped: the package repository trust check refused package work."
|
||||
elif [[ -x "$migrate" ]]; then
|
||||
printf '\n=== migrations ===\n'
|
||||
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
|
||||
"$migrate" run || failed+=(migrations)
|
||||
@@ -495,6 +483,17 @@ else
|
||||
retry='./install'
|
||||
fi
|
||||
|
||||
# Reported last and on its own, because it is not an ordinary stage failure:
|
||||
# everything safe did run, and what did not run is named rather than buried in a
|
||||
# list. The exit status stays 78 so a caller can still tell the two apart.
|
||||
if (( package_trust_refused )); then
|
||||
printf 'Package work was refused: the Terra repository configuration on this\n' >&2
|
||||
printf 'machine is not one Panama can verify. Skipped: %s\n' "${DNF_STAGES[*]}" >&2
|
||||
printf 'Everything that touches no repository was still applied.\n' >&2
|
||||
printf 'Inspect it with: panama diagnose\n' >&2
|
||||
exit "$TERRA_TRUST_FAILURE_STATUS"
|
||||
fi
|
||||
|
||||
if (( ${#failed[@]} == 0 )); then
|
||||
if (( UPGRADE )); then
|
||||
echo "Panama is up to date."
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# replace the community ChatGPT Desktop build with the official OpenAI package
|
||||
#
|
||||
# `panama app chatgpt-desktop` used to build a community wrapper (codex-desktop)
|
||||
# from the upstream macOS disk image, complete with a local rebuild daemon.
|
||||
# OpenAI ships an official Linux RPM now, and the installer takes that instead;
|
||||
# this repairs machines still carrying the community build. The official
|
||||
# package goes on before the community one comes off, so a failure part-way
|
||||
# leaves the machine with an app, never without one.
|
||||
#
|
||||
# Rules, because the runner cannot enforce them:
|
||||
#
|
||||
# * Safe to run twice. The marker records success, not intent.
|
||||
# * Tolerant of the repair already being correct -- the user may have fixed
|
||||
# it by hand, or a later ./install may have put it back.
|
||||
# * Root work goes through `panama-sudo --reason "..."`, never bare sudo,
|
||||
# so the password prompt names the repair.
|
||||
# * Exit non-zero to be retried at the next login. Exit zero only when the
|
||||
# machine is genuinely in the state this describes.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
|
||||
# Machines that never had the community build are already correct. The official
|
||||
# app is the installer's job, not this one's.
|
||||
rpm -q codex-desktop >/dev/null 2>&1 || exit 0
|
||||
|
||||
# The same verified repository the installer establishes: the pinned signing
|
||||
# key, then a repository that names it, so dnf checks OpenAI's signature before
|
||||
# root installs anything. See setup/lib/chatgpt-package.
|
||||
# shellcheck source=../setup/lib/chatgpt-package
|
||||
source "$PANAMA_PATH/setup/lib/chatgpt-package"
|
||||
|
||||
sudo_cmd=(sudo)
|
||||
if [[ -t 0 && -x "$PANAMA_PATH/bin/panama-sudo" ]]; then
|
||||
sudo_cmd=(
|
||||
"$PANAMA_PATH/bin/panama-sudo" --reason
|
||||
"Replacing the community-built ChatGPT Desktop (codex-desktop) with the official OpenAI package"
|
||||
--
|
||||
)
|
||||
fi
|
||||
|
||||
# The official package first, so the machine is never left without one.
|
||||
if ! rpm -q chatgpt >/dev/null 2>&1; then
|
||||
chatgpt_install_repository "${sudo_cmd[@]}"
|
||||
"${sudo_cmd[@]}" dnf install -y chatgpt
|
||||
fi
|
||||
|
||||
# The community package's updater is a user unit; stop it before dnf removes
|
||||
# the unit file out from under it. Removal also takes the app in /opt, both
|
||||
# binaries, and the polkit policy the local rebuilds needed.
|
||||
systemctl --user disable --now codex-update-manager.service 2>/dev/null || true
|
||||
"${sudo_cmd[@]}" dnf remove -y codex-desktop
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
|
||||
# The rebuild state the updater kept; the official package needs none of it.
|
||||
rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/codex-update-manager" \
|
||||
"${XDG_CACHE_HOME:-$HOME/.cache}/codex-runtimes" \
|
||||
"${XDG_CONFIG_HOME:-$HOME/.config}/codex-update-manager" \
|
||||
"${XDG_STATE_HOME:-$HOME/.local/state}/codex-update-manager"
|
||||
|
||||
echo "Replaced the community codex-desktop build with the official chatgpt package."
|
||||
+33
-6
@@ -6,13 +6,34 @@ screen, plus rootless podman compose services managed as systemd user units.
|
||||
|
||||
```
|
||||
server/
|
||||
containers/ One directory per service: compose.yml, the
|
||||
podman-<name>.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-<name>.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,12 +56,18 @@ 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
|
||||
the service stopped, not in git.
|
||||
- Only 80, 443 and 81 are open (setup-server's doing). Everything else is
|
||||
- Only 80, 443 and 81 are open, and `setup-server` opens each one only where
|
||||
nothing already grants it. A port reachable through an enabled service or an
|
||||
accepting rich rule — a CDN-scoped grant, say — is left exactly as it is,
|
||||
because turning a narrow grant into a public one during an unattended
|
||||
`panama update` is not a decision an installer gets to make. A machine that
|
||||
manages its own firewall opts out for good with
|
||||
`echo manual > ~/.local/state/panama/firewall`. Everything else is
|
||||
reverse-proxied over the `nginx-bridge` network by container name, with no
|
||||
published ports of its own. A service that truly needs another host port
|
||||
says so in a README in its directory, and the port is opened by hand.
|
||||
@@ -67,7 +94,7 @@ ones — and verify it came back up. One service at a time, quiet hours.
|
||||
|
||||
## Adding a service
|
||||
|
||||
Add `server/containers/<Name>/` with the three files, modelled on any
|
||||
Add `server/vps/<Name>/` with the three files, modelled on any
|
||||
existing service. The unit is the standard oneshot `podman compose up -d`
|
||||
shape (`WorkingDirectory=%h/Server/<Name>`); the shape contract pins the
|
||||
details. Secrets go in `.env.example` as `KEY=CHANGE_ME` lines.
|
||||
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
PENPOT_SECRET_KEY=CHANGE_ME
|
||||
PENPOT_DATABASE_PASSWORD=CHANGE_ME
|
||||
@@ -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 '<see this service .env on the machine>';
|
||||
# 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
|
||||
@@ -1 +0,0 @@
|
||||
RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
ADMIN_TOKEN=CHANGE_ME
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN=CHANGE_ME
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
RTSP_PASSWORD=CHANGE_ME
|
||||
PLUS_API_KEY=CHANGE_ME
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/<name>, then copy the "gib" service below and point it there
|
||||
# 2. add "<hostname>=<service>:25565" to MAPPING
|
||||
# 3. add the DNS-only record for <hostname>
|
||||
# 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:
|
||||
# <hostname players type>=<service name>:<port inside the network>
|
||||
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: {}
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
DB_POSTGRES_PASSWORD=CHANGE_ME
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
BW_SESSION=CHANGE_ME
|
||||
OPENAI_API_KEY=CHANGE_ME
|
||||
OAUTH_CLIENT_ID=CHANGE_ME
|
||||
OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||
@@ -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
|
||||
- [email protected]
|
||||
- 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=<query>
|
||||
- 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
- [email protected]
|
||||
- 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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/<name>.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/<token>/config` is blocked by
|
||||
ubo-privacy.txt line 3159 (`$script,xhr,1p` — first-party too), but
|
||||
`/array/<token>/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/<id>/session_recordings` returns 502 from
|
||||
openresty in ~130ms, while a SINGLE recording
|
||||
(`.../session_recordings/<session_id>`) 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/<retention>/`, named `<epoch_ms>-<hash>`.
|
||||
|
||||
## "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.
|
||||
@@ -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.
|
||||
+57
@@ -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"))
|
||||
Executable
+48
@@ -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"
|
||||
@@ -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 protected]>
|
||||
EMAIL_REPLY_TO: [email protected]
|
||||
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
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
python /compose/local-patches.py
|
||||
/compose/wait
|
||||
./bin/migrate
|
||||
./bin/docker-server
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
python /compose/local-patches.py
|
||||
./bin/temporal-django-worker
|
||||
Executable
+19
@@ -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()
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
python /compose/local-patches.py
|
||||
exec ./bin/docker-worker-celery --with-scheduler
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 <[email protected]>'
|
||||
# mail.from: 'System Administrator <root@localhost>'
|
||||
|
||||
# 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: "<application id>"
|
||||
# discord.public-key: "<public key>"
|
||||
|
||||
###############
|
||||
# Google Auth #
|
||||
###############
|
||||
|
||||
# Refer to https://develop.sentry.dev/self-hosted/sso/#google-auth
|
||||
|
||||
# auth-google.client-id: "<client id>"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user