Author SHA1 Message Date
Gabriel Brown ffad7e56ea Give the Mac mini a zsh config with the Panama prompt and SSH tmux 2026-10-12 08:06:20 -04:00
Gabriel Brown 34bc1663ed Give opencode the same agent instructions, rules and MCP servers as Claude Code
opencode already reads skills from ~/.claude/skills and ~/.agents/skills, so
those needed nothing. The rest did.

The manifest now links agents/AGENTS.md to ~/.config/opencode/AGENTS.md as
well, beside the Claude Code and Codex names.

opencode keeps its MCP servers in its own JSON config, with no file to point
a symlink at, so link-mcp now writes them there too. It builds one entry per
row of user/agents/mcp/servers with the same tokens it gives Claude Code,
and adds ~/.claude/rules/*.md under instructions, since opencode does not
read that directory by itself. Only those two keys change. The file is
written private and swapped in whole because it now holds tokens, and a
config with JSONC comments is left alone with a warning rather than
rewritten without them.
2026-10-07 01:06:44 -04:00
Gabriel Brown f4fca1797f Skip locally built images by their build: stanza instead of a list of names
update-containers kept LOCAL_BUILD, a hand-written list of services whose
images are built on the machine, so it would not try to pull them. The
compose files already say this. A service with a build: stanza names a
local image, and there is nothing in a registry to pull.

images_for now drops the image of any compose service that has a build:
stanza. Those services resolve no images, and the run skips them without a
log line. The list, and the app names it carried, are gone. On the VPS,
--list resolves the same images as before for every other service.
2026-10-06 10:52:47 -04:00
Gabriel Brown 913a57f8a6 Snapshot both servers: the VPS catalog moves to server/vps, the home server joins as server/home
The catalog in server/containers had drifted from the VPS. Eleven compose
files and twelve units no longer matched what runs, and two running
services, Gbrown_Agent and BillTracker, were missing. server/vps is now a
copy of the live files, and panama-server reads from it.

server/home holds the home server's services in the same shape. They still
run under Docker, so the podman units there are drafts for a later move, and
nothing installs from that tree. PostHog, Sentry and Plausible are checkouts
of upstream repositories. Only their local files are kept, next to an
UPSTREAM file naming the repository and the exact commit.

Every secret in both trees is a ${VAR} reference named in .env.example, or
CHANGE_ME in files that are not compose files. The live servers still hold
some of those values inline, so a live file can differ from its copy on
those lines alone. Existing .env.example files keep their comments, with new
keys appended at the end.

The ignore rules and the secrets contract now cover both trees, plus the
volume/ and volumes/ directories the home server uses.
2026-10-06 10:52:47 -04:00
gibandClaude Opus 5 a664cf68f5 Do not prefer a dialog on a machine with nowhere to draw it
config/bash/shell exported SSH_ASKPASS=ksshaskpass with
SSH_ASKPASS_REQUIRE=prefer on every machine. `prefer` means ssh and git reach
for that dialog BEFORE the terminal prompt, so on a headless server the
prompt that would have worked was skipped in favour of a KDE window that
cannot open:

  error: /usr/bin/ksshaskpass died of signal 6
  fatal: could not read Username for 'https://git.gbrown.org'

A push from a server failed to ASK for a username rather than failing to
authenticate. The shell environment is the one half of Panama a server does
install, so nothing downstream was going to catch this.

The export is now conditional on a session existing to draw in, and on the
binary being there to run. The headless branch also CLEARS both variables
rather than merely declining to set them: an askpass inherited from a parent
that had a display, or from a login predating this guard, is exactly as
unusable and a guard that only withholds leaves it in place. A fresh login
shell on the repaired server still reported prefer until it cleared them.

A desktop is unchanged; a server, a serial console and a plain SSH session
fall back to the terminal prompt, which works.

tests/setup/headless-askpass-contract pins every branch, the inherited case
included, and fails against the previous code and against a guard that only
withholds.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-01 17:50:14 -04:00
gibandClaude Opus 5 a493fa5584 Open what is shut, and never widen what is already open
setup-server queried --query-port, saw "not open", and blanket-opened 80,
443 and 81. On a machine reaching those ports through rich rules -- one CDN's
address sets, say -- neither appears in --list-ports, so the step read a
deliberate, narrow grant as "closed" and replaced it with a public one. It
did this during `panama update`, which asks nothing and runs unattended, so
there was no prompt and no diff for anyone to read. A production server lost
its firewall posture that way, port 81 and the proxy's admin portal included.

Each port is now checked for existing coverage first -- an open port, an
enabled service, or an accepting rich rule, ranges included -- and left alone
when it has some. The bias stays where it was: only a rule that positively
reads as a grant counts, and anything unrecognised still falls through to
opening the port, because a fresh server that is silently unreachable is the
worse failure. A rule that DROPS a port is not coverage either; reading a
block as a grant would leave the proxy unreachable on a machine nobody
thought to re-check.

A machine that manages its own firewall opts out once, with
`echo manual > ~/.local/state/panama/firewall`, and Panama never touches
firewalld on it again. A file rather than an environment variable, because
the run that needs to respect it is the unattended one.

tests/setup/server-firewall-contract pins all of it, and fails against the
previous code on exactly the cases that cost the posture.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-01 17:50:14 -04:00
Gabriel Brown eb0056ac94 Register the self-hosted Sentry MCP server and keep OAuth sign-ins across updates
link-mcp gains a stdio transport: the servers row names a launcher under user/agents/mcp/, run from the checkout so every machine runs the same script, and the token reaches it as MCP_TOKEN. sentry-gbrown is the first, pointing Sentry's MCP server at sentry.gbrown.org with a read-only token from the ignored env file.

A server already registered exactly as its row would register it is now left alone. Re-adding it every run threw away the sign-in of the OAuth servers, bill-tracker and atlassian, on every update.
2026-10-01 17:33:12 -04:00
Gabriel Brown 58a64d6cee Register Penpot's MCP server, whose token rides in the URL
Penpot's MCP server reads the user key only from the userToken query
parameter, never a header. link-mcp now substitutes a {TOKEN} placeholder
in a server's URL instead of sending the Authorization header, and the
servers list gains a penpot row that uses it.
2026-09-23 08:48:12 -04:00
Gabriel Brown 933c197060 Capture ticket proof with Playwright instead of the browser extension 2026-09-22 15:18:38 -04:00
Gabriel Brown de3591a25b Add capture-proof for sharp Playwright proof screenshots 2026-09-22 15:18:38 -04:00
Gabriel Brown 88032490c0 Drive the ticket skill through the live ticket page 2026-09-22 14:54:05 -04:00
Gabriel Brown fd24b69039 Add the live ticket page and epic index renderer 2026-09-22 14:54:05 -04:00
Gabriel Brown dfc009e422 Add the ticket page data model and its ticket-page CLI 2026-09-22 14:54:05 -04:00
Gabriel Brown 1843c0495a Fix: Teach the update contract about the link-mcp stage 2026-09-22 14:04:32 -04:00
Gabriel Brown 43ae5bb53b Test: Fail the provenance contract when a case writes into the real nvm tree 2026-09-22 14:04:32 -04:00
Gabriel Brown 05054afdc9 Fix: Leave logind alone while a desktop session is running 2026-09-22 14:04:32 -04:00
Gabriel Brown 9022847971 Add a plan.html review page to the ticket skill 2026-09-22 12:54:32 -04:00
Gabriel Brown 9b06e9e24b Docs: Add a spelling rule to unslop
American spelling is the default and British forms turn up in generated
prose. Catch the common endings and the irregulars, with two exceptions:
project vocabulary that the client's documents, ticket titles and codebase
all spell the British way, such as "enrolment" on APSCA, and existing code
identifiers, which are never renamed to fix spelling.

The rules after it shift by one.
2026-09-18 17:28:56 -04:00
Gabriel Brown 59da2bb2fc Docs: Scope the Ksense sections to the Ksense folder
The merge request conventions and the coworker list only apply to Ksense
work, but they load in every project including personal ones. Name the
folder that work lives in so an agent reads those sections as conditional
rather than as always in force.
2026-09-18 17:28:56 -04:00
Gabriel Brown 5ebecd59bb Register Atlassian's MCP server
Jira and Confluence reached Claude Code through two claude.ai connectors,
which are configured per account rather than per machine and had been added
twice. Atlassian hosts its own MCP server, so it belongs in this list with
the others and a new machine picks it up from the installer.

It carries no token. The server runs its own OAuth and Claude Code signs in
through /mcp on first use.
2026-09-18 17:28:56 -04:00
Gabriel Brown 501f7306ed Point every ssh host at the desktop keyring agent
Claude Code and Codex start their shells without SSH_AUTH_SOCK. id_ed25519
has a passphrase, so batch ssh from a harness had no way to unlock it and
failed with "Permission denied (publickey)" against server.gib, junior.gib
and git.gbrown.org. The key and the per-host blocks were right the whole
time, which made it read as a key problem and cost several rounds of
checking keys that were never wrong.

The desktop keyring agent at $XDG_RUNTIME_DIR/keyring/ssh already holds the
key unlocked for the interactive session. A Host * block with IdentityAgent
points every host at that socket, so a shell that inherits no agent gets the
same one the terminal uses.
2026-09-18 17:28:56 -04:00
gibandClaude Opus 5 130137485e Guard the mesa driver swap on its end state
mesa-va-drivers-freeworld Provides mesa-va-drivers, so once the swap has run
once it becomes the only thing providing the name we ask dnf to remove. The
command then resolves to removing and installing the same package in a single
transaction and exits with "conflicting requests", which reported
install-packages as a failed stage on every run after the first even though
the machine was already in the desired state.

The failure also produced misleading output, because dnf explored removing
mesa-dri-drivers and surfaced unrelated complaints from steam, lutris and
tigervnc-server-common.

Check for the freeworld package with rpm -q and skip the swap when it is
already installed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01TFG55mJBiH2tV38tXcNzJ9
2026-09-18 11:02:35 -04:00
Gabriel Brown 5ebebc8aa2 Docs: Treat the pre-MR audit verdict as the gate and converge reruns with context 2026-09-17 11:43:07 -04:00
Gabriel Brown 85160ffef2 Stop writing text into the Working Feature Proof field
Dragging attachments into customfield_10142 replaces whatever text is
sitting there, so the list the skill wrote was destroyed by the upload it
was describing. Naming each artifact is already the job of the Test Cases
proof cells, which reference it by filename, and step 10 tells me which
files to upload.

Only the Story rule changes. On a Bug that field carries the test-case
table rather than prose.
2026-09-14 14:36:54 -04:00
Gabriel Brown 59192143c5 Docs: Add the prose bar and record Josh as the APSCA lead
Anything a person other than me reads now gets an unslop pattern pass
before it ships: MR descriptions, ticket fields, deliverables. The rules
that matter most are the ones a generic pass misses. Write as me in first
person, never name me or use a stand-in, and never flag one of my own
decisions as a deviation, because that reads as my own AI tattling on me
and costs the PM time investigating a non-issue.

Punctuation is stricter than unslop's own pattern: no em dashes, en
dashes, semicolons or arrow glyphs. Generated output is never rewritten,
only regenerated.

Also records Josh as the tech lead on apsca_next, whose dev reviews carry
draft code and schema and are a starting point rather than a finished spec.
2026-09-14 14:36:52 -04:00
Gabriel Brown 2f2e3b88e8 Register MCP servers through the installer
An MCP server is a URL plus a bearer token, and the token is why this is a
stage rather than a manifest line. Panama is public, so the tokens cannot
live in it, and neither runtime keeps its server list in a file worth
symlinking: Codex writes them into config.toml beside dozens of unrelated
settings, and Claude Code into ~/.claude.json. link-mcp registers them
through the runtime's own CLI instead.

user/agents/mcp/servers is tracked and names which variable carries each
token. user/agents/mcp/env holds the tokens and is ignored. A new machine
gets the servers by dropping its own env file beside the tracked one.

Only Claude Code is handled, and only where the interview enabled personal
content. Rewriting a section of somebody's live Codex TOML is a worse
failure mode than leaving two lines to paste once.
2026-09-14 14:36:44 -04:00
Gabriel Brown 9156a7e05c Fix: Scan for DDC displays when quick settings opens
The scan was keyed off this item becoming visible, but on a machine with no
backlight `visible` stays false until a scan has already found a DDC
display, so it never scanned at all. It now keys off the quick settings
panel opening, which still keeps the second-long I2C probe off shell
startup.
2026-09-14 14:36:39 -04:00
Gabriel Brown 39a2e8e9ca Fix: Stop the display link retraining and blanking
This panel's DP link is marginal. 4500x3000@60 at 10bpc is around 24 Gbps,
right at the edge of DP 1.4 HBR3 and reliant on DSC, so every modeset
retrains the link and blanks the screen. 8bpc keeps headroom.

Steam games are the other trigger. Everything Hyprland does only for a real
fullscreen window (direct scanout, VRR, tearing, auto HDR) forces that
retrain, so steam_app windows get fullscreen_state "1 2": maximized
internally while the game believes it is fullscreen, which is what
borderless windowed looks like from the game's side.

Recorded alongside the related Panama settings already at 0,
directScanoutPolicy and vrrPolicy.
2026-09-14 14:36:35 -04:00
Gabriel Brown 6206565d95 Fix: Keep the screenshot picker out of its own capture
The picker paints an opaque frozen frame edge to edge, so the blur and dim
it inherited from the shared qs-overlay rule were compositor work on pixels
nobody sees. It now has its own layer rule.

no_anim is the load-bearing part. Capture.qml unmaps the picker and runs
grim 90ms later, and the default 200ms layersOut fade put the half-faded
picker, dimming and all, into every screenshot.
2026-09-14 14:36:33 -04:00
Gabriel Brown cceb7a707b Fix: Never pair exclusionMode with exclusiveZone
Quickshell's exclusiveZone setter flips exclusionMode back to Normal as a
side effect, so a window declaring 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.

Each overlay now declares ExclusionMode.Ignore alone. The capture picker
was the visible failure: it started under the bar, the full-output freeze
frame was squeezed into a shorter box, and every selection landed one
bar-height off in the real capture.

tests/quickshell/exclusion-idiom-contract fails any file that pairs them.
2026-09-14 14:36:24 -04:00
Gabriel Brown c1bbc69c8a Fix: Pass an explicit value to eza --icons in the ls and lt aliases
eza 0.23.5 gives --icons an optional WHEN value, so a trailing --icons
swallowed the path argument and `ls docs` failed with "invalid value
'docs' for '--icons'". Using --icons=auto makes both aliases independent
of flag order.

Claude-Session: https://claude.ai/code/session_017trjCxkXTdv7Z5ePfGWYtW
2026-09-07 17:16:17 -04:00
Gabriel Brown fa8b14e05e Fix: Adopt a Terra the machine already trusts
The repository audit made any Terra that is not Panama's own pinned form a
trust-root failure, and status 78 then stopped every stage before it ran. A
machine that installed Terra the way Terra documents it -- terra-release's own
repo file, a metalink, the key at its stock path -- was classified hostile and
had no way back, because install_terra_repository refused to touch a machine
terra-release had already reached. A gate with no door.

The trust root is the signing key, and that key is byte-for-byte the
fingerprint this repository reviewed and pinned, with every signature check
already on. So verify the fingerprint and adopt the configuration into the
pinned form instead of refusing it. Adoption needs no network and no DNF, it
runs before any other transaction in the stage, and it is repeatable, which it
has to be: terra-release owns that file and restores it on update.

Adoption stays narrow. The pinned fingerprint must match both the reviewed key
and the key the machine actually verifies against, the gpgkey must be a local
file under the system trust directory, and the endpoint must be one Terra
itself serves -- so the reviewed baseurl or the reviewed metalink host, now
pinned as TERRA_METALINK_BASEURL. An unknown key, a redirected baseurl, a
second enabled Terra, or a disabled signature check is still a hard refusal.

A refusal also stops less than it did. It suppresses the stages that open DNF
and the migrations, which may run a transaction of their own, and the run still
exits 78. It no longer stops link-dotfiles, link-skills or link-user, which
read no repository and install no package. Exiting before them is what left
this laptop with a stale ~/.claude/skills and no shipped skill reachable.

Also stub ensure_flathub_remote in the extras contract, which has been failing
since that call was added to install_extra_category without one.

Claude-Session: https://claude.ai/code/session_01PeTrG9dGY89UWuhGm4Pr1s
2026-08-28 14:53:48 -04:00
Gabriel Brown b5832fc94a Fix installer state and launcher freshness checks 2026-08-27 17:31:46 -04:00
Gabriel Brown 99156442b5 Merge branch 'main' into codex/repo-audit-remediation-package-2
# Conflicts:
#	README.md
#	setup/scripts/install-packages
2026-08-27 16:51:53 -04:00
Gabriel Brown 4c27203214 Route pre-MR review through the work account 2026-08-27 16:39:16 -04:00
Gabriel Brown c71d6c8799 Publish skills through the agent-neutral skill home 2026-08-27 16:39:16 -04:00
Gabriel Brown cb305f6662 Install ChatGPT Desktop from a repository this checkout can verify
OpenAI ships an official Linux RPM now, so the community wrapper goes away:
`panama app chatgpt-desktop` built codex-desktop from the upstream macOS disk
image and ran a local rebuild daemon to keep it current, and the official
package comes from a repository that upgrades with everything else. The app
file, the help example and the dock's pinned id all move over, and a migration
replaces the build on machines that already have it -- official package on
before the community one comes off, so a failure part-way still leaves an app.

The install itself does not follow upstream's instructions. Those are "download
this RPM and install it", and the RPM's own root scriptlet is what writes the
repository file and drops the signing key into /etc/pki/rpm-gpg -- so root runs
an unverified download and then learns from it what to trust. That is the shape
the repository audit forbids: no network response is executed as root without a
verified digest or signature first.

OpenAI publishes no key and no fingerprint anywhere an install could fetch and
check them, so the key is pinned here instead. setup/keys/ carries it and says
where it came from, including the honest part -- this is trust established on
first use and then held, not trust verified against the publisher. setup/lib/
chatgpt-package verifies that copy's fingerprint, installs it, and writes the
repository with gpgcheck and repo_gpgcheck on before anything is installed, so
dnf checks the metadata signature and the package signature itself. It is byte
for byte the repository the scriptlet would have written, so nothing churns
afterwards, and every later upgrade goes through the same key. Both callers use
it; a verification failure skips ChatGPT rather than installing it anyway.

The contract proves the pinned key is the key the library names, that a
missing, unreadable or mismatched key writes nothing at all, that what is
written actually turns the checks on, and that neither caller hands root a
downloaded RPM.

Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
2026-08-27 14:48:20 -04:00
Gabriel Brown 1ee42f2cb6 Fix: Sort the contract manifest in byte order, not the machine's
The manifest is written in byte order, but both the runner and the manifest
contract discovered contracts with a bare `sort` and compared them with bash's
`<` -- and both of those follow LC_COLLATE. Under en_US.UTF-8 the collation
folds punctuation away, so `calendar_agenda_bridge_test.py` sorts before
`calendar-agenda-helper-contract` instead of after it, and eight pairs that
differ only by `-` against `_` come back out of order.

The effect was that `tests/setup/contract-manifest-contract` failed on this
machine, and `panama test` refused to run at all, with eight identical "paths
are not lexicographically sorted" findings and nothing naming which paths. A
gate whose answer depends on the machine's LANG is not a gate, so the sort and
the comparison are both pinned to byte order. LC_ALL rather than LC_COLLATE,
because an exported LC_ALL outranks it and would have put the bug back.

Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
2026-08-27 14:47:47 -04:00
Gabriel Brown 1ae3825fda Alias a second Claude Code configuration
CLAUDE_CONFIG_DIR points the CLI at a different config home, so `klaude`
runs Claude Code against ~/.klaude -- its own settings, auth and history --
without disturbing the default one this machine already uses.

Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
2026-08-27 14:39:43 -04:00
219 changed files with 9332 additions and 814 deletions

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.
+2 -2
View File
@@ -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).
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/panama
+11 -5
View File
@@ -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
+8 -5
View 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
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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"
+14 -4
View File
@@ -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"
+24 -2
View File
@@ -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"
+4 -1
View File
@@ -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;
+54
View File
@@ -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")"
+75 -22
View File
@@ -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:
@@ -93,7 +93,10 @@ hash_packages() {
for fixed_input in \
"$PANAMA_PATH/setup/scripts/install-packages" \
"$PANAMA_PATH/setup/lib/artifact-provenance"; do
"$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
@@ -102,7 +105,10 @@ hash_packages() {
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" || exit 1
"$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"/}"
@@ -116,12 +122,11 @@ hash_packages() {
}
packages_needed() {
local current_hash recorded_hash
local current_hash="$1" recorded_hash
(( FORCE_PACKAGES )) && return 0
(( UPGRADE )) || return 0
[[ -r "$PACKAGES_HASH" ]] || return 0
current_hash="$(hash_packages)" || return 2
recorded_hash="$(cat "$PACKAGES_HASH")" || return 2
[[ "$current_hash" != "$recorded_hash" ]]
}
@@ -130,10 +135,12 @@ packages_needed() {
# 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 temporary_hash
local starting_hash="$1" current_hash temporary_hash
current_hash="$(hash_packages)" || return 1
[[ "$current_hash" == "$starting_hash" ]] || return 1
mkdir -p "$STATE_DIR"
temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
if hash_packages >"$temporary_hash"; then
if printf '%s\n' "$starting_hash" >"$temporary_hash"; then
mv -f -- "$temporary_hash" "$PACKAGES_HASH"
else
rm -f -- "$temporary_hash"
@@ -142,20 +149,37 @@ record_packages_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 ────────────────────────────────────────────────────────────
@@ -175,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
@@ -319,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
@@ -342,9 +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_state_status=0
packages_needed || package_state_status=$?
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."
@@ -357,16 +388,19 @@ for stage in "${STAGES[@]}"; do
fi
if "$script"; then
if [[ "$stage" == install-packages ]]; then
if ! record_packages_hash; then
if ! record_packages_hash "$package_start_hash"; then
failed+=("$stage")
printf '!!! %s could not record its tracked installation inputs\n' "$stage" >&2
fi
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
@@ -392,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)
@@ -441,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."
+64
View File
@@ -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
View File
@@ -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.
-25
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
PENPOT_SECRET_KEY=CHANGE_ME
PENPOT_DATABASE_PASSWORD=CHANGE_ME
-120
View File
@@ -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
View File
@@ -1 +0,0 @@
RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME
-88
View File
@@ -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
-55
View File
@@ -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
+48
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
GITEA_RUNNER_REGISTRATION_TOKEN=CHANGE_ME
+153
View File
@@ -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
+39
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
RTSP_PASSWORD=CHANGE_ME
PLUS_API_KEY=CHANGE_ME
+79
View File
@@ -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
+16
View File
@@ -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
+219
View File
@@ -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
+18
View File
@@ -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
+24
View File
@@ -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
+18
View File
@@ -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
+7
View File
@@ -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
+86
View File
@@ -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
+18
View File
@@ -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
+9
View File
@@ -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
+429
View File
@@ -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"
+18
View File
@@ -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
+76
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
DB_POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_PASSWORD=CHANGE_ME
+55
View File
@@ -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
+18
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
BW_SESSION=CHANGE_ME
OPENAI_API_KEY=CHANGE_ME
OAUTH_CLIENT_ID=CHANGE_ME
OAUTH_CLIENT_SECRET=CHANGE_ME
+146
View File
@@ -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
+8
View File
@@ -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
+6
View File
@@ -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.
+131
View 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
+29
View File
@@ -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
+391
View File
@@ -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.
+6
View File
@@ -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
View File
@@ -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"))
+48
View File
@@ -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"
+99
View File
@@ -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
+767
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
python /compose/local-patches.py
/compose/wait
./bin/migrate
./bin/docker-server
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
python /compose/local-patches.py
./bin/temporal-django-worker
+19
View File
@@ -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()
+3
View File
@@ -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
+10
View File
@@ -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
+66
View File
@@ -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
+18
View File
@@ -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
+59
View File
@@ -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
+24
View File
@@ -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
+6
View File
@@ -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.
+18
View File
@@ -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
+150
View File
@@ -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>"
+572
View File
@@ -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"
+23
View File
@@ -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
+22
View File
@@ -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
+70
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
services:
nextcloud-aio-mastercontainer:
image: ghcr.io/nextcloud-releases/all-in-one:latest
container_name: nextcloud-aio-mastercontainer
volumes:
- nextcloud_aio_mastercontainer:/mnt/docker-aio-config
- /var/run/docker.sock:/var/run/docker.sock:ro
network_mode: bridge
ports:
- 8070:8080
environment:
APACHE_PORT: 11000
APACHE_ADDITIONAL_NETWORK: nginx-bridge
# https://github.com/nextcloud/all-in-one#how-to-adjust-borgs-retention-policy
BORG_RETENTION_POLICY: --keep-within=7d --keep-weekly=4 --keep-monthly=6
# Host path for data directory. Can't change after initial run!
NEXTCLOUD_DATADIR: /home/gib/Public/Nextcloud/Server
# https://github.com/nextcloud/all-in-one#how-to-allow-the-nextcloud-container-to-access-directories-on-the-host
# NEXTCLOUD_MOUNT: /mnt/
# https://github.com/nextcloud/all-in-one#how-to-adjust-the-upload-limit-for-nextcloud
NEXTCLOUD_UPLOAD_LIMIT: 32G
# https://github.com/nextcloud/all-in-one#how-to-adjust-the-max-execution-time-for-nextcloud
NEXTCLOUD_MAX_TIME: 3600
# https://github.com/nextcloud/all-in-one#how-to-adjust-the-php-memory-limit-for-nextcloud
NEXTCLOUD_MEMORY_LIMIT: 4096M
# https://github.com/nextcloud/all-in-one#how-to-trust-user-defined-certification-authorities-ca
# NEXTCLOUD_TRUSTED_CACERTS_DIR: /path/to/my/cacerts
# https://github.com/nextcloud/all-in-one#how-to-change-the-nextcloud-apps-that-are-installed-on-the-first-startup
NEXTCLOUD_STARTUP_APPS: deck twofactor_totp calendar contacts notes
NEXTCLOUD_ENABLE_NVIDIA_GPU: true
SKIP_DOMAIN_VALIDATION: true
# TALK_PORT: 3478
# WATCHTOWER_DOCKER_SOCKET_PATH: /var/run/docker.sock
security_opt: ["seccomp:unconfined", "label:disable"]
devices: ["/dev/dri:/dev/dri"]
init: true
restart: unless-stopped
tty: true
runtime: nvidia
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
nextcloud_aio_mastercontainer:
name: nextcloud_aio_mastercontainer # CANT CHANGE
networks:
nginx-bridge:
external: true
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more