227 changed files with 3310 additions and 9600 deletions

No files matched your search

+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), the app source
checkouts such as sierraandtyler and Bang (deployed by CI from their own repos —
gitea down; its dir stays on junior until cleanup), Completeuphoria,
sierraandtyler, Bang (source checkouts 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
@@ -1 +0,0 @@
../../.agents/skills/panama
@@ -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.
Before large work, read these in order:
Read before large work, in this 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,8 +62,7 @@ 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
`config/dot/quickshell/services/SettingsRoutes.qml`, regenerate:
- After changing `PreferenceSchema.qml` or `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.
+5 -11
View File
@@ -3,21 +3,15 @@
/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 (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/
# 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/
# 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
+5 -8
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 `~/.agents/skills` and `~/.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 `~/.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, opened only where nothing already grants them), the `nginx-bridge` network, and the nightly image-update timer |
| `setup-server` | Server role only: linger, unprivileged ports from 80, firewalld (80/443/81), 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,13 +235,10 @@ 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
~/.agents/skills and ~/.claude/skills
~/.claude/skills
setup/
apps/ Applications built from source, one file each
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
lib/ Shared by more than one stage; the extras catalog reader
packages/ One package per line; extras/ holds the optional categories
scripts/ Run in order by ./install
tests/ Contracts. See below
@@ -250,7 +247,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
192 of them, under `tests/`. `tests/contracts.manifest` classifies every
188 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:
+2 -8
View File
@@ -134,6 +134,7 @@ ${BOLD}Examples:${RESET}
$PROGRAM upgrade
$PROGRAM apps
$PROGRAM app
$PROGRAM app chatgpt-desktop
EOF
}
@@ -450,16 +451,11 @@ 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__*' | LC_ALL=C sort)
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
}
contract_manifest_entries() {
@@ -482,8 +478,6 @@ 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/vps/<Name>/ holds a
# The repository carries the catalog: server/containers/<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/vps}"
SERVER_DIR="${PANAMA_SERVER_DIR:-$PANAMA_DIR/server/containers}"
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/vps/
${BOLD}$PROGRAM${RESET} – manage the compose services in server/containers/
${BOLD}Usage:${RESET}
$PROGRAM list Every service in the catalog, and its state here
+89 -6
View File
@@ -45,6 +45,65 @@ checkout_command() {
fi
}
# Git's index hints are performance promises, not trust evidence. In
# particular, assume-unchanged and skip-worktree can make porcelain status
# report a clean checkout whose files no longer match HEAD. Compare every
# tracked blob and Git mode with the verified commit before handing control to
# any file in the worktree.
checkout_matches_verified_commit() (
local checkout="$1" listing="" entry metadata mode type expected path actual
local link_target_with_sentinel link_target
trap '[[ -z "$listing" ]] || rm -f -- "$listing"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
listing="$(mktemp -u -t panama-boot-tree.XXXXXX)" || exit 1
umask 077
if ! (set -o noclobber; : >"$listing") 2>/dev/null; then
listing=""
exit 1
fi
checkout_command git -C "$checkout" ls-tree -rz --full-tree \
"$PANAMA_BOOT_REVISION" >"$listing" || exit 1
while IFS= read -r -d '' entry; do
[[ "$entry" == *$'\t'* ]] || exit 1
metadata="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
read -r mode type expected <<<"$metadata"
[[ "$type" == blob && -n "$path" && "$path" != /* ]] || exit 1
case "$mode" in
100644) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
&& ! -x "$checkout/$path" ]] || exit 1 ;;
100755) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
&& -x "$checkout/$path" ]] || exit 1 ;;
120000)
[[ -L "$checkout/$path" ]] || exit 1
# hash-object given a pathname follows a symlink. Git's 120000 blob is
# the link text itself, including any trailing newlines, so preserve
# those bytes with a sentinel and hash stdin instead.
link_target_with_sentinel="$(
readlink -n -- "$checkout/$path" && printf .
)" || exit 1
[[ "$link_target_with_sentinel" == *. ]] || exit 1
link_target="${link_target_with_sentinel%.}"
actual="$(
printf '%s' "$link_target" \
| checkout_command git -C "$checkout" hash-object --stdin
)" || exit 1
[[ "$actual" == "$expected" ]] || exit 1
continue
;;
*) exit 1 ;;
esac
actual="$(checkout_command git -C "$checkout" hash-object --no-filters -- "$path")" \
|| exit 1
[[ "$actual" == "$expected" ]] || exit 1
done <"$listing"
)
prepare_panama_checkout() {
local checkout="$1" actual_head checkout_status
@@ -110,6 +169,30 @@ for arg in "$@"; do
esac
done
# Keep the worktree comparison at the last possible boundary. Checkout
# preparation may invoke several commands and return to the caller; performing
# the byte/mode/link check here ensures a change in that interval is rejected
# before any tracked file is executed.
verified_install_handoff() {
local use_tty="$1"
if ! checkout_matches_verified_commit "$PANAMA_PATH"; then
echo "boot: checkout files do not match PANAMA_BOOT_REVISION" >&2
return 1
fi
if [[ -n "$BOOTSTRAP_USER" ]]; then
if (( use_tty )); then
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
fi
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
fi
if (( use_tty )); then
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
fi
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
}
# The public bootstrap contract runs this branch as an ordinary user with a
# stubbed root identity. Keep its filesystem adapter unavailable to a real root
# shell so it cannot redirect a real installation by accident.
@@ -471,7 +554,7 @@ if [[ "$(id -u)" -eq 0 ]]; then
if ! command -v git >/dev/null 2>&1; then
echo "Installing git, which the clone needs"
dnf install -y git
dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
fi
# Create or advance the checkout as the target user. A root-owned .git in a
@@ -481,15 +564,14 @@ if [[ "$(id -u)" -eq 0 ]]; then
prepare_panama_checkout "$PANAMA_PATH"
echo "Handing off to install as $username"
exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" --server </dev/tty
verified_install_handoff 1
fi
# git is the one dependency the clone itself needs. Everything else -- gum
# included -- is bootstrapped by `install`.
if ! command -v git >/dev/null 2>&1; then
echo "Installing git, which the clone needs"
sudo dnf install -y git
sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
fi
prepare_panama_checkout "$PANAMA_PATH"
@@ -500,7 +582,8 @@ prepare_panama_checkout "$PANAMA_PATH"
# so itself.
# The probe actually opens /dev/tty rather than testing -r: a process with no
# controlling terminal passes -r and then fails the redirect.
handoff_tty=0
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
handoff_tty=1
fi
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
verified_install_handoff "$handoff_tty"
+2 -3
View File
@@ -22,7 +22,6 @@ 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
@@ -37,9 +36,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=auto'
alias ls='eza -lh --group-directories-first --icons'
alias lsa='ls -a'
alias lt='eza --tree --level=2 --long --icons=auto --git'
alias lt='eza --tree --level=2 --long --icons --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 {}'"
+2 -17
View File
@@ -16,23 +16,8 @@ unset PANAMA_ENV
# Editor used by CLI
export EDITOR="nvim"
export SUDO_EDITOR="$EDITOR"
# 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
export SSH_ASKPASS=/usr/bin/ksshaskpass
export SSH_ASKPASS_REQUIRE=prefer
# Define Paths
export CARGO_PATH="$HOME/.cargo"
+4 -14
View File
@@ -244,20 +244,10 @@ local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5
local shipped_transform = 0
-- 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
-- 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
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
local shipped_cm = "auto"
+2 -24
View File
@@ -125,16 +125,6 @@ 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.*)$" },
@@ -279,28 +269,16 @@ hl.layer_rule({
ignore_alpha = 0.2,
})
-- Overview and local screen-reading UI dim the desktop behind them.
-- Overview, capture and local screen-reading UI dim the desktop behind them.
hl.layer_rule({
name = "qs-overlay",
match = { namespace = "^qs-(overview|screen-intelligence)$" },
match = { namespace = "^qs-(overview|capture|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",
"chatgpt", "md.obsidian.Obsidian",
"codex-desktop", "md.obsidian.Obsidian",
"com.obsproject.Studio", "steam"
]
},
@@ -27,13 +27,9 @@ PanelWindow {
right: true
}
// 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
// Fullscreen overlays must not reserve space, or every window on the
// workspace gets resized as the picker opens and closes.
exclusiveZone: 0
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
WlrLayershell.layer: WlrLayer.Overlay
@@ -57,6 +57,7 @@ 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,6 +55,7 @@ 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,6 +40,7 @@ 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,6 +44,7 @@ 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,6 +29,7 @@ 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,6 +22,7 @@ PanelWindow {
}
color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
@@ -34,23 +34,11 @@ Item {
visible: root.rowCount > 0
implicitHeight: rows.implicitHeight
// 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();
}
// 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()
// `-m` is the machine-readable form: name,class,current,percent,max
Process {
@@ -22,6 +22,7 @@ Variants {
implicitWidth: 260
implicitHeight: 172
color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
mask: Region {}
@@ -383,13 +383,6 @@ 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,6 +78,7 @@ PanelWindow {
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "qs-popover-welcome"
+1 -4
View File
@@ -341,10 +341,7 @@ 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. 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.
// the output without the overlay on it.
interval: 90
onTriggered: {
const r = commitDelay.rect;
-54
View File
@@ -1,54 +0,0 @@
#!/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")"
+106 -105
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, every helper it sources, and regular provenance files;
# package-stage adapter, the provenance helper, and regular provenance files;
# both relative paths and bytes are part of the state.
#
# A content hash rather than a git range, because Panama is developed in place:
@@ -88,42 +88,75 @@ source "$PANAMA_PATH/bin/ascii"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
PACKAGES_HASH="$STATE_DIR/packages-hash"
hash_packages() {
local file relative size fixed_input digest
for fixed_input in \
"$PANAMA_PATH/setup/scripts/install-packages" \
"$PANAMA_PATH/setup/lib/artifact-provenance" \
"$PANAMA_PATH/setup/lib/chatgpt-package" \
"$PANAMA_PATH/setup/lib/extras-catalog" \
"$PANAMA_PATH/setup/lib/machine-role"; do
[[ -f "$fixed_input" && ! -L "$fixed_input" && -r "$fixed_input" ]] || return 1
done
digest="$(
{
find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 || exit 1
printf '%s\0' \
"$PANAMA_PATH/setup/scripts/install-packages" \
"$PANAMA_PATH/setup/lib/artifact-provenance" \
"$PANAMA_PATH/setup/lib/chatgpt-package" \
"$PANAMA_PATH/setup/lib/extras-catalog" \
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
find "$PANAMA_PATH/setup/provenance" -type f -print0 || exit 1
} | LC_ALL=C sort -z | while IFS= read -r -d '' file; do
relative="${file#"$PANAMA_PATH"/}"
size="$(wc -c <"$file")" || exit 1
printf '%s\0%s\0' "$relative" "$size" || exit 1
cat -- "$file" || exit 1
printf '\0' || exit 1
done | sha256sum | cut -d' ' -f1
)" || return 1
printf '%s\n' "$digest"
_collect_package_inputs() {
local destination="$1" raw="${1}.raw"
[[ -d "$PANAMA_PATH/setup/packages" \
&& ! -L "$PANAMA_PATH/setup/packages" \
&& -d "$PANAMA_PATH/setup/provenance" \
&& ! -L "$PANAMA_PATH/setup/provenance" ]] || return 1
{
printf '%s\0' \
"$PANAMA_PATH/setup/scripts/install-packages" \
"$PANAMA_PATH/setup/lib/artifact-provenance" \
"$PANAMA_PATH/setup/lib/extras-catalog" \
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
# extras/ is deliberately excluded. A symlink or other non-directory
# object at this level is still an input error, not something discovery may
# silently omit.
find "$PANAMA_PATH/setup/packages" -mindepth 1 -maxdepth 1 \
! -type d -print0 || exit 1
find "$PANAMA_PATH/setup/provenance" -mindepth 1 \
! -type d -print0 || exit 1
} >"$raw" || return 1
LC_ALL=C sort -z "$raw" >"$destination"
}
packages_needed() {
local current_hash="$1" recorded_hash
_write_package_manifest() {
local inputs="$1" destination="$2" file relative digest
: >"$destination" || return 1
while IFS= read -r -d '' file; do
[[ -f "$file" && ! -L "$file" && -r "$file" ]] || return 1
relative="${file#"$PANAMA_PATH"/}"
[[ "$relative" != "$file" ]] || return 1
digest="$(sha256sum -- "$file" | awk '{ print $1 }')" || return 1
[[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1
printf '%s\0%s\0' "$relative" "$digest" >>"$destination" || return 1
done <"$inputs"
}
# Read every input twice from the same enumerated set. A file or path that
# changes while the snapshot is built cannot produce a receipt.
hash_packages() (
local work="" inputs_before inputs_after manifest_before manifest_after
trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
work="$(mktemp -u -d -t panama-packages-hash.XXXXXX)" || exit 1
if ! mkdir -m 700 -- "$work"; then
work=""
exit 1
fi
inputs_before="$work/inputs-before"
inputs_after="$work/inputs-after"
manifest_before="$work/manifest-before"
manifest_after="$work/manifest-after"
_collect_package_inputs "$inputs_before" || exit 1
_write_package_manifest "$inputs_before" "$manifest_before" || exit 1
_collect_package_inputs "$inputs_after" || exit 1
cmp -s -- "$inputs_before" "$inputs_after" || exit 1
_write_package_manifest "$inputs_after" "$manifest_after" || exit 1
cmp -s -- "$manifest_before" "$manifest_after" || exit 1
sha256sum -- "$manifest_before" | awk '{ print $1 }'
)
PACKAGE_START_HASH=""
packages_needed() {
local current_hash recorded_hash
current_hash="$(hash_packages)" || return 2
PACKAGE_START_HASH="$current_hash"
(( FORCE_PACKAGES )) && return 0
(( UPGRADE )) || return 0
[[ -r "$PACKAGES_HASH" ]] || return 0
@@ -134,52 +167,41 @@ packages_needed() {
# Written only after the stage succeeds, mirroring the rule panama-migrate
# documents for its markers: a step that did not complete has not happened, and
# recording it as done hides it forever.
record_packages_hash() {
local starting_hash="$1" current_hash temporary_hash
record_packages_hash() (
local expected_hash="$1" current_hash temporary_hash=""
trap '[[ -z "$temporary_hash" ]] || rm -f -- "$temporary_hash"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
[[ "$expected_hash" =~ ^[0-9a-f]{64}$ ]] || return 1
current_hash="$(hash_packages)" || return 1
[[ "$current_hash" == "$starting_hash" ]] || return 1
[[ "$current_hash" == "$expected_hash" ]] || return 1
mkdir -p "$STATE_DIR"
temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
if printf '%s\n' "$starting_hash" >"$temporary_hash"; then
mv -f -- "$temporary_hash" "$PACKAGES_HASH"
else
rm -f -- "$temporary_hash"
temporary_hash="$(mktemp -u "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
umask 077
if ! (set -o noclobber; : >"$temporary_hash") 2>/dev/null; then
temporary_hash=""
return 1
fi
}
printf '%s\n' "$expected_hash" >"$temporary_hash" || return 1
mv -f -- "$temporary_hash" "$PACKAGES_HASH" || return 1
temporary_hash=""
)
# Repository trust is checked before the installer can reach its bootstrap DNF.
# Status 78 is reserved for a trust-root failure. 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.
# 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.
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
package_trust_refused=1
elif ! "$trust_preflight" --trust-preflight; then
exit "$TERRA_TRUST_FAILURE_STATUS"
fi
if "$trust_preflight" --trust-preflight; then
:
else
trust_status=$?
printf 'install: package repository trust preflight failed\n' >&2
package_trust_refused=1
exit "$trust_status"
fi
# ── The interview ────────────────────────────────────────────────────────────
@@ -199,7 +221,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 && ! package_trust_refused )); then
if (( ! UPGRADE )); then
bootstrap=()
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
# The probe tools serve only the hardware questions, which a server is never
@@ -214,7 +236,8 @@ if (( ! UPGRADE && ! package_trust_refused )); then
fi
if (( ${#bootstrap[@]} > 0 )); then
echo "Installing what the setup questions are built on: ${bootstrap[*]}"
sudo dnf install -y "${bootstrap[@]}" >/dev/null || {
sudo dnf install -y --repo=fedora --repo=updates \
--from-repo=fedora,updates "${bootstrap[@]}" >/dev/null || {
echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2
exit 1
}
@@ -277,7 +300,12 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
# is unset and each stage takes the empty-answer path it already documents --
# which is why this is a flag rather than a rewrite of seven stage scripts.
if (( ! UPGRADE )); then
PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)"
PANAMA_ANSWERS="$(mktemp -u -t panama-answers.XXXXXX)" || exit 1
umask 077
if ! (set -o noclobber; : >"$PANAMA_ANSWERS") 2>/dev/null; then
PANAMA_ANSWERS=""
exit 1
fi
export PANAMA_ANSWERS
if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then
@@ -343,7 +371,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 link-mcp change-settings link-vicinae-scripts setup-identity install-hardware)
STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware)
fi
# The two an upgrade drops. Both exist only to act on interview answers, and
@@ -366,16 +394,11 @@ for stage in "${STAGES[@]}"; do
script="$PANAMA_PATH/setup/scripts/$stage"
[[ -x "$script" ]] || continue
printf '\n=== %s ===\n' "$stage"
if (( package_trust_refused )) && stage_opens_dnf "$stage"; then
echo "Skipped: the package repository trust check refused package work."
continue
fi
if [[ "$stage" == install-packages ]]; then
package_start_hash=""
package_state_status=0
package_start_hash="$(hash_packages)" || package_state_status=2
if (( package_state_status == 0 )); then
packages_needed "$package_start_hash" || package_state_status=$?
fi
packages_needed || package_state_status=$?
package_start_hash="$PACKAGE_START_HASH"
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."
@@ -395,12 +418,9 @@ for stage in "${STAGES[@]}"; do
fi
else
stage_status=$?
# A configuration change between the preflight and this stage. Suppress the
# remaining DNF stages, keep the safe ones, and carry the status to the end.
if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
package_trust_refused=1
continue
exit "$stage_status"
fi
failed+=("$stage")
printf '!!! %s failed\n' "$stage" >&2
@@ -426,16 +446,8 @@ 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 (( package_trust_refused )) && [[ -x "$migrate" ]]; then
printf '\n=== migrations ===\n'
echo "Skipped: the package repository trust check refused package work."
elif [[ -x "$migrate" ]]; then
if [[ -x "$migrate" ]]; then
printf '\n=== migrations ===\n'
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
"$migrate" run || failed+=(migrations)
@@ -483,17 +495,6 @@ 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
@@ -1,64 +0,0 @@
#!/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."
+6 -33
View File
@@ -6,34 +6,13 @@ screen, plus rootless podman compose services managed as systemd user units.
```
server/
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)
containers/ One directory per service: compose.yml, the
podman-<name>.service user unit, and an .env.example
naming what the service needs told
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.
@@ -56,18 +35,12 @@ is what `panama server enable` refuses to start on.
## Conventions
- Service directories are TitleCase, matching `~/Server` as it has always
looked: `server/vps/Gitea` becomes `~/Server/Gitea`.
looked: `server/containers/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, 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
- Only 80, 443 and 81 are open (setup-server's doing). 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.
@@ -94,7 +67,7 @@ ones — and verify it came back up. One service at a time, quiet hours.
## Adding a service
Add `server/vps/<Name>/` with the three files, modelled on any
Add `server/containers/<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.
File renamed without changes.
File renamed without changes.
@@ -18,8 +18,3 @@ AUTHENTIK_EMAIL__USE_SSL=false
AUTHENTIK_EMAIL__TIMEOUT=10
# Email address authentik will send from, should have a correct @domain
AUTHENTIK_EMAIL__FROM=[email protected]
# Also set on the live machine
AUTHENTIK_IMAGE=CHANGE_ME
PG_DB=CHANGE_ME
PG_USER=CHANGE_ME
File renamed without changes.
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Authentik
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
@@ -35,11 +35,6 @@ services:
# No published port: NPM proxies to http://beszel:8090 over nginx-bridge.
environment:
- TZ=America/New_York
# Login is SSO-only through Authentik (OIDC provider "Gibs Auth", app slug
# beszel). The OAuth config lives in data.db (users collection options),
# not here. The PocketBase superuser panel at /_/ keeps its own password,
# and is the way back in if Authentik is down: flip this to false there.
- DISABLE_PASSWORD_AUTH=true
# Lets the hub reach a host-network agent. Under rootless podman this resolves to
# the nginx-bridge gateway (172.18.0.1). Add the VPS in the hub UI with host
# "host.docker.internal", port 45876.
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Beszel
After=network-online.target podman.socket
Wants=network-online.target
File renamed without changes.
@@ -7,7 +7,7 @@
# BEFORE FIRST START, create its role and database on the SHARED postgres (this stack
# no longer ships its own):
# podman exec -i postgresql psql -U npm <<'SQL'
# CREATE ROLE calcom LOGIN PASSWORD '617408b6790b546811cc38d0353ea506';
# CREATE ROLE calcom LOGIN PASSWORD '<see this service .env on the machine>';
# CREATE DATABASE calcom OWNER calcom;
# SQL
# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql.
@@ -52,7 +52,7 @@ services:
- EMAIL_SERVER_HOST=CHANGE_ME
- EMAIL_SERVER_PORT=587
- EMAIL_SERVER_USER=CHANGE_ME
- EMAIL_SERVER_PASSWORD=CHANGE_ME
- EMAIL_SERVER_PASSWORD=${EMAIL_SERVER_PASSWORD}
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
@@ -57,8 +57,3 @@ DO_NOT_REQUIRE_SSL=true
#ACTIONS_USER_TIMEOUT_SECS=
#RUST_LOG=
#RUST_BACKTRACE=
# Also set on the live machine
BACKEND_PORT=CHANGE_ME
PORT=CHANGE_ME
SITE_PROXY_PORT=CHANGE_ME
File renamed without changes.
@@ -9,7 +9,7 @@
# 1. Create its role and database on the SHARED postgres (this stack no longer
# ships its own):
# podman exec -i postgresql psql -U npm <<'SQL'
# CREATE ROLE documenso LOGIN PASSWORD 'e2bf60b34aff8ce259e5ed42da46c9c6';
# CREATE ROLE documenso LOGIN PASSWORD '<see this service .env on the machine>';
# CREATE DATABASE documenso OWNER documenso;
# SQL
# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql so a
@@ -60,7 +60,7 @@ services:
- NEXT_PRIVATE_SMTP_HOST=CHANGE_ME
- NEXT_PRIVATE_SMTP_PORT=587
- NEXT_PRIVATE_SMTP_USERNAME=CHANGE_ME
- NEXT_PRIVATE_SMTP_PASSWORD=CHANGE_ME
- NEXT_PRIVATE_SMTP_PASSWORD=${NEXT_PRIVATE_SMTP_PASSWORD}
- NEXT_PRIVATE_SMTP_FROM_NAME=Documenso
- [email protected]
labels:
+25
View File
@@ -0,0 +1,25 @@
networks:
nginx-bridge:
external: true
services:
gitea:
image: docker.io/gitea/gitea:latest
container_name: gitea
hostname: gitea
domainname: git.gbrown.org
networks:
- nginx-bridge
ports:
- '2222:22'
environment:
- TZ=America/New_York
- USER_UID=1001
- USER_GID=1003
labels:
com.centurylinklabs.watchtower.enable: 'true'
volumes:
- ./volume:/data:Z
- /home/gib/Media/gitea-packages:/data/gitea/packages:Z
- ./ssh:/data/git/.ssh:Z
tty: true
restart: unless-stopped
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: iSponsorBlockTV
After=network-online.target podman.socket
Wants=network-online.target
@@ -140,6 +140,3 @@ KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN=false
# ClickHouse (optional) for audit log storage
# CLICKHOUSE_URL=http://infisical:infisical@clickhouse:8123/infisical
# Also set on the live machine
CLIENT_GITLAB_LOGIN_URL=CHANGE_ME
File renamed without changes.
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Infisical
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
@@ -62,11 +62,3 @@ POSTGRES_CONTAINER_NAME=lashaddict-postgres
POSTGRES_USER=gib
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=lashaddict-payload
# Also set on the live machine
NEXT_PUBLIC_POSTHOG_KEY=CHANGE_ME
NEXT_PUBLIC_POSTHOG_HOST=CHANGE_ME
NEXT_PUBLIC_PLAUSIBLE_SRC=CHANGE_ME
BACKEND_PORT=CHANGE_ME
PORT=CHANGE_ME
SITE_PROXY_PORT=CHANGE_ME
@@ -6,12 +6,6 @@ services:
image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest
container_name: ${NEXT_CONTAINER_NAME}
hostname: ${NEXT_CONTAINER_NAME}
# Upstream DNS pinned to Cloudflare: Hetzner resolvers held a stale
# NXDOMAIN for api.lashaddictstudio.com for hours after it was created
# (2026-09-30). Container names still resolve through aardvark-dns.
dns:
- 1.1.1.1
- 1.0.0.1
domainname: ${NEXT_DOMAIN}
networks:
- ${NETWORK:-nginx-bridge}
@@ -35,11 +29,6 @@ services:
lashaddict-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-lashaddict-backend}
# Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend
# fetches its own auth discovery URL, which Hetzner resolvers failed.
dns:
- 1.1.1.1
- 1.0.0.1
hostname: ${BACKEND_CONTAINER_NAME:-lashaddict-backend}
domainname: ${BACKEND_DOMAIN:-lashaddict.gbrown.org}
networks:
@@ -69,11 +58,6 @@ services:
lashaddict-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard}
# Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend
# fetches its own auth discovery URL, which Hetzner resolvers failed.
dns:
- 1.1.1.1
- 1.0.0.1
hostname: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-lashaddict.gbrown.org}}
networks:
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: N8n
After=network-online.target podman-postgresql.service
Wants=network-online.target
@@ -37,6 +37,7 @@ services:
#networks: ['nginx-bridge']
#environment:
#POSTGRES_USER: 'npm'
#POSTGRES_PASSWORD: '<see this service .env on the machine>'
#POSTGRES_DB: 'npm'
#labels:
#com.centurylinklabs.watchtower.enable: 'true'
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Nginx Proxy Manager
After=network-online.target podman-postgresql.service
Wants=network-online.target
+2
View File
@@ -0,0 +1,2 @@
PENPOT_SECRET_KEY=CHANGE_ME
PENPOT_DATABASE_PASSWORD=CHANGE_ME
+120
View File
@@ -0,0 +1,120 @@
# 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
File renamed without changes.
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Portainer
After=network-online.target
Wants=network-online.target
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: PostgreSQL
After=network-online.target
Wants=network-online.target
+1
View File
@@ -0,0 +1 @@
RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME
@@ -72,7 +72,7 @@ services:
- RUSTDESK_API_RUSTDESK_ID_SERVER=relay.gbrown.org
- RUSTDESK_API_RUSTDESK_RELAY_SERVER=relay.gbrown.org
- RUSTDESK_API_RUSTDESK_API_SERVER=https://rustdesk.gbrown.org
- RUSTDESK_API_RUSTDESK_KEY=${RUSTDESK_API_RUSTDESK_KEY}
- RUSTDESK_API_RUSTDESK_KEY=WLgvHhau6aa5nDPQutTHeQBpIrOEb8aPXByVBWQwkKc=
- RUSTDESK_API_RUSTDESK_JWT_KEY=${RUSTDESK_API_RUSTDESK_JWT_KEY}
- RUSTDESK_API_LANG=en
- RUSTDESK_API_APP_DISABLE_PWD_LOGIN=true
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Rustdesk
After=network-online.target podman.socket
Wants=network-online.target
@@ -64,6 +64,3 @@ SPOON_AGENT_WORKER_ID=production-worker
SPOON_AGENT_WORKER_HTTP_PORT="3921"
SPOON_AGENT_WORKER_INTERNAL_TOKEN=CHANGE_ME
SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921
# Also set on the live machine
PORT=CHANGE_ME
File renamed without changes.
@@ -56,8 +56,3 @@ POSTGRES_URL=CHANGE_ME
#ACTIONS_USER_TIMEOUT_SECS=
#RUST_LOG=
#RUST_BACKTRACE=
# Also set on the live machine
BACKEND_PORT=CHANGE_ME
PORT=CHANGE_ME
SITE_PROXY_PORT=CHANGE_ME
File renamed without changes.
@@ -19,7 +19,7 @@ services:
container_name: convertx
hostname: convertx
domainname: convert.tools.gbrown.org
networks: ["nginx-bridge"]
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- ALLOW_UNAUTHENTICATED=true
@@ -37,7 +37,7 @@ services:
container_name: it-tools
hostname: it-tools
domainname: tools.gbrown.org
networks: ["nginx-bridge"]
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
labels:
@@ -45,12 +45,33 @@ services:
tty: true
restart: unless-stopped
pdf-tools:
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
container_name: pdf-tools
hostname: pdf-tools
domainname: pdf.tools.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- LANGS=en_US
- DOCKER_ENABLE_SECURITY=true
volumes:
- ./volumes/pdf-tools/trainingData:/usr/share/tessdata:Z
- ./volumes/pdf-tools/extraConfigs:/configs:Z
- ./volumes/pdf-tools/customFiles:/customFiles:Z
- ./volumes/pdf-tools/logs:/logs/:Z
- ./volumes/pdf-tools/pipeline:/pipeline/:Z
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
restart: unless-stopped
bento-pdf:
image: ghcr.io/alam00000/bentopdf:latest
container_name: bento-pdf
hostname: bento-pdf
domainname: bento.tools.gbrown.org
networks: ["nginx-bridge"]
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PUID=1000
@@ -61,28 +82,7 @@ services:
tty: true
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080"]
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:8080']
interval: 30s
timeout: 10s
retries: 3
# pdf-tools:
# image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
# container_name: pdf-tools
# hostname: pdf-tools
# domainname: pdf.tools.gbrown.org
# networks: ["nginx-bridge"]
# environment:
# - TZ=America/New_York
# - LANGS=en_US
# - DOCKER_ENABLE_SECURITY=true
# volumes:
# - ./volumes/pdf-tools/trainingData:/usr/share/tessdata:Z
# - ./volumes/pdf-tools/extraConfigs:/configs:Z
# - ./volumes/pdf-tools/customFiles:/customFiles:Z
# - ./volumes/pdf-tools/logs:/logs/:Z
# - ./volumes/pdf-tools/pipeline:/pipeline/:Z
# labels:
# com.centurylinklabs.watchtower.enable: "true"
# tty: true
# restart: unless-stopped
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Tools
After=network-online.target podman.socket
Wants=network-online.target
File renamed without changes.
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Uptime
After=network-online.target
Wants=network-online.target
@@ -20,7 +20,3 @@ SMTP_HOST=smtp.usesend.gbrown.org
SMTP_USER=usesend
USESEND_PORT=3000
USESEND_URL=https://usesend.gbrown.org
# Also set on the live machine
AWS_ACCESS_KEY_ID=CHANGE_ME
AWS_SECRET_ACCESS_KEY=CHANGE_ME
+88
View File
@@ -0,0 +1,88 @@
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
@@ -0,0 +1 @@
ADMIN_TOKEN=CHANGE_ME
+55
View File
@@ -0,0 +1,55 @@
# 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
@@ -1,4 +1,5 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Vaultwarden
After=network-online.target podman.socket
Wants=network-online.target
-48
View File
@@ -1,48 +0,0 @@
# 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
@@ -1,18 +0,0 @@
[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
@@ -1 +0,0 @@
GITEA_RUNNER_REGISTRATION_TOKEN=CHANGE_ME
-153
View File
@@ -1,153 +0,0 @@
# 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
@@ -1,18 +0,0 @@
[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
@@ -1,39 +0,0 @@
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
@@ -1,18 +0,0 @@
[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
@@ -1,2 +0,0 @@
RTSP_PASSWORD=CHANGE_ME
PLUS_API_KEY=CHANGE_ME
-79
View File
@@ -1,79 +0,0 @@
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
@@ -1,18 +0,0 @@
[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
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff. Show more