Panama learns what a server is: from a root login to running containers

A machine's role is now the interview's first question and the one answer
Panama records. Servers get the same shell minus the screen: core packages,
nvm, Bun, Claude Code and Codex (desktops get Codex too), linger, rootless
ports from 80, firewalld, the nginx-bridge network, and a nightly image
updater that replaced watchtower for cause.

server/containers/ carries junior's 23 compose services -- secrets moved to
per-machine .env files that never enter this public repo, every transformed
compose proven to render byte-identical to what is live. 'panama server'
enables, disables and relinks them; nothing here restarts a running service.
'boot --server' walks a fresh VPS from its root login to a normal install.

Five new contracts pin the secrets rule, the catalog's shape, panama-server's
behavior, the role plumbing, and the dotfile classification.

Claude-Session: https://claude.ai/code/session_01NU5JGiN3JfzqrLQB6wmJ1E
This commit is contained in:
Gabriel Brown
2026-08-25 23:11:49 -04:00
parent 9b338608ef
commit f33da41cc6
93 changed files with 4735 additions and 247 deletions
@@ -0,0 +1,230 @@
# Panama server role: headless installs and versioned containers
2026-08-25. Designed with Gib across two interview rounds; every decision below
was made explicitly, none inferred.
## Goal
Panama becomes the one repo for every Fedora machine Gib runs. A desktop gets
what it gets today. A server — a Hetzner VPS, a future home server — gets the
same shell environment (bash profile, prompt, tmux-on-ssh, agents) minus
everything that needs a screen, plus rootless podman, systemd user units, and a
versioned catalog of compose services that `panama server enable` brings up.
The repo stays public; secrets never enter it.
## Decisions of record
| Decision | Choice |
|---|---|
| Repo | One public monorepo. No companion repo. `.gitignore` + a secrets contract keep it safe |
| Role | Asked first in the interview (`desktop` / `server`), persisted to `~/.local/state/panama/role`; `--server` on `boot`/`install` presets it |
| Layout | Top-level `server/`, services under `server/containers/<Name>/` (TitleCase, matching junior) |
| Tracked per service | `compose.yml`, `podman-<name>.service`, `.env.example`. Nothing else |
| `~/Server` | Real directories. Only `compose.yml` is symlinked into them; units symlink into `~/.config/systemd/user/`. Live `.env` and data stay outside the repo tree entirely |
| Data dirs | Standardized to `./data/`, applied per service at cutover (a stop/rename/start), never at import |
| Enablement | `panama server` subcommand family, not the interview |
| Units | Checked in per service (they legitimately differ), installed as symlinks so edits flow with `panama update` |
| Updates | `panama update` relinks and `daemon-reload`s but never restarts a running service; it names services whose definitions changed |
| Nightly image updates | junior's `update-containers.sh` + `podman-update.timer` adopted into the repo (it replaced watchtower for cause) |
| Codex | Installed everywhere, same pattern as Claude Code |
| Ports | 80/443/81 only. Anything else is opened by hand and documented in the service's folder |
| VPS bootstrap | `boot` run as root walks user-creation/keys/sshd-hardening, each step skipped when already true, then re-runs as the user |
| Out of scope | server.gib (docker; translate later), backup and verify-rootless (own design later), cutover of junior's live services (later, per service) |
## 1. Machine role
- New interview question, asked first: "What is this machine?" — `desktop` or
`server` via `gum choose`. `./install --server` (and `boot --server`) records
the answer without asking.
- Persisted to `~/.local/state/panama/role`, plain text, one word. This is the
first durable answer Panama keeps, and deliberately so: the transient-answers
principle exists to keep *personal* data out of durable paths, and a machine
role is a fact about the machine. `install`, `panama update`, `panama doctor`
and future migrations read it. A machine with no role file is a desktop —
every machine that predates this work is one.
- On role=server the interview skips: hardware probes (NVIDIA, Secure Boot,
firmware — and their bootstrap tools; only `gum` is bootstrapped), the
debloat question, and the extras checklist. It keeps: hostname, git
identity, gh sign-in, SSH key, user-content.
## 2. Stages by role
Role=desktop is exactly today's behavior. Role=server runs:
| Stage | Server behavior |
|---|---|
| `install-packages` | Core + server lists (§3). No flatpaks, no COPRs, no desktop repos. Claude Code and Codex install for both roles |
| `link-dotfiles` | Universal dot dirs only: `btop`, `nvim`, `ohmyposh`, `tmux`, `vim`, `panama`. Desktop-only (everything else: `hypr`, `quickshell`, `kitty`, `ghostty`, `espanso`, `gtk-*`, `uwsm`, `vicinae`, `wofi`, `xdg-desktop-portal`) is skipped via an explicit classification list in the stage — a new dot dir must be classified or the contract fails. Bash profile links as today; Firefox/wallpaper/cursor seeding skipped |
| `link-user` | Unchanged (SSH aliases and agent rules are exactly what a server wants) |
| `setup-identity` | Unchanged |
| `setup-server` (new) | §4 |
| `link-server` (new) | Creates `~/Server`, then delegates to `panama-server relink` so link repair has one owner |
| Skipped | `link-skills` (all three shipped skills are desktop-specific today; revisit when a server skill exists), `change-settings`, `link-vicinae-scripts`, `install-hardware` |
Migrations run on servers too — they are self-guarding no-ops where they do
not apply. `panama doctor` learns the role: on a server it reports service
health via `panama-server status` instead of probing desktop units.
## 3. Package restructure
`setup/packages/` gains `core-packages` (both roles) and `server-packages`;
`initial-packages` keeps what is desktop-only. Nothing is added or dropped for
desktops — the union of core + desktop lists equals today's set.
- **core-packages**: awk, bat, btop, curl, dnf5-plugins, eza, fontconfig, fzf,
gh, git-all, gum, jq, neovim, oh-my-posh, openssh, openssl, podman,
podman-compose, python3-dnf, ripgrep, tmux, unzip, wget, zoxide.
(`podman` moves here from development-packages; `podman-compose` is what
provides `podman compose` on junior — verified, 1.6.0.)
- **server-packages**: nvm (Node via nvm, as on desktop — agents and MCP
servers want it, and Codex installs through npm).
- **initial-packages** (desktop-only remainder): cargo, rustup, flatpak, fwupd,
kitty, ksshaskpass, libselinux-utils, pciutils, python3-neovim,
wireguard-tools, vim-X11 — plus everything else currently there that the
core list did not claim.
- Codex CLI: installed in `install-packages` beside the Claude Code step —
skip when `codex` is on PATH, `npm install -g @openai/codex` (after nvm/node
is available), soft-fail like Claude Code does.
- The package-hash mechanic in `install` is untouched; new files under
`-maxdepth 1` join the hash automatically.
## 4. `setup-server` stage
Idempotent, like every stage; each item checks before acting:
1. `loginctl enable-linger $USER`.
2. `/etc/sysctl.d/99-rootless-ports.conf``net.ipv4.ip_unprivileged_port_start=80`,
applied with `sysctl --system` (junior already has exactly this file).
3. firewalld: open 80, 443, 81 (NPM admin portal), permanent + runtime.
Nothing else — reverse-proxy philosophy, ports open only when unavoidable.
4. `podman network create nginx-bridge` if absent (every compose expects it,
`external: true`).
5. Nightly image updates: `update-containers.sh` is adopted into the repo as
`server/scripts/update-containers` (no `.sh`, per repo rule — junior's copy
is imported and its `ExecStart`/log paths updated), with
`server/systemd/podman-update.service` and `.timer` symlinked into
`~/.config/systemd/user/` and the timer enabled. Logs go to
`~/Server/logs/` as today.
## 5. The `panama server` command
`bin/panama-server`, surfaced as `panama server <verb>` (README's subcommand
table and its contract update with it):
- `list` — every service in `server/containers/`, with enabled/active state.
- `enable <Name>` — create `~/Server/<Name>/`; symlink `compose.yml`; seed
`~/Server/<Name>/.env` from `.env.example` when the example exists and the
live file does not; if the seeded `.env` still contains placeholder values,
stop there and say what to fill in — do **not** start; otherwise symlink the
unit, `daemon-reload`, `enable --now`.
- `disable <Name>``disable --now`, remove the unit symlink,
`daemon-reload`. `~/Server/<Name>` and its data are never touched.
- `status [<Name>]` — unit state plus `podman compose ps` per service.
- `relink` — for every service whose unit symlink exists: refresh both
symlinks, `daemon-reload`, and report services whose definitions changed
since the last run (so the operator restarts deliberately). Called by the
`link-server` stage and by `panama update` on role=server.
Live `.env` files live in `~/Server/<Name>/`, not in the repo tree — the repo
carries only `.env.example`. `.gitignore` still covers `.env` everywhere under
`server/` as belt and braces.
## 6. VPS bootstrap (`boot` as root)
`boot` gains a root path. When `EUID == 0`, before anything else, walk these
steps, each checked and skipped when already true:
1. Ask for a username (default `gib`). Create it with `wheel` if absent; if it
exists, use it.
2. Ensure the user has a password (needed for sudo) — `passwd` interactively
if none is set.
3. Copy root's `authorized_keys` to the user when the user has none.
4. Offer sshd hardening (yes/no, default yes): drop-in under
`/etc/ssh/sshd_config.d/` with `PermitRootLogin no`,
`PasswordAuthentication no`, then reload sshd. Skipped when already set.
5. Move/clone the checkout under the user's home, chown it, and re-exec
`install --server` as that user.
Run as a normal user, `boot` behaves exactly as today (plus passing `--server`
through).
## 7. Importing junior's services (the first population)
A one-time operation, performed against junior over SSH, that **never stops,
starts, or restarts anything**. For each dedicated compose dir:
1. Copy `compose.yml` into `server/containers/<Name>/`.
2. Take the **installed** unit (`~/.config/systemd/user/podman-<name>.service`)
as truth; diff against the copy in the service dir and flag any divergence.
3. Move inline secrets out: secret-bearing values in the repo copy of
`compose.yml` become `${VAR}` interpolations; extracted values are written
to `~/Server/<Name>/.env` **on junior** (inert until cutover — the live
compose still carries them inline); `.env.example` with placeholder values
is committed. Services already using `.env`/`env_file` get an
`.env.example` generated from the live `.env` with values blanked.
4. Verify without touching the stack: `podman compose config` rendered from
the repo copy (with the live `.env`) must match the render of the live
compose. Volume paths are kept **verbatim** at import (`volumes/`,
`volume/`, `pg_data/` and all) so this diff can be exact; the `data/`
standardization happens per service at cutover.
5. Commit. The secrets contract (§8) gates every one of these commits.
**Import list (23):** Adminer, Agentchat, Authentik, Beszel, CalCom,
ConvexMonorepo, Documenso, Gitea, Infisical, ISponsorBlockTV, LashAddict, N8n,
Nginx_Proxy_Manager, Penpot, Portainer, PostgreSQL, Rustdesk, Spoon, StPeteIT,
Tools, Uptime, UseSend, Vaultwarden. CalCom, Documenso, Penpot and N8n are
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 —
Panama owning a second copy of their compose would drift), `ci/`, `logs/`,
and junior's loose scripts other than `update-containers.sh` (§4).
**Cutover** (per service, later, not this work): stop → rename the volume dir
to `data/` and update the repo compose to match → replace live compose with
the symlink, installed unit with the symlink → `daemon-reload` → start →
verify. Ten minutes a service, done when Gib chooses, favoring quiet hours.
## 8. Contracts and docs
New `tests/server/`, same discipline as the rest:
- `compose-secrets-contract` — fails when any tracked file under `server/`
contains an inline secret-looking value (the `user-content-contract`
pattern), when `.gitignore` stops covering `.env` under `server/`, or when
`git ls-files` shows a tracked `.env`.
- `containers-shape-contract` — every `server/containers/<Name>/` carries
`compose.yml` + `podman-<name>.service`; every unit's `WorkingDirectory` is
`%h/Server/<Name>`; every `${VAR}` used in a compose file appears in that
service's `.env.example`.
- `panama-server-contract` — throwaway `HOME`, stubbed `systemctl`/`podman` on
PATH: `enable` creates the right symlinks and seeds `.env`; placeholder
values block start; `disable` leaves data alone; `relink` reports changed
definitions.
- `role-contract` — the interview asks role first, `--server` presets it, the
role file is written, stages filter correctly per role (via the stage
scripts' static/stubbed seams).
- `dotfile-classification-contract` — every dir in `config/dot/` appears in
exactly one of link-dotfiles' universal/desktop lists.
- README: layout section gains `server/`, the subcommand table gains
`panama server`, the pinned contract count moves — `readme-contract` and the
interview/link-dotfiles contracts that assert today's behavior update in the
same change.
## Phasing
1. **Role plumbing** — interview question, role file, `--server`, package
split, stage filters, `setup-server`, boot's root path.
2. **Server skeleton**`server/` tree, `panama-server`, `link-server`,
update-timer adoption, contracts, README/docs.
3. **Import** — junior's 23 services into `server/containers/` per §7.
4. **Later, separately** — per-service cutover on junior; backup and
verify-rootless design; server.gib docker→podman translation; a real
end-to-end test on a fresh VPS.
Testing note: contracts for phases 12 run with stubs and throwaway HOMEs, but
per standing practice all suite runs batch at the end of the session with
Gib's go-ahead. Phase 3 touches junior only with read commands plus the inert
`.env` writes described in §7.
+7
View File
@@ -1,6 +1,13 @@
# Ignore bash environment variables, and the lock its writer takes. # Ignore bash environment variables, and the lock its writer takes.
/config/bash/env /config/bash/env
/config/bash/.env.lock /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/
# Personal espanso triggers (name, email), seeded per-machine by setup-identity. # Personal espanso triggers (name, email), seeded per-machine by setup-identity.
/config/dot/espanso/match/identity.yml /config/dot/espanso/match/identity.yml
# Ignore backups of old config files # Ignore backups of old config files
+32 -1
View File
@@ -29,6 +29,22 @@ It pulls, applies any repairs this machine has not had, and runs the stages
below that need no answers. `./install` remains what it is: how a machine is below that need no answers. `./install` remains what it is: how a machine is
built, and how you change an answer you gave. built, and how you change an answer you gave.
The first question the interview asks is what the machine is: a **desktop**
gets everything below; a **server** — a VPS, a headless box — gets the same
shell environment minus everything that needs a screen, plus rootless podman
and the compose services in [`server/`](server/README.md). The answer is the
one thing Panama records durably (`~/.local/state/panama/role`), because
`panama update` asks nothing and still has to know. A fresh server skips the
question entirely:
```sh
bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot) --server
```
That command also works from a brand-new VPS's **root** login: it creates
your user with sudo, moves the SSH key over, offers to harden sshd, and hands
off to a normal install as that user.
`install` asks its questions first and then runs the stages in `setup/scripts/` `install` asks its questions first and then runs the stages in `setup/scripts/`
in order, without stopping again: in order, without stopping again:
@@ -43,6 +59,15 @@ in order, without stopping again:
| `link-vicinae-scripts` | Publishes the Vicinae script commands | | `link-vicinae-scripts` | Publishes the Vicinae script commands |
| `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for | | `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 | | `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 |
| `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 —
`link-skills`, `change-settings`, `link-vicinae-scripts` and
`install-hardware` are desktop concerns. `link-dotfiles` links only the
universal dot dirs there (bash, nvim, tmux, btop and tmux theming, the hook
samples), and `install-packages` takes a short path: the core tools, nvm,
Bun, Claude Code and Codex — no third-party repos, no desktop, no flatpaks.
Then `panama migrate` applies any repairs this machine has not had yet. That is Then `panama migrate` applies any repairs this machine has not had yet. That is
the half of an upgrade installing cannot do: the stages above only ever add, so the half of an upgrade installing cannot do: the stages above only ever add, so
@@ -157,6 +182,9 @@ config/
extensions, linked into ~/.local/share extensions, linked into ~/.local/share
old/ Backups of whatever was replaced (gitignored) old/ Backups of whatever was replaced (gitignored)
wallpapers/ Copied into ~/Pictures/Wallpapers when absent wallpapers/ Copied into ~/Pictures/Wallpapers when absent
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 skills/ Agent skills for operating this desktop, linked into
~/.claude/skills ~/.claude/skills
setup/ setup/
@@ -170,7 +198,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
180 of them, under `tests/`. Run the lot, or a subset by pattern: 185 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh ```sh
panama test # everything panama test # everything
@@ -190,6 +218,8 @@ been ones that source text looked fine for.
tests/setup/ The installer: the interview, package lists, hardware, extras tests/setup/ The installer: the interview, package lists, hardware, extras
tests/quickshell/ The shell and its settings pages tests/quickshell/ The shell and its settings pages
tests/hypr/ The compositor config tests/hypr/ The compositor config
tests/server/ The server role: the service catalog's shape, the secrets
rule that keeps a public repo safe, and panama-server
``` ```
## Projects ## Projects
@@ -220,6 +250,7 @@ panama migrate # apply repairs this machine has not had yet
panama upgrade # re-run ./install from anywhere, interview and all panama upgrade # re-run ./install from anywhere, interview and all
panama apps # choose applications to install, by category panama apps # choose applications to install, by category
panama app # applications no repository carries; build one by name panama app # applications no repository carries; build one by name
panama server # the services a server runs: list, enable, disable, status
``` ```
`panama update` and `panama sync` are separate verbs on purpose. One acts on `panama update` and `panama sync` are separate verbs on purpose. One acts on
+3
View File
@@ -103,6 +103,8 @@ ${BOLD}Commands:${RESET}
install. install.
${GREEN}app${RESET} Build and install an application that neither dnf nor ${GREEN}app${RESET} Build and install an application that neither dnf nor
Flathub carries. With no name, lists what is available. Flathub carries. With no name, lists what is available.
${GREEN}server${RESET} The compose services a server machine runs: list, enable,
disable, status, relink. See 'panama server help'.
${GREEN}help${RESET} Show this help (also -h, --help). ${GREEN}help${RESET} Show this help (also -h, --help).
${BOLD}Options:${RESET} ${BOLD}Options:${RESET}
@@ -892,6 +894,7 @@ main() {
migrate) shift; cmd_migrate "$@" ;; migrate) shift; cmd_migrate "$@" ;;
app) shift; cmd_app "$@" ;; app) shift; cmd_app "$@" ;;
apps) shift; cmd_apps "$@" ;; apps) shift; cmd_apps "$@" ;;
server) shift; exec "$PANAMA_DIR/bin/panama-server" "$@" ;;
help|-h|--help|"") usage ;; help|-h|--help|"") usage ;;
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;; --version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
*) *)
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env bash
#
# panama-server the compose services a server machine runs.
#
# 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,
# gitignored or not) and the bind-mounted data. Only the tracked files are
# symlinked, so a `git clean` in the repo can never reach a database.
#
# list every service in the catalog, with its state here
# enable <Name> link it into ~/Server, seed .env, enable the unit
# disable <Name> stop it and remove the unit; data and .env stay put
# status [<Name>] what is actually running
# relink refresh the symlinks for everything enabled here
#
# Verbs act on the unit and the links, never on data: there is deliberately no
# verb here that deletes ~/Server/<Name> or anything in it.
set -uo pipefail
PROGRAM="panama server"
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
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}"
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"
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
BOLD=$(tput bold); RESET=$(tput sgr0)
RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); BLUE=$(tput setaf 4)
else
BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""
fi
info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; }
ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; }
warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; }
err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
usage() {
cat <<EOF
${BOLD}$PROGRAM${RESET} manage the compose services in server/containers/
${BOLD}Usage:${RESET}
$PROGRAM list Every service in the catalog, and its state here
$PROGRAM enable <Name> Link a service into ~/Server, seed its .env from
.env.example, and enable its unit. Stops short of
starting when the .env still holds CHANGE_ME
placeholders, and says which.
$PROGRAM disable <Name> Stop the unit and unlink it. ~/Server/<Name>,
its .env and its data are never touched.
$PROGRAM status [<Name>] What is actually running, per service
$PROGRAM relink Refresh every enabled service's symlinks after a
pull, and name the ones whose definitions changed
(nothing is restarted for you)
EOF
}
# ── The catalog ──────────────────────────────────────────────────────────────
services() {
local dir
for dir in "$SERVER_DIR"/*/; do
[[ -d "$dir" ]] || continue
basename "$dir"
done
}
require_service() {
local name="$1"
if [[ ! -d "$SERVER_DIR/$name" ]]; then
err "No such service: '$name'"
printf 'The catalog has:\n' >&2
services | sed 's/^/ /' >&2
exit 1
fi
}
# The one unit file a service directory carries. Its filename is its identity
# -- podman-<name>.service, exactly what lands in the systemd user directory --
# so nothing here invents a name that could drift from the file's.
unit_path_for() {
local name="$1" unit
for unit in "$SERVER_DIR/$name"/*.service; do
[[ -e "$unit" ]] || continue
printf '%s' "$unit"
return 0
done
return 1
}
unit_name_for() {
local unit
unit="$(unit_path_for "$1")" || return 1
basename "$unit"
}
# Installed means our symlink is in the systemd user directory. A regular file
# of the same name is a pre-Panama install done by hand -- reported, never
# silently replaced.
unit_installed() {
local unit_name
unit_name="$(unit_name_for "$1")" || return 1
[[ -L "$UNIT_DIR/$unit_name" ]]
}
definition_hash() {
local name="$1" unit
unit="$(unit_path_for "$name")" || unit=/dev/null
cat "$SERVER_DIR/$name/compose.yml" "$unit" 2>/dev/null | sha256sum | cut -d' ' -f1
}
recorded_hash() {
[[ -r "$STATE_FILE" ]] || return 0
awk -v name="$1" '$1 == name {print $2}' "$STATE_FILE"
}
record_hash() {
local name="$1" hash="$2"
mkdir -p "$(dirname "$STATE_FILE")"
{ [[ -r "$STATE_FILE" ]] && awk -v name="$name" '$1 != name' "$STATE_FILE"; \
printf '%s %s\n' "$name" "$hash"; } >"$STATE_FILE.tmp"
mv "$STATE_FILE.tmp" "$STATE_FILE"
}
# Replace path with a symlink to src. An existing regular file is moved aside
# with its name intact plus a suffix, because the only regular file that can be
# here is a pre-Panama one somebody wrote by hand -- worth keeping to diff.
link_file() {
local src="$1" path="$2"
if [[ -L "$path" ]]; then
[[ "$(readlink -f "$path")" == "$(readlink -f "$src")" ]] && return 0
rm "$path"
elif [[ -e "$path" ]]; then
mv "$path" "$path.pre-panama"
warn "Kept the existing $(basename "$path") as $(basename "$path").pre-panama"
fi
ln -s "$src" "$path"
}
# ── Verbs ────────────────────────────────────────────────────────────────────
cmd_list() {
local name unit_name state enabled
for name in $(services); do
unit_name="$(unit_name_for "$name")" || { printf '%-24s %s\n' "$name" "no unit file"; continue; }
if unit_installed "$name"; then
state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)"
enabled="enabled"
[[ "$state" == active ]] && state="${GREEN}active${RESET}" || state="${YELLOW}${state:-unknown}${RESET}"
printf '%-24s %s, %b\n' "$name" "$enabled" "$state"
elif [[ -e "$UNIT_DIR/$unit_name" ]]; then
printf '%-24s %s\n' "$name" "installed by hand (not Panama's symlink)"
else
printf '%-24s %s\n' "$name" "-"
fi
done
}
cmd_enable() {
local name="${1:-}"
[[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM enable <Name>"; exit 1; }
require_service "$name"
local repo_dir="$SERVER_DIR/$name" live_dir="$TARGET_DIR/$name"
[[ -f "$repo_dir/compose.yml" ]] || { err "$name has no compose.yml in the catalog"; exit 1; }
local unit_path unit_name
unit_path="$(unit_path_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
unit_name="$(basename "$unit_path")"
mkdir -p "$live_dir"
link_file "$repo_dir/compose.yml" "$live_dir/compose.yml"
ok "Linked compose.yml → $live_dir/compose.yml"
# The .env lives with the machine, not the checkout. Seeded once from the
# example and never overwritten -- it is where the person's secrets go.
if [[ -f "$repo_dir/.env.example" && ! -e "$live_dir/.env" ]]; then
cp "$repo_dir/.env.example" "$live_dir/.env"
chmod 600 "$live_dir/.env"
ok "Seeded $live_dir/.env from .env.example"
fi
# Refusing to start on placeholders, and saying which: a service brought up
# with CHANGE_ME as its database password does not fail loudly, it runs --
# and what it runs is a service whose password is CHANGE_ME.
if [[ -e "$live_dir/.env" ]] && grep -q 'CHANGE_ME' "$live_dir/.env"; then
warn "$live_dir/.env still has placeholder values:"
grep -n 'CHANGE_ME' "$live_dir/.env" | sed 's/^/ /'
printf 'Fill them in, then run: %s enable %s\n' "$PROGRAM" "$name"
exit 1
fi
mkdir -p "$UNIT_DIR"
link_file "$unit_path" "$UNIT_DIR/$unit_name"
systemctl --user daemon-reload
if systemctl --user enable --now "$unit_name"; then
record_hash "$name" "$(definition_hash "$name")"
ok "$name enabled and started ($unit_name)"
else
err "$unit_name did not start; see: systemctl --user status $unit_name"
exit 1
fi
}
cmd_disable() {
local name="${1:-}"
[[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM disable <Name>"; exit 1; }
require_service "$name"
local unit_name
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
systemctl --user disable --now "$unit_name" 2>/dev/null || true
if [[ -L "$UNIT_DIR/$unit_name" ]]; then
rm "$UNIT_DIR/$unit_name"
systemctl --user daemon-reload
fi
ok "$name disabled. ~/Server/$name, its .env and its data were not touched."
}
cmd_status() {
local name="${1:-}"
if [[ -n "$name" ]]; then
require_service "$name"
local unit_name
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file"; exit 1; }
systemctl --user status --no-pager "$unit_name" || true
if [[ -d "$TARGET_DIR/$name" ]]; then
( cd "$TARGET_DIR/$name" && podman compose ps 2>/dev/null ) || true
fi
return 0
fi
# The role's health summary: what is enabled here, and is it actually up.
local any=0 unit_name state
for name in $(services); do
unit_installed "$name" || continue
any=1
unit_name="$(unit_name_for "$name")"
state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)"
if [[ "$state" == active ]]; then
ok "$name"
else
err "$name is ${state:-unknown}"
fi
done
(( any )) || info "No services enabled on this machine yet. See: $PROGRAM list"
return 0
}
cmd_relink() {
local name unit_path unit_name changed=() unmanaged=() refreshed=0
for name in $(services); do
unit_path="$(unit_path_for "$name")" || continue
unit_name="$(basename "$unit_path")"
if [[ -e "$UNIT_DIR/$unit_name" && ! -L "$UNIT_DIR/$unit_name" ]]; then
unmanaged+=("$name")
continue
fi
unit_installed "$name" || continue
link_file "$unit_path" "$UNIT_DIR/$unit_name"
mkdir -p "$TARGET_DIR/$name"
link_file "$SERVER_DIR/$name/compose.yml" "$TARGET_DIR/$name/compose.yml"
refreshed=$((refreshed + 1))
# Changed since the last time this machine looked, which is what makes a
# pull actionable: the restart is deliberately yours to run, so the least
# this can do is say which services are running a definition that is no
# longer what the repository says.
local now
now="$(definition_hash "$name")"
if [[ "$(recorded_hash "$name")" != "$now" ]]; then
changed+=("$name")
record_hash "$name" "$now"
fi
done
systemctl --user daemon-reload 2>/dev/null || true
info "Relinked $refreshed enabled service(s)"
if (( ${#unmanaged[@]} > 0 )); then
warn "Installed by hand, left alone: ${unmanaged[*]}"
fi
if (( ${#changed[@]} > 0 )); then
warn "Definitions changed; restart each when ready:"
for name in "${changed[@]}"; do
printf ' %s: systemctl --user restart %s\n' "$name" "$(unit_name_for "$name")"
done
fi
}
# ── Dispatcher ───────────────────────────────────────────────────────────────
main() {
if [[ ! -d "$SERVER_DIR" ]]; then
err "No service catalog at $SERVER_DIR"
exit 1
fi
local cmd="${1:-}"
case "$cmd" in
list) shift; cmd_list "$@" ;;
enable) shift; cmd_enable "$@" ;;
disable) shift; cmd_disable "$@" ;;
status) shift; cmd_status "$@" ;;
relink) shift; cmd_relink "$@" ;;
help|-h|--help|"") usage ;;
*)
err "Unknown command: '$cmd'"
echo
usage
exit 1
;;
esac
}
main "$@"
+116 -9
View File
@@ -3,12 +3,17 @@
# Panama's front door: the one command a fresh Fedora machine needs. # Panama's front door: the one command a fresh Fedora machine needs.
# #
# bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot) # bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot)
# bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot) --server
# #
# Deliberately dumb, because a copy of this script leaves the repository the # Deliberately dumb, because a copy of this script leaves the repository the
# moment somebody curls it -- nothing here can be fixed by re-running # moment somebody curls it -- nothing here can be fixed by re-running
# ./install, so there is as little here as possible: get git, get the clone, # ./install, so there is as little here as possible: get git, get the clone,
# hand off. Everything with judgment in it lives in `install`, which is also # hand off. Everything with judgment in it lives in `install`, which is also
# where re-runs and upgrades already work. # where re-runs and upgrades already work.
#
# The one exception to dumb is the root path below, which cannot live in
# `install`: a fresh VPS hands you a root login and nothing else, and the user
# that `install` needs to exist is exactly what has not been created yet.
set -euo pipefail set -euo pipefail
@@ -16,17 +21,119 @@ REPO_URL="https://git.gbrown.org/gib/Panama.git"
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
export PANAMA_PATH export PANAMA_PATH
# Root would put the clone and every dotfile in root's home and run the SERVER=0
# desktop setup for the wrong user. sudo is used inside where it is needed. INSTALL_ARGS=()
if [[ "$(id -u)" -eq 0 ]]; then for arg in "$@"; do
echo "Run this as your own user, not root: the install configures YOUR desktop." >&2 case "$arg" in
--server) SERVER=1; INSTALL_ARGS+=(--server) ;;
*)
printf 'boot: unknown argument: %s\n' "$arg" >&2
printf 'usage: boot [--server]\n' >&2
exit 2 ;;
esac
done
# Panama assumes Fedora's repositories and package names.
if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then
echo "This looks like something other than Fedora; Panama only supports Fedora." >&2
exit 1 exit 1
fi fi
# Panama assumes Fedora's repositories, package names, and GNOME base install. # ── Root ─────────────────────────────────────────────────────────────────────
if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then #
echo "This looks like something other than Fedora; Panama only supports Fedora Workstation." >&2 # On a desktop, root is a mistake: the clone and every dotfile would land in
# root's home and configure the wrong user. On a fresh VPS it is the starting
# condition -- Hetzner hands over a root login and nothing else -- so with
# --server this walks the machine from that to a normal Panama install: a
# user with sudo, keys, an optionally hardened sshd, and `install --server`
# running as that user. Every step checks before acting, because the machine
# may be anywhere along this path already: a user half-created by hand, keys
# already copied, sshd already locked down.
if [[ "$(id -u)" -eq 0 ]]; then
if (( ! SERVER )); then
echo "Run this as your own user, not root: the install configures YOUR desktop." >&2
echo "Setting up a fresh server from its root login is: boot --server" >&2
exit 1 exit 1
fi
# Everything here asks, and a root shell from `bash <(curl ...)` can have
# the pipe as stdin, so every prompt reads the terminal explicitly.
if ! (exec </dev/tty) 2>/dev/null; then
echo "No terminal to ask on; run this from an interactive root shell." >&2
exit 1
fi
printf 'Username for this server [gib]: '
read -r username </dev/tty || username=""
username="${username:-gib}"
if id -u "$username" >/dev/null 2>&1; then
echo "User $username already exists"
# wheel is what makes sudo work on Fedora; a user created by hand may not
# have it, and everything after this depends on it.
id -nG "$username" | grep -qw wheel || usermod -aG wheel "$username"
else
echo "Creating $username with sudo (wheel)"
useradd -m -G wheel "$username"
fi
# useradd leaves the account locked, and sudo asks for this password -- a
# user who cannot sudo is a user the install cannot run as.
if ! passwd -S "$username" 2>/dev/null | awk '{exit $2 != "PS" && $2 != "P"}'; then
echo "Set a password for $username (sudo will ask for it):"
passwd "$username" </dev/tty
fi
# The key that reached root is the key that should reach the user, or the
# next SSH login has no way in once root logins are closed below.
user_home="$(getent passwd "$username" | cut -d: -f6)"
if [[ -s /root/.ssh/authorized_keys && ! -s "$user_home/.ssh/authorized_keys" ]]; then
echo "Copying root's authorized_keys to $username"
mkdir -p "$user_home/.ssh"
cp /root/.ssh/authorized_keys "$user_home/.ssh/authorized_keys"
chmod 700 "$user_home/.ssh"
chmod 600 "$user_home/.ssh/authorized_keys"
chown -R "$username:$username" "$user_home/.ssh"
fi
# Offered rather than imposed, defaulting to yes: a VPS keeps its provider's
# web console, so locking password and root logins out of sshd is
# recoverable even when it goes wrong. Written as a drop-in so it never
# fights the distribution's own sshd_config.
SSHD_DROPIN=/etc/ssh/sshd_config.d/90-panama.conf
if [[ -f "$SSHD_DROPIN" ]]; then
echo "sshd is already hardened ($SSHD_DROPIN)"
else
printf 'Harden sshd (disable root login and password auth)? [Y/n]: '
read -r harden </dev/tty || harden=""
if [[ ! "$harden" =~ ^[Nn] ]]; then
printf 'PermitRootLogin no\nPasswordAuthentication no\n' >"$SSHD_DROPIN"
systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true
echo "Wrote $SSHD_DROPIN; make sure your key works before logging out."
fi
fi
if ! command -v git >/dev/null 2>&1; then
echo "Installing git, which the clone needs"
dnf install -y git
fi
# Cloned straight into the user's home and owned by them: this is the
# checkout `panama update` will pull from for the life of the machine, and
# a root-owned .git in a user's home is a wound that never heals.
PANAMA_PATH="$user_home/.local/share/Panama"
if [[ -d "$PANAMA_PATH/.git" ]]; then
echo "Panama is already cloned at $PANAMA_PATH; updating"
runuser -u "$username" -- git -C "$PANAMA_PATH" pull --ff-only \
|| echo "Could not fast-forward; installing from the clone as it is" >&2
else
runuser -u "$username" -- mkdir -p "$user_home/.local/share"
runuser -u "$username" -- git clone "$REPO_URL" "$PANAMA_PATH"
fi
echo "Handing off to install as $username"
exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" --server </dev/tty
fi fi
# git is the one dependency the clone itself needs. Everything else -- gum # git is the one dependency the clone itself needs. Everything else -- gum
@@ -54,6 +161,6 @@ fi
# The probe actually opens /dev/tty rather than testing -r: a process with no # The probe actually opens /dev/tty rather than testing -r: a process with no
# controlling terminal passes -r and then fails the redirect. # controlling terminal passes -r and then fails the redirect.
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
exec "$PANAMA_PATH/install" </dev/tty exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
fi fi
exec "$PANAMA_PATH/install" exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
+65 -8
View File
@@ -34,19 +34,25 @@ PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
UPGRADE=0 UPGRADE=0
FORCE_PACKAGES=0 FORCE_PACKAGES=0
ROLE_PRESET=""
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--upgrade) UPGRADE=1 ;; --upgrade) UPGRADE=1 ;;
--packages) FORCE_PACKAGES=1 ;; --packages) FORCE_PACKAGES=1 ;;
--server) ROLE_PRESET=server ;;
-h|--help) -h|--help)
cat <<'USAGE' cat <<'USAGE'
usage: install [--upgrade] [--packages] usage: install [--upgrade] [--packages] [--server]
(no arguments) Build this machine. Asks the interview, runs every stage. (no arguments) Build this machine. Asks the interview, runs every stage.
--upgrade Update a machine that already exists. Asks nothing, and --upgrade Update a machine that already exists. Asks nothing, and
skips setup-identity and install-hardware. skips setup-identity and install-hardware.
--packages Run install-packages even when the lists are unchanged. --packages Run install-packages even when the lists are unchanged.
Only meaningful with --upgrade; a full install always runs it. Only meaningful with --upgrade; a full install always runs it.
--server Answer the interview's role question with 'server' without
being asked -- the curl-onto-a-fresh-VPS path. The rest of
the interview still runs. Meaningless with --upgrade, which
reads the role this machine already recorded.
USAGE USAGE
exit 0 ;; exit 0 ;;
*) *)
@@ -119,9 +125,16 @@ record_packages_hash() {
if (( ! UPGRADE )); then if (( ! UPGRADE )); then
bootstrap=() bootstrap=()
command -v gum >/dev/null 2>&1 || bootstrap+=(gum) command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
# The probe tools serve only the hardware questions, which a server is never
# asked -- installing lspci on a VPS to not use it would be the interview
# costing packages the machine has no reason to carry. Only gated when the
# role is already known; a plain ./install on a server still bootstraps them,
# harmlessly, because the role is not known until the interview answers.
if [[ "$ROLE_PRESET" != server ]]; then
command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils) command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils)
command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil) command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil)
command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd) command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd)
fi
if (( ${#bootstrap[@]} > 0 )); then if (( ${#bootstrap[@]} > 0 )); then
echo "Installing what the setup questions are built on: ${bootstrap[*]}" echo "Installing what the setup questions are built on: ${bootstrap[*]}"
sudo dnf install -y "${bootstrap[@]}" >/dev/null || { sudo dnf install -y "${bootstrap[@]}" >/dev/null || {
@@ -190,16 +203,35 @@ if (( ! UPGRADE )); then
PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)" PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)"
export PANAMA_ANSWERS export PANAMA_ANSWERS
if ! "$PANAMA_PATH/setup/scripts/interview"; then if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then
exit 1 exit 1
fi fi
# shellcheck source=/dev/null # shellcheck source=/dev/null
source "$PANAMA_ANSWERS" source "$PANAMA_ANSWERS"
export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \ export PANAMA_ROLE PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL \
PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA PANAMA_MOK_HASH \ PANAMA_GIT_EDITOR PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA \
PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS PANAMA_USER_CONTENT PANAMA_MOK_HASH PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS \
PANAMA_USER_CONTENT
fi fi
# ── The role ─────────────────────────────────────────────────────────────────
#
# The one interview answer that outlives the run, because every later
# `panama update` runs with no interview and still has to know which machine
# this is. A fresh install records what was just answered; an upgrade reads
# what an earlier install recorded, defaulting to desktop -- which is what
# every machine that predates roles is. Exported so each stage sees the same
# answer through setup/lib/machine-role without re-deriving it.
# shellcheck source=setup/lib/machine-role
source "$PANAMA_PATH/setup/lib/machine-role"
if (( ! UPGRADE )); then
PANAMA_ROLE="${PANAMA_ROLE:-desktop}"
panama_role_record "$PANAMA_ROLE"
else
PANAMA_ROLE="$(panama_role)"
fi
export PANAMA_ROLE
# One password, before anything long runs, and then never again. The stages # One password, before anything long runs, and then never again. The stages
# call sudo dozens of times across twenty-plus minutes, and the timestamp # call sudo dozens of times across twenty-plus minutes, and the timestamp
# expires five minutes after whichever call came last -- so a single dnf step # expires five minutes after whichever call came last -- so a single dnf step
@@ -223,7 +255,19 @@ if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then
echo "Hostname set to: $(hostname)" echo "Hostname set to: $(hostname)"
fi fi
STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware) # One list per role, chosen whole rather than filtered from a superset, so
# what a server runs is readable here rather than derived. A server gets the
# shared stages plus its own two; it never links skills (all three shipped
# skills operate the desktop), never touches gsettings or Vicinae, and has no
# hardware stage -- NVIDIA, Secure Boot and firmware are first-boot desktop
# concerns. setup-server runs after packages (it needs podman and firewalld
# installed) and link-server after that, so the units it links land on a
# machine already able to run them.
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)
fi
# The two an upgrade drops. Both exist only to act on interview answers, and # The two an upgrade drops. Both exist only to act on interview answers, and
# both are first-run decisions: who you are and what hardware this is. Filtered # both are first-run decisions: who you are and what hardware this is. Filtered
@@ -297,10 +341,21 @@ fi
# It never changes the exit code. On a fresh machine it legitimately reports # It never changes the exit code. On a fresh machine it legitimately reports
# things as unconfigured -- no Home Assistant token yet, Nextcloud not signed in # things as unconfigured -- no Home Assistant token yet, Nextcloud not signed in
# -- and failing an install over those would be crying wolf. # -- and failing an install over those would be crying wolf.
doctor="$PANAMA_PATH/config/dot/quickshell/scripts/panama-doctor" # On a server the quickshell doctor would report a desktop that was never
if [[ -x "$doctor" ]]; then # installed; what is actually running there is the container services, and
# panama-server status is the check that answers for them.
if [[ "$PANAMA_ROLE" == server ]]; then
server_status="$PANAMA_PATH/bin/panama-server"
if [[ -x "$server_status" ]]; then
printf '\n=== health ===\n'
"$server_status" status || true
fi
else
doctor="$PANAMA_PATH/config/dot/quickshell/scripts/panama-doctor"
if [[ -x "$doctor" ]]; then
printf '\n=== health ===\n' printf '\n=== health ===\n'
"$doctor" --summary || true "$doctor" --summary || true
fi
fi fi
# Whatever this particular machine wants doing that Panama should not carry for # Whatever this particular machine wants doing that Panama should not carry for
@@ -318,6 +373,8 @@ fi
if (( ${#failed[@]} == 0 )); then if (( ${#failed[@]} == 0 )); then
if (( UPGRADE )); then if (( UPGRADE )); then
echo "Panama is up to date." echo "Panama is up to date."
elif [[ "$PANAMA_ROLE" == server ]]; then
echo "Panama installed. Enable a service with: panama server enable <Name>"
else else
echo "Panama installed. Log out and choose the Hyprland session to start it." echo "Panama installed. Log out and choose the Hyprland session to start it."
fi fi
+82
View File
@@ -0,0 +1,82 @@
# The server role
What a Panama machine runs when the interview answered `server`: the same
shell environment as every other machine, minus everything that needs a
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
scripts/ update-containers, the nightly image updater
systemd/ The units behind it, linked by setup-server
```
## The contract with ~/Server
The repository carries the definitions; the machine carries the state.
`panama server enable <Name>` creates `~/Server/<Name>/` as a **real
directory** and symlinks only `compose.yml` into it; the unit symlinks into
`~/.config/systemd/user/`. The `.env` (seeded once from `.env.example`, then
yours) and the bind-mounted `data/` live in `~/Server/<Name>/` and are never
inside the checkout — so nothing git can do, `clean -fdx` included, can reach
a database.
Because of that split, compose files must reference secrets as `${VAR}`
interpolations resolved from the `.env` beside them, never inline.
`tests/server/compose-secrets-contract` fails the suite when a tracked file
under `server/` carries anything that looks like a secret: this repository is
public, and the gitignore is a seatbelt, not the brakes.
Placeholders in `.env.example` are spelled `CHANGE_ME`, exactly — that token
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`.
- 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
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.
- Every compose joins `nginx-bridge` (external; created by setup-server).
## Enabling, updating, cutting over
```sh
panama server list # the catalog, and what this machine runs
panama server enable Gitea # link, seed .env, enable --now
panama server status # is what is enabled actually up
panama server disable Gitea # stop and unlink; data and .env stay
```
`panama update` relinks and reloads but **never restarts a running
service** — it names the ones whose definitions changed and leaves the
restart to you.
Moving a service that already runs from hand-managed files onto the repo's
(the cutover): stop the unit, standardize the volume directory to `data/`
(updating the repo compose to match), then `panama server enable <Name>`
it backs up the hand-written files as `*.pre-panama` and links the tracked
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
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.
## Nightly updates
`server/scripts/update-containers` pulls images and restarts changed
services through their units, nightly at midnight via `podman-update.timer`.
It replaced watchtower after watchtower recreated a container outside its
compose pod and took Gitea down for three days — the script's header carries
the full story, plus the SKIP list for services that must only ever be
updated by hand (postgresql, authentik).
+44
View File
@@ -0,0 +1,44 @@
# Adminer — database admin UI. VPS (ROOTLESS PODMAN), added 2026-08-12.
#
# On the home server this shipped alongside a MySQL container in the same compose
# file. Here it is standalone and defaults to the shared PostgreSQL instance, which
# is where the 14 databases now live (authentik, infisical_db, n8n, npm, the payload_*
# and convex databases, usesend, lashaddict-payload, spoon_convex...).
#
# MySQL stays on the home server — nothing on the VPS uses it.
#
# It can still reach any other database reachable on nginx-bridge; ADMINER_DEFAULT_SERVER
# only pre-fills the server field on the login form.
#
# ⚠️ SECURITY: Adminer has NO authentication of its own — it is a login form that
# forwards credentials to whichever database you name. Publishing it means exposing a
# database login prompt to the internet. Put it behind authentik forward-auth in NPM
# (same pattern as sonarr/prowlarr on the home server), or restrict it to the LAN by
# giving it only a UniFi record and no Cloudflare record.
#
# Rootless adaptations: no bind mounts (stateless, so no :Z needed), no published
# ports — NPM proxies to http://adminer:8080 over nginx-bridge.
#
# NPM: adminer.gbrown.org -> http://adminer:8080
networks:
nginx-bridge:
external: true
services:
adminer:
image: docker.io/library/adminer:latest
container_name: adminer
hostname: adminer
domainname: adminer.gbrown.org
networks:
- nginx-bridge
environment:
- TZ=America/New_York
# Pre-fills the server field; any nginx-bridge host can still be typed in.
- ADMINER_DEFAULT_SERVER=postgresql
- ADMINER_DESIGN=dracula
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Adminer
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Adminer
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+27
View File
@@ -0,0 +1,27 @@
networks:
nginx-bridge:
external: true # ALWAYS external; see AGENTS.md §5
services:
agentchat:
image: git.gbrown.org/gib/agentchat:latest
container_name: agentchat
hostname: agentchat
domainname: agentchat.gbrown.org
networks: ['nginx-bridge']
ports:
# Loopback only: lets this host's own agent reach the hub at
# http://127.0.0.1:8080 without going through NPM - the LAN access
# list there blocks the VPS's public IP. Unreachable from elsewhere.
- '127.0.0.1:8080:8080'
environment:
- TZ=America/New_York
- AGENTCHAT_DB=/data/agentchat.db
volumes:
- ./data:/data:z # lowercase :z — watchtower recreates DROP the relabel flag, and :Z-category files would lock the new container out (learned 2026-08-13); :z keeps them accessible
labels: ['com.centurylinklabs.watchtower.enable=true']
restart: unless-stopped
healthcheck:
test: wget -qO- http://localhost:8080/healthz || exit 1
interval: 30s
start_period: 10s
@@ -0,0 +1,24 @@
# TEMPLATE for a rootless user unit.
# Install to ~/.config/systemd/user/podman-<name>.service then:
# systemctl --user daemon-reload && systemctl --user enable --now podman-<name>.service
# Requires linger: sudo loginctl enable-linger $USER
[Unit]
Description=Podman Compose: agentchat
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Agentchat
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+20
View File
@@ -0,0 +1,20 @@
PG_PASS=CHANGE_ME
AUTHENTIK_SECRET_KEY=CHANGE_ME
AUTHENTIK_ERROR_REPORTING__ENABLED=true
COMPOSE_PORT_HTTP=9000
COMPOSE_PORT_HTTPS=9443
AUTHENTIK_TAG=2026.2.2
# SMTP Host Emails are sent to
AUTHENTIK_EMAIL__HOST=smtp.mail.me.com
AUTHENTIK_EMAIL__PORT=587
# Optionally authenticate (don't add quotation marks to your password)
AUTHENTIK_EMAIL__USERNAME=CHANGE_ME
AUTHENTIK_EMAIL__PASSWORD=CHANGE_ME
# Use StartTLS
AUTHENTIK_EMAIL__USE_TLS=true
# Use SSL
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]
+93
View File
@@ -0,0 +1,93 @@
# Authentik — VPS (ROOTLESS PODMAN) copy of the home server's auth stack.
#
# This is a 1:1 copy of ~/Server/auth on server.gib, migrated 2026-08-11. The home
# instance is still running and authoritative; this one has an independent database
# restored from a dump taken at migration time, so THE TWO DIVERGE FROM THAT MOMENT ON.
# Do not treat this as a hot standby -- it is a rehearsal/cutover target.
#
# Differences from the home Docker/root version, and why:
#
# :z on ./volumes/media (LOWERCASE, shared)
# Both server and worker mount this same path. `:Z` assigns a PRIVATE SELinux MCS
# category per container, so the second container to start would relabel it and
# lock the first one out. Shared mounts must use `:z`. The per-container mounts
# (server/custom-templates, worker/custom-templates, worker/certs) are exclusive
# and correctly use `:Z`.
#
# No redis anywhere
# authentik 2026.x dropped the Redis dependency (Postgres-backed now). The home
# stack has no redis container and no AUTHENTIK_REDIS__* vars either -- verified,
# not assumed. Do not "helpfully" add one.
#
# Postgres is the shared VPS instance
# AUTHENTIK_POSTGRESQL__HOST=postgresql resolves over nginx-bridge to the same
# container N8n and NPM use. Role + database `authentik` were created there with
# the same PG_PASS as home, so .env needed no edits.
#
# .env is copied verbatim from home and contains AUTHENTIK_SECRET_KEY. That key MUST
# match the one the database was encrypted with, or tokens and stored secrets break.
# It is mode 600 -- never print it, never commit it.
#
# NOT YET SERVING auth.gbrown.org. That DNS record still points home. Cutover = create
# an explicit auth.gbrown.org record pointing at this VPS (an explicit record overrides
# the *.gbrown.org wildcard) plus an NPM proxy host to http://authentik-server:9000.
networks:
nginx-bridge:
external: true
services:
server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
container_name: authentik-server
hostname: authentik-server
domainname: auth.gbrown.org
networks:
- nginx-bridge
command: server
# Bound to the WireGuard address ONLY -- never listens on eth0, so this is not
# reachable from the internet regardless of firewall state. Home's NPM uses it for
# forward-auth (14 proxy hosts point at http://192.168.2.2:9000/outpost.goauthentik.io).
# Do NOT change this to a bare "9000:9000"; that would expose it on the public
# interface. Port 9443 is deliberately NOT published -- portainer already uses it.
ports:
- "192.168.2.2:9000:9000"
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
TZ: America/New_York
env_file:
- .env
volumes:
- ./volumes/server/custom-templates:/templates:Z
- ./volumes/media:/data/media:z
labels:
com.centurylinklabs.watchtower.enable: "true"
restart: unless-stopped
tty: true
worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
container_name: authentik-worker
hostname: authentik-worker
networks:
- nginx-bridge
command: worker
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
TZ: America/New_York
env_file:
- .env
volumes:
- ./volumes/media:/data/media:z
- ./volumes/worker/certs:/certs:Z
- ./volumes/worker/custom-templates:/templates:Z
labels:
com.centurylinklabs.watchtower.enable: "true"
restart: unless-stopped
tty: true
@@ -0,0 +1,21 @@
[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
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Authentik
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+70
View File
@@ -0,0 +1,70 @@
# Beszel — lightweight host + container monitoring, VPS (ROOTLESS PODMAN) edition.
#
# Adapted from the home server's Docker/root version. Differences and why:
#
# docker.sock -> /run/user/1000/podman/podman.sock
# Rootless podman's API socket. It exposes ONLY rootless containers, which is
# exactly the set we want reported. No :Z on the socket mount -- relabeling the
# live socket breaks podman. See AGENTS.md §6.
#
# security_opt: label:disable on the agent
# Required under SELinux Enforcing to read that socket, which systemd recreates
# each boot as user_tmp_t. Same treatment as portainer/uptime.
#
# EXTRA_FILESYSTEMS=/boot dropped
# The home server has /boot on its own 974M partition. This VPS does not --
# /dev/sda1 is the whole root fs and /boot/efi is a 64M ESP not worth alerting on.
#
# No hostname/domainname on the agent
# Host networking forbids them, same as on the home server.
#
# The hub generates its keypair on first start at ./volumes/hub/id_ed25519.pub.
# That public key is what goes in the agent's KEY below.
networks:
nginx-bridge:
external: true
services:
beszel:
image: docker.io/henrygd/beszel:latest
container_name: beszel
hostname: beszel
domainname: beszel.gibbyb.com
networks: ['nginx-bridge']
# No published port: NPM proxies to http://beszel:8090 over nginx-bridge.
environment:
- TZ=America/New_York
# 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.
extra_hosts:
- "host.docker.internal:host-gateway"
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/hub:/beszel_data:Z
tty: true
stdin_open: true
restart: unless-stopped
beszel-agent:
image: docker.io/henrygd/beszel-agent:latest
container_name: beszel-agent
network_mode: host
environment:
- TZ=America/New_York
- PORT=45876
# This hub's public key. Newer Beszel writes only ./volumes/hub/id_ed25519 (private);
# derive the public half with: ssh-keygen -y -f volumes/hub/id_ed25519
# It is a PUBLIC key -- safe to keep in this file.
- KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBWNqxKGRltqXJZg+wlDOtICeLo19ftQ6P3P8+uJg8y
security_opt: ['label:disable']
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/agent:/var/lib/beszel-agent:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Beszel
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Beszel
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+5
View File
@@ -0,0 +1,5 @@
NEXTAUTH_SECRET=CHANGE_ME
CALENDSO_ENCRYPTION_KEY=CHANGE_ME
DATABASE_URL=CHANGE_ME
DATABASE_DIRECT_URL=CHANGE_ME
EMAIL_SERVER_PASSWORD=CHANGE_ME
+60
View File
@@ -0,0 +1,60 @@
# Cal.com — 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-calcom.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 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.
#
# Also unset: EMAIL_SERVER_HOST/USER/PASSWORD are still CHANGE_ME. Cal.com will run,
# but booking confirmations and invitations will not send.
#
# Note Cal.com runs database migrations on first boot and can take several minutes to
# become responsive. The unit allows 900s for this.
#
# Rootless adaptations from the home version:
# - Dropped its private postgres:16-alpine; uses the shared instance (AGENTS.md §2).
# - /etc/localtime mount removed in favour of TZ (AGENTS.md §8).
# - No bind mounts remain, so no :Z is needed.
#
# NPM: proxy cal.gbrown.org -> http://calcom:3000
networks:
nginx-bridge:
external: true
services:
calcom:
image: docker.io/calcom/cal.com:latest
container_name: calcom
hostname: calcom
domainname: cal.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- NEXT_PUBLIC_WEBAPP_URL=https://cal.gbrown.org
- NEXT_PUBLIC_WEBSITE_URL=https://cal.gbrown.org
- NEXTAUTH_URL=https://cal.gbrown.org/api/auth
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- CALENDSO_ENCRYPTION_KEY=${CALENDSO_ENCRYPTION_KEY}
# Shared postgres, not a private container
- DATABASE_URL=${DATABASE_URL}
- DATABASE_DIRECT_URL=${DATABASE_DIRECT_URL}
- NEXT_PUBLIC_LICENSE_CONSENT=agree
- ALLOWED_HOSTNAMES="cal.gbrown.org"
- [email protected]
- EMAIL_SERVER_HOST=CHANGE_ME
- EMAIL_SERVER_PORT=587
- EMAIL_SERVER_USER=CHANGE_ME
- EMAIL_SERVER_PASSWORD=${EMAIL_SERVER_PASSWORD}
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Cal.com
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/CalCom
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=900
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
@@ -0,0 +1,59 @@
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
# Keep this file up-to-date when you add new variables to \`.env\`.
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
## Next App ##
NODE_ENV=production
SENTRY_AUTH_TOKEN=CHANGE_ME
PAYLOAD_SECRET=CHANGE_ME
PAYLOAD_DB_URL=CHANGE_ME
NEXT_PUBLIC_SITE_URL=https://convexmonorepo.gbrown.org
NEXT_PUBLIC_CONVEX_URL=https://api.convexmonorepo.gbrown.org # convex-backend:3210
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.gbrown.org
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/3
NEXT_PUBLIC_SENTRY_URL=https://sentry.gbrown.org
NEXT_PUBLIC_SENTRY_ORG=sentry
NEXT_PUBLIC_SENTRY_PROJECT_NAME=convexmonorepo-nextjs
## Convex ##
CONVEX_SELF_HOSTED_URL=https://api.convexmonorepo.gbrown.org # convex-backend:3210
CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME
# Convex Auth
CONVEX_SITE_URL=https://convexmonorepo.gbrown.org # convex-backend:3211
#CONVEX_SITE_URL=https://convexmonorepo.gbrown.org # convex-backend:3211
USESEND_API_KEY=CHANGE_ME
USESEND_URL=https://usesend.example.com
USESEND_FROM_EMAIL='Convex Admin <[email protected]>'
AUTH_AUTHENTIK_ID=CHANGE_ME
AUTH_AUTHENTIK_SECRET=CHANGE_ME
AUTH_AUTHENTIK_ISSUER=https://auth.gbrown.org/application/o/convexmonorepo/
## Docker Compose Variables for Next App ##
NETWORK=nginx-bridge
NEXT_CONTAINER_NAME=convexmonorepo-next
NEXT_DOMAIN=convexmonorepo.gbrown.org
NEXT_PORT=3000
## Docker Compose Variables for Self hosted Convex ##
BACKEND_TAG=latest
DASHBOARD_TAG=latest
BACKEND_CONTAINER_NAME=convexmonorepo-backend
DASHBOARD_CONTAINER_NAME=convexmonorepo-dashboard
BACKEND_DOMAIN=convex.convexmonorepo.gbrown.org
DASHBOARD_DOMAIN=dashboard.convexmonorepo.gbrown.org
INSTANCE_NAME=convex
#INSTANCE_SECRET=
CONVEX_CLOUD_ORIGIN=https://api.convexmonorepo.gbrown.org
CONVEX_SITE_ORIGIN=https://convex.convexmonorepo.gbrown.org
NEXT_PUBLIC_DEPLOYMENT_URL=https://api.convexmonorepo.gbrown.org
DISABLE_BEACON=true
REDACT_LOGS_TO_CLIENT=true
DO_NOT_REQUIRE_SSL=true
#POSTGRES_URL= #postgresql://user:password@host:5432/db_name
#BACKEND_PORT=
#DASHBOARD_PORT
#SITE_PROXY_PORT=
#ACTIONS_USER_TIMEOUT_SECS=
#RUST_LOG=
#RUST_BACKTRACE=
@@ -0,0 +1,78 @@
networks:
nginx-bridge: # Change to network you plan to use
external: true
services:
convexmonorepo-next:
image: git.gbrown.org/gib/convexmonorepo-next:latest
container_name: convexmonorepo-next
hostname: convexmonorepo-next
domainname: ${NEXT_DOMAIN}
networks: ['${NETWORK:-nginx-bridge}']
#ports: ['${NEXT_PORT}:${NEXT_PORT}']
environment:
- NODE_ENV=${NODE_ENV}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
- NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
- PAYLOAD_SECRET=${PAYLOAD_SECRET}
- PAYLOAD_DB_URL=${PAYLOAD_DB_URL}
labels: ['com.centurylinklabs.watchtower.enable=true']
depends_on: ['convexmonorepo-backend']
tty: true
stdin_open: true
restart: unless-stopped
convexmonorepo-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-convex-backend}
hostname: ${BACKEND_CONTAINER_NAME:-convex-backend}
domainname: ${BACKEND_DOMAIN:-convex.gbrown.org}
networks: ['${NETWORK:-nginx-bridge}']
#user: '1000:1000'
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
volumes: [./data:/convex/data:z]
labels: ['com.centurylinklabs.watchtower.enable=true']
environment:
- INSTANCE_NAME
#- INSTANCE_SECRET
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}}
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${SITE_PROXY_PORT:-3211}}
- DISABLE_BEACON=${DISABLE_BEACON:-true}
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true}
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false}
#- POSTGRES_URL=${POSTGRES_URL}
stdin_open: true
tty: true
restart: unless-stopped
healthcheck:
test: curl -f http://localhost:3210/version
interval: 5s
start_period: 10s
stop_grace_period: 10s
stop_signal: SIGINT
convexmonorepo-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.gbrown.org}}
networks: ['${NETWORK:-nginx-bridge}']
#user: 1000:1000
#ports: ['${DASHBOARD_PORT:-6791}:6791']
labels: ['com.centurylinklabs.watchtower.enable=true']
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${PORT:-3210}}
depends_on:
convexmonorepo-backend:
condition: service_healthy
stdin_open: true
tty: true
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: ConvexMonorepo
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/ConvexMonorepo
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
+6
View File
@@ -0,0 +1,6 @@
NEXTAUTH_SECRET=CHANGE_ME
NEXT_PRIVATE_ENCRYPTION_KEY=CHANGE_ME
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=CHANGE_ME
NEXT_PRIVATE_DATABASE_URL=CHANGE_ME
NEXT_PRIVATE_DIRECT_DATABASE_URL=CHANGE_ME
NEXT_PRIVATE_SMTP_PASSWORD=CHANGE_ME
+72
View File
@@ -0,0 +1,72 @@
# Documenso — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12.
#
# ⚠️ NOT STARTED. No systemd unit is enabled for this. It has never run on either
# machine, so there is no data to migrate. Bring it up with:
# systemctl --user enable --now podman-documenso.service
#
# BEFORE FIRST START, two things must happen:
#
# 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 '<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
# rebuild recreates them.
#
# 2. Provide a signing certificate at ./volumes/cert/cert.p12, or Documenso will
# not start. Generate one with:
# openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 3650 -nodes
# openssl pkcs12 -export -out cert.p12 -inkey key.pem -in cert.pem -passout pass:
#
# Also unset: the four NEXT_PRIVATE_SMTP_* values are still CHANGE_ME. Documenso will
# run without working email, but signature invitations will fail to send.
#
# Rootless adaptations from the home version:
# - Dropped its private postgres:16-alpine; uses the shared instance (AGENTS.md §2).
# - :Z on bind mounts (SELinux Enforcing).
# - /etc/localtime mounts removed in favour of TZ (AGENTS.md §8).
#
# NPM: proxy docs.gbrown.org -> http://documenso:3000
networks:
nginx-bridge:
external: true
services:
documenso:
image: docker.io/documenso/documenso:latest
container_name: documenso
hostname: documenso
domainname: docs.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PORT=3000
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXT_PRIVATE_ENCRYPTION_KEY=${NEXT_PRIVATE_ENCRYPTION_KEY}
- NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=${NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY}
- NEXT_PUBLIC_WEBAPP_URL=https://docs.gbrown.org
- NEXTAUTH_URL=https://docs.gbrown.org
# Shared postgres, not a private container
- NEXT_PRIVATE_DATABASE_URL=${NEXT_PRIVATE_DATABASE_URL}
- NEXT_PRIVATE_DIRECT_DATABASE_URL=${NEXT_PRIVATE_DIRECT_DATABASE_URL}
- NEXT_PRIVATE_SIGNING_TRANSPORT=local
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
- NEXT_PRIVATE_SIGNING_PASSPHRASE=
- NEXT_PUBLIC_UPLOAD_TRANSPORT=database
- NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
- NEXT_PRIVATE_SMTP_HOST=CHANGE_ME
- NEXT_PRIVATE_SMTP_PORT=587
- NEXT_PRIVATE_SMTP_USERNAME=CHANGE_ME
- NEXT_PRIVATE_SMTP_PASSWORD=${NEXT_PRIVATE_SMTP_PASSWORD}
- NEXT_PRIVATE_SMTP_FROM_NAME=Documenso
- [email protected]
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/cert/cert.p12:/opt/documenso/cert.p12:Z
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Documenso
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Documenso
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
+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
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Gitea
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Gitea
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
@@ -0,0 +1,30 @@
# iSponsorBlockTV — VPS (ROOTLESS PODMAN) port of the home server's
# ~/Server/isponsorblocktv, migrated 2026-08-12.
#
# Why this works off-LAN: the configured device is pinned by `screen_id`, which uses
# YouTube's CLOUD lounge API rather than a direct LAN connection to the Apple TV. So it
# does not need to be on the same network as the TV. (If you ever re-pair a device via
# SSDP/mDNS discovery, that step DOES need LAN access and must be done from home.)
#
# Rootless adaptations:
# - `user: 1000:1000` REPLACED with userns_mode keep-id. Under a rootless userns the
# original would resolve to subuid 525287 and lose access to its own gib-owned data
# directory -- the exact failure that kept n8n broken for two months. See AGENTS.md §3.2.
# - :Z on the data mount (SELinux Enforcing).
# - network_mode: host retained to match home. It works fine rootless.
services:
isponsorblocktv:
image: ghcr.io/dmunozv04/isponsorblocktv:latest
container_name: iSponsorBlockTV
network_mode: host
userns_mode: "keep-id:uid=1000,gid=1000"
environment:
- TZ=America/New_York
volumes:
- './volumes/isponsorblocktv:/app/data:Z'
labels:
com.centurylinklabs.watchtower.enable: 'true'
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: iSponsorBlockTV
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/ISponsorBlockTV
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+142
View File
@@ -0,0 +1,142 @@
# Keys
# Required key for platform encryption/decryption ops
# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NEVER BE USED FOR PRODUCTION
ENCRYPTION_KEY=CHANGE_ME
# JWT
# Required secrets to sign JWT tokens
# THIS IS A SAMPLE AUTH_SECRET KEY AND SHOULD NEVER BE USED FOR PRODUCTION
AUTH_SECRET=CHANGE_ME
# Postgres creds
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_USER=infisical_user
POSTGRES_DB=infisical_db
# Required
DB_CONNECTION_URI=CHANGE_ME
# Redis
REDIS_URL=redis://infisical-redis:6379
# Website URL
# Required
SITE_URL=https://infisical.gbrown.org
# Mail/SMTP
SMTP_HOST=smtp.mail.me.com
SMTP_PORT=587
SMTP_FROM_ADDRESS=[email protected]
SMTP_FROM_NAME=Infisical Admin
SMTP_USERNAME=CHANGE_ME
SMTP_PASSWORD=CHANGE_ME
# CICD Integration
CLIENT_ID_GITHUB=
CLIENT_ID_GITHUB_APP=
CLIENT_SLUG_GITHUB_APP=
CLIENT_SECRET_GITHUB=
CLIENT_SECRET_GITHUB_APP=
CLIENT_ID_GITLAB=
CLIENT_SECRET_GITLAB=
CLIENT_PRIVATE_KEY_GITHUB_APP=
CLIENT_APP_ID_GITHUB_APP=
# Sentry (optional) for monitoring errors
SENTRY_DSN=
# Infisical Cloud-specific configs
# Ignore - Not applicable for self-hosted version
POSTHOG_HOST=
POSTHOG_PROJECT_API_KEY=
# SSO-specific variables
CLIENT_ID_GOOGLE_LOGIN=
CLIENT_SECRET_GOOGLE_LOGIN=
CLIENT_ID_GITHUB_LOGIN=
CLIENT_SECRET_GITHUB_LOGIN=
CLIENT_ID_GITLAB_LOGIN=
CLIENT_SECRET_GITLAB_LOGIN=
CAPTCHA_SECRET=
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
OTEL_TELEMETRY_COLLECTION_ENABLED=false
OTEL_EXPORT_TYPE=prometheus
OTEL_EXPORT_OTLP_ENDPOINT=
OTEL_OTLP_PUSH_INTERVAL=
OTEL_COLLECTOR_BASIC_AUTH_USERNAME=
OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=
PLAIN_API_KEY=
PLAIN_WISH_LABEL_IDS=
SSL_CLIENT_CERTIFICATE_HEADER_KEY=
ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT=true
# App Connections
# aws assume-role connection
INF_APP_CONNECTION_AWS_ACCESS_KEY_ID=
INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY=
# github oauth connection
INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID=
INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET=
#github app connection
INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID=
INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET=
INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY=
INF_APP_CONNECTION_GITHUB_APP_SLUG=
INF_APP_CONNECTION_GITHUB_APP_ID=
#gitlab app connection
INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID=
INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET=
#github radar app connection
INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID=
INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET=
INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY=
INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG=
INF_APP_CONNECTION_GITHUB_RADAR_APP_ID=
INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET=
#gcp app connection
INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL=
# azure app connections
INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID=
INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET=
INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID=
INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET=
INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID=
INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET=
INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID=
INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET=
# heroku app connection
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID=
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET=
# datadog
SHOULD_USE_DATADOG_TRACER=
DATADOG_PROFILING_ENABLED=
DATADOG_ENV=
DATADOG_SERVICE=
DATADOG_HOSTNAME=
# kubernetes
KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN=false
# ClickHouse (optional) for audit log storage
# CLICKHOUSE_URL=http://infisical:infisical@clickhouse:8123/infisical
+62
View File
@@ -0,0 +1,62 @@
# Infisical — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/infisical,
# migrated 2026-08-12.
#
# CONSOLIDATED ONTO THE SHARED POSTGRES. Home runs a dedicated postgres:14 for infisical;
# here it uses the shared `postgresql` container (PG 17) alongside authentik, n8n and NPM.
# The PG14 dump restored into PG17 cleanly (no extensions, 749 tables, row counts verified
# identical). One postgres to back up, patch and monitor instead of several.
#
# role/database: infisical_user / infisical_db (created in the shared instance)
# DB_CONNECTION_URI in .env points at host `postgresql`, not the old `db`
# REDIS_URL points at `infisical-redis` -- container_name is what netavark resolves
# on nginx-bridge, so the home file's bare `redis` hostname does not work here
#
# redis stays local to this stack: it holds only cache/queue state, starts empty by
# design, and is not worth centralising.
#
# Rootless adaptations:
# - :Z on the redis data mount (SELinux Enforcing).
# - No published ports; NPM proxies to http://infisical-backend:8080 over nginx-bridge.
# - Images start as root and drop privileges internally, so no userns_mode needed.
#
# .env is copied verbatim from home (mode 600) and holds ENCRYPTION_KEY and AUTH_SECRET.
# Those MUST match the database they encrypted -- never regenerate them on migrated data.
networks:
nginx-bridge:
external: true
services:
redis:
image: docker.io/library/redis:latest
container_name: infisical-redis
hostname: infisical-redis
networks: ["nginx-bridge"]
env_file: .env
environment:
- ALLOW_EMPTY_PASSWORD=yes
- TZ=America/New_York
volumes:
- ./data/redis:/data:Z
labels:
com.centurylinklabs.watchtower.enable: "true"
restart: unless-stopped
tty: true
backend:
image: docker.io/infisical/infisical:latest
container_name: infisical-backend
hostname: infisical-backend
domainname: infisical.gbrown.org
networks: ["nginx-bridge"]
env_file: .env
environment:
- NODE_ENV=production
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "true"
depends_on:
- redis
restart: unless-stopped
tty: true
stdin_open: true
@@ -0,0 +1,21 @@
[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
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Infisical
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+64
View File
@@ -0,0 +1,64 @@
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
# Keep this file up-to-date when you add new variables to \`.env\`.
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
## Next.js ##
NODE_ENV=production
SENTRY_AUTH_TOKEN=CHANGE_ME
PAYLOAD_SECRET=CHANGE_ME
PAYLOAD_DB_URL=CHANGE_ME
NEXT_PUBLIC_SITE_URL=https://lashaddict.gbrown.org
NEXT_PUBLIC_CONVEX_URL=https://api.lashaddict.gbrown.org # convex-backend:3210
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.gbrown.org
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/7
NEXT_PUBLIC_SENTRY_URL=https://sentry.gbrown.org
NEXT_PUBLIC_SENTRY_ORG=sentry
NEXT_PUBLIC_SENTRY_PROJECT_NAME=lashaddict-next
## Convex ##
CONVEX_SELF_HOSTED_URL=https://api.lashaddict.gbrown.org # convex-backend:3210
CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME
# Convex Auth
CONVEX_SITE_URL=https://lashaddict.gbrown.org
USESEND_API_KEY=CHANGE_ME
USESEND_URL=https://usesend.gbrown.org
USESEND_FROM_EMAIL='Admin <[email protected]>'
AUTH_AUTHENTIK_ID=CHANGE_ME
AUTH_AUTHENTIK_SECRET=CHANGE_ME
AUTH_AUTHENTIK_ISSUER=https://auth.gbrown.org/application/o/lashaddict/
## Docker Compose Variables for Next App ##
NETWORK=nginx-bridge
NEXT_CONTAINER_NAME=lashaddict-next
NEXT_DOMAIN=lashaddict.gbrown.org
#NEXT_PORT=
## Docker Compose Variables for Self hosted Convex ##
BACKEND_TAG=latest
DASHBOARD_TAG=latest
BACKEND_CONTAINER_NAME=lashaddict-backend
DASHBOARD_CONTAINER_NAME=lashaddict-dashboard
BACKEND_DOMAIN=convex.lashaddict.gbrown.org
DASHBOARD_DOMAIN=dashboard.lashaddict.gbrown.org
INSTANCE_NAME=lashaddict_convex
INSTANCE_SECRET=CHANGE_ME
CONVEX_CLOUD_ORIGIN=https://api.lashaddict.gbrown.org
CONVEX_SITE_ORIGIN=https://convex.lashaddict.gbrown.org
NEXT_PUBLIC_DEPLOYMENT_URL=https://api.lashaddict.gbrown.org
DISABLE_BEACON=true
REDACT_LOGS_TO_CLIENT=true
DO_NOT_REQUIRE_SSL=true
POSTGRES_URL=CHANGE_ME
#BACKEND_PORT=
#DASHBOARD_PORT
#SITE_PROXY_PORT=
#ACTIONS_USER_TIMEOUT_SECS=
#RUST_LOG=
#RUST_BACKTRACE=
## Docker Compose Variables for Postgres ##
POSTGRES_CONTAINER_NAME=lashaddict-postgres
POSTGRES_USER=gib
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=lashaddict-payload
+76
View File
@@ -0,0 +1,76 @@
networks:
nginx-bridge:
external: true
services:
lashaddict-next:
image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest
container_name: ${NEXT_CONTAINER_NAME}
hostname: ${NEXT_CONTAINER_NAME}
domainname: ${NEXT_DOMAIN}
networks:
- ${NETWORK:-nginx-bridge}
environment:
- NODE_ENV=${NODE_ENV}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-lashaddict-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
- NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
- PAYLOAD_SECRET=${PAYLOAD_SECRET}
- PAYLOAD_DB_URL=${PAYLOAD_DB_URL}
depends_on:
- lashaddict-backend
tty: true
stdin_open: true
restart: unless-stopped
lashaddict-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-lashaddict-backend}
hostname: ${BACKEND_CONTAINER_NAME:-lashaddict-backend}
domainname: ${BACKEND_DOMAIN:-lashaddict.gbrown.org}
networks:
- ${NETWORK:-nginx-bridge}
volumes:
- ./volumes/convex:/convex/data:z
labels:
- com.centurylinklabs.watchtower.enable=true
environment:
- INSTANCE_NAME=${INSTANCE_NAME}
- INSTANCE_SECRET=${INSTANCE_SECRET}
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}}
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${SITE_PROXY_PORT:-3211}}
- DISABLE_BEACON=${DISABLE_BEACON:-true}
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true}
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false}
- POSTGRES_URL=${POSTGRES_URL}
stdin_open: true
tty: true
restart: unless-stopped
healthcheck:
test: curl -f http://localhost:3210/version
interval: 5s
start_period: 10s
stop_grace_period: 10s
stop_signal: SIGINT
lashaddict-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-lashaddict.gbrown.org}}
networks:
- ${NETWORK:-nginx-bridge}
labels:
- com.centurylinklabs.watchtower.enable=true
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-lashaddict-backend}:${PORT:-3210}}
depends_on:
lashaddict-backend:
condition: service_healthy
stdin_open: true
tty: true
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: LashAddict
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/LashAddict
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 @@
GENERIC_TIMEZONE=America/New_York
TZ=America/New_York
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
N8N_RUNNERS_ENABLED=true
N8N_RUNNERS_MODE=external
N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
N8N_RUNNERS_AUTH_TOKEN=CHANGE_ME
N8N_NATIVE_PYTHON_RUNNER=true
DB_TYPE=postgresdb
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_HOST=postgresql
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_USER=npm
DB_POSTGRESDB_PASSWORD=CHANGE_ME
N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
N8N_RUNNERS_AUTH_TOKEN=CHANGE_ME
+50
View File
@@ -0,0 +1,50 @@
networks:
nginx-bridge:
external: true
services:
n8n:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n
hostname: n8n
domainname: n8n.gbrown.org
networks: ['nginx-bridge']
#ports: ['5678:5678']
env_file: [.env]
environment:
- GENERIC_TIMEZONE
- TZ
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS
- N8N_RUNNERS_ENABLED
- N8N_RUNNERS_MODE
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS
- N8N_RUNNERS_AUTH_TOKEN
- N8N_NATIVE_PYTHON_RUNNER
- DB_TYPE
- DB_POSTGRESDB_DATABASE
- DB_POSTGRESDB_HOST
- DB_POSTGRESDB_PORT
- DB_POSTGRESDB_USER
- DB_POSTGRESDB_PASSWORD
# Rootless: replaces `user: 1000:1000`, which under a rootless userns would have
# resolved to subuid 525287 and lost access to the gib-owned ./data dir.
userns_mode: "keep-id:uid=1000,gid=1000"
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- './data:/home/node/.n8n:Z'
tty: true
stdin_open: true
restart: unless-stopped
task-runners:
image: n8nio/runners:1.111.0
container_name: n8n-runners
# Was missing: without this the runner sits on the compose default network and
# cannot resolve N8N_RUNNERS_TASK_BROKER_URI (http://n8n:5679).
networks: ['nginx-bridge']
env_file: [.env]
environment:
- N8N_RUNNERS_TASK_BROKER_URI
- N8N_RUNNERS_AUTH_TOKEN
depends_on:
- n8n
+21
View File
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: N8n
After=network-online.target podman-postgresql.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/N8n
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
@@ -0,0 +1,5 @@
DB_POSTGRES_HOST=postgresql
DB_POSTGRES_PORT=5432
DB_POSTGRES_USER=npm
DB_POSTGRES_PASSWORD=CHANGE_ME
DB_POSTGRES_NAME=npm
@@ -0,0 +1,52 @@
services:
nginx-proxy-manager:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
hostname: nginx-proxy-manager
domainname: nginx.gibbyb.com
networks: ['nginx-bridge']
restart: unless-stopped
ports:
- '80:80'
- '443:443'
# ⚠️ ONLY 81 moves. 80 and 443 above MUST stay on 0.0.0.0 — they are the
# public front door and firewalld already restricts them to Cloudflare ranges.
# 81 is the admin UI and is reached over WireGuard (http://192.168.2.2:81).
- '192.168.2.2:81:81' # Admin Web Port — WireGuard only
#- '21:21' # FTP
#- '22:22' # SSH
#- '25565:25565' # Minecraft
environment:
- TZ=America/New_York
- DB_POSTGRES_HOST
- DB_POSTGRES_PORT
- DB_POSTGRES_USER
- DB_POSTGRES_PASSWORD
- DB_POSTGRES_NAME
labels:
com.centurylinklabs.watchtower.enable: 'true'
volumes:
- ./volumes/data:/data:Z
- ./volumes/letsencrypt:/etc/letsencrypt:Z
#depends_on: [postgresql]
#postgresql:
#image: postgres:17
#container_name: nginx-proxy-manager-db
#hostname: nginx-proxy-manager-db
#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'
#volumes:
#- ./volumes/postgres:/var/lib/postgresql/data
#restart: unless-stopped
networks:
# Created out-of-band so all nine stacks agree and there is no create race at boot:
# podman network create --subnet 172.18.0.0/24 nginx-bridge
nginx-bridge:
external: true
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Nginx Proxy Manager
After=network-online.target podman-postgresql.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Nginx_Proxy_Manager
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+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
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Penpot
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Penpot
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
+31
View File
@@ -0,0 +1,31 @@
services:
portainer:
image: portainer/portainer-ee:latest
container_name: portainer
hostname: portainer
domainname: port.gibbyb.com
networks:
- nginx-bridge
environment:
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "true"
ports:
# WireGuard address only — this admin UI has no business on the public
# interface. It has its own login and the Hetzner firewall does not admit this
# port, so this is defence in depth: it removes the dependency on a firewall rule
# set that lives in a web console. See AGENTS.md §13.2.
- 192.168.2.2:9443:9443
# Required under SELinux Enforcing to reach the rootless podman socket, which
# systemd recreates at each boot with a non-container label. The socket mount
# deliberately has no :Z -- relabeling the live socket would break podman itself.
security_opt: ['label:disable']
volumes:
- ./volumes/data:/data:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock
tty: true
restart: unless-stopped
networks:
nginx-bridge:
external: true
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Portainer
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Portainer
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
@@ -0,0 +1,4 @@
POSTGRES_USER=npm
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_NAME=npm
POSTGRES_DB=npm
+80
View File
@@ -0,0 +1,80 @@
networks:
nginx-bridge:
external: true
services:
postgresql:
# pgvector image = stock postgres:17 plus the `vector` extension compiled in. Same
# PostgreSQL 17.10 and same data directory format, so this is a drop-in swap with no
# dump/restore needed.
#
# ⚠️ THE TAG MUST STAY -trixie. This is not cosmetic.
#
# The plain `pgvector/pgvector:pg17` tag is built on Debian 12 (bookworm, glibc 2.36).
# The stock `postgres:17` this cluster was created under is Debian 13 (trixie,
# glibc 2.41). glibc supplies the collation used to order every text index, and it is
# NOT guaranteed stable across versions. Starting on bookworm made all 14 databases
# report:
# WARNING: database "npm" has a collation version mismatch
# DETAIL: created using collation version 2.41, but the OS provides version 2.36
# An index built under one collation and read under another can silently return wrong
# results — missed rows in range scans, duplicate values slipping past unique
# constraints. It does not error; it just quietly answers incorrectly.
#
# Using the -trixie build keeps glibc at 2.41, matching how the data was written, so
# no REINDEX is required. If you ever must move to a different base, the correct
# procedure is: REINDEX DATABASE <each>, then ALTER DATABASE <each> REFRESH COLLATION
# VERSION — not simply silencing the warning.
#
# The extension is AVAILABLE but not enabled anywhere by default. To use it in a
# database, enable it per-database (it is not cluster-wide):
# podman exec postgresql psql -U npm -d <dbname> -c 'CREATE EXTENSION vector;'
#
# ⚠️ Do NOT let watchtower update this to a different pg major. It is already in
# WATCHTOWER_DISABLE_CONTAINERS, which is what keeps that from happening.
image: pgvector/pgvector:pg17-trixie
container_name: postgresql
# Rootless: maps host uid 1000 (gib) -> container uid 999 (postgres), so ./pg_data
# stays gib-owned on the host. Postgres' entrypoint supports running as non-root
# provided the data dir ownership matches, which this guarantees.
userns_mode: "keep-id:uid=999,gid=999"
hostname: postgresql
domainname: pg.gbrown.org
networks:
- nginx-bridge
# ⚠️ WIREGUARD ADDRESS ONLY. This was previously '5432:5432', which bound the
# database to every interface including the public one — the only thing preventing
# the internet from reaching a Postgres auth prompt was firewalld plus the Hetzner
# cloud firewall. Two independent firewall rules were the sole barrier in front of
# all 14 databases.
#
# Nothing needs the public binding: every consumer (authentik, infisical, adminer,
# the payload/convex app stacks, gitea) resolves `postgresql` over the nginx-bridge
# container network and never touches the published port at all. This mapping now
# exists purely so you can point a desktop client at it across the tunnel.
#
# Same pattern as watchtower's API and authentik's 9000 — see AGENTS.md §5.
ports: ['192.168.2.2:5432:5432']
env_file: .env
environment:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./pg_data:/var/lib/postgresql/data:Z
# Runs ONLY when pg_data is empty (fresh install). Recreates every role and
# database this VPS needs, so the environment can be rebuilt from scratch without
# hand-creating them. Does nothing to an existing database — safe to leave mounted.
# The committed file is 00-roles-and-databases.sql.TEMPLATE with placeholder
# passwords; copy it to .sql and fill in real values from each service's .env.
- ./initdb:/docker-entrypoint-initdb.d:Z
tty: true
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
start_period: 20s
interval: 30s
retries: 5
timeout: 5s
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: PostgreSQL
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/PostgreSQL
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+1
View File
@@ -0,0 +1 @@
RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME
+89
View File
@@ -0,0 +1,89 @@
# RustDesk Server — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/rustdesk,
# migrated 2026-08-12.
#
# ⚠️ THIS SERVICE CANNOT GO BEHIND CLOUDFLARE'S PROXY.
# RustDesk's ID/relay protocol is raw TCP/UDP on 21115-21119. Cloudflare's proxy only
# carries HTTP/HTTPS; raw TCP needs Spectrum, which is not on this plan. So
# rustdesk.gbrown.org MUST be a grey-cloud (DNS-only) record pointing at
# 178.156.197.55, and these ports must be open in firewalld's public zone.
#
# Consequence, accepted deliberately: this publishes the VPS's real IP. That is
# tolerable because the firewall still restricts 80/443 to Cloudflare ranges, so
# knowing the IP does not grant access to any of the web services.
#
# The server keypair in ./volumes/server/id_ed25519 was carried over from the home
# server. Its public half matches RUSTDESK_API_RUSTDESK_KEY below
# (WLgvHhau6aa5nDPQutTHeQBpIrOEb8aPXByVBWQwkKc=), which is what lets existing clients
# reconnect without being re-paired. NEVER regenerate it.
#
# Rootless adaptations:
# - :Z on both bind mounts (SELinux Enforcing); each is exclusive to this container.
# - All published ports are >1024 so no privileged-port handling is needed.
# - The image starts as root under s6 and drops privileges internally, so no
# userns_mode is required.
#
# PORT REFERENCE (all must be open in the firewall):
# 21114/tcp web UI + API 21117/tcp hbbr relay
# 21115/tcp hbbs NAT type test 21118/tcp websocket (web client)
# 21116/tcp hbbs ID registration 21119/tcp websocket relay
# 21116/udp hbbs heartbeat <-- UDP, easy to forget
networks:
nginx-bridge:
external: true
services:
rustdesk-server:
image: docker.io/lejianwen/rustdesk-server-s6:latest
container_name: rustdesk-server
hostname: rustdesk-server
domainname: rustdesk.gbrown.org
networks: ['nginx-bridge']
ports:
- 21114:21114
- 21115:21115
- 21116:21116
- 21116:21116/udp
- 21117:21117
- 21118:21118
- 21119:21119
environment:
- MUST_LOGIN=Y
- TZ=America/New_York
# SPLIT HOSTNAMES — this is deliberate, do not "simplify" it back to one name.
# relay.gbrown.org DNS-only (grey cloud) -> 178.156.197.55
# Carries the raw TCP/UDP protocol on 21115-21119.
# Cloudflare's proxy only handles HTTP/HTTPS, so this
# hostname MUST bypass it. That publishes the VPS IP,
# which is acceptable: the firewall still restricts
# 80/443 to Cloudflare ranges, so knowing the IP grants
# no access to any web service.
# rustdesk.gbrown.org Proxied -> NPM -> rustdesk-server:21114
# The web console and API, over TLS via Cloudflare.
# Keeping the API on the proxied name is what avoids sending login
# credentials over plain HTTP.
# RELAY is read by the s6 run script as `hbbs -r $RELAY`. It is what hbbs
# hands back to clients when P2P hole-punching fails, so it MUST be set here:
# the RUSTDESK_API_* vars below only configure the web console/API, not hbbs.
# Left unset, the image defaults to `relay.example.com`, which resolves to
# nothing -- so every connection needing a relay (i.e. every off-LAN,
# off-WireGuard client behind CGNAT) silently fails.
- RELAY=relay.gbrown.org
- RUSTDESK_API_RUSTDESK_ID_SERVER=relay.gbrown.org
- RUSTDESK_API_RUSTDESK_RELAY_SERVER=relay.gbrown.org
- RUSTDESK_API_RUSTDESK_API_SERVER=https://rustdesk.gbrown.org
- RUSTDESK_API_RUSTDESK_KEY=WLgvHhau6aa5nDPQutTHeQBpIrOEb8aPXByVBWQwkKc=
- RUSTDESK_API_RUSTDESK_JWT_KEY=${RUSTDESK_API_RUSTDESK_JWT_KEY}
- RUSTDESK_API_LANG=en
- RUSTDESK_API_APP_DISABLE_PWD_LOGIN=true
- RUSTDESK_API_ADMIN_TITLE=Gib's Rustdesk
- RUSTDESK_API_ADMIN_HELLO=<h1>Welcome to Gib's Rustdesk<h1>
- ENCRYPTED_ONLY=1
- RUSTDESK_API_RUSTDESK_WEBCLIENT_MAGIC_QUERYONLINE=1
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/server:/data:Z
- ./volumes/api:/app/data:Z
restart: unless-stopped
tty: true
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Rustdesk
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Rustdesk
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+66
View File
@@ -0,0 +1,66 @@
AUTH_AUTHENTIK_ID=CHANGE_ME
AUTH_AUTHENTIK_ISSUER="https://auth.gbrown.org/application/o/spoon/"
AUTH_AUTHENTIK_SECRET=CHANGE_ME
AUTH_GITHUB_ID="Iv23liygrKjd17rba96x"
AUTH_GITHUB_SECRET=CHANGE_ME
BACKEND_CONTAINER_NAME="spoon-backend"
BACKEND_DOMAIN="convex.spoon.gbrown.org"
BACKEND_PORT="3210"
BACKEND_TAG="latest"
CONVEX_CLOUD_ORIGIN="https://api.spoon.gbrown.org"
CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME
CONVEX_SELF_HOSTED_URL="https://api.spoon.gbrown.org"
CONVEX_SITE_ORIGIN="https://convex.spoon.gbrown.org"
CONVEX_SITE_URL="https://spoon.gbrown.org"
DASHBOARD_CONTAINER_NAME="spoon-dashboard"
DASHBOARD_DOMAIN="dashboard.spoon.gbrown.org"
DASHBOARD_PORT="6791"
DASHBOARD_TAG="latest"
DISABLE_BEACON="true"
DO_NOT_REQUIRE_SSL="true"
GITHUB_APP_CLIENT_ID="Iv23liygrKjd17rba96x"
GITHUB_APP_CLIENT_SECRET=CHANGE_ME
GITHUB_APP_ID="4111484"
GITHUB_APP_INSTALLATION_ID="141786602"
GITHUB_APP_OWNER="gibbyb"
GITHUB_APP_PRIVATE_KEY=CHANGE_ME
GITHUB_APP_SLUG="spoon-gbrown"
GITHUB_APP_WEBHOOK_SECRET=CHANGE_ME
INSTANCE_NAME="convex"
NETWORK="nginx-bridge"
NEXT_CONTAINER_NAME="spoon-next"
NEXT_DOMAIN="spoon.gbrown.org"
NEXT_PORT="3000"
NEXT_PUBLIC_CONVEX_URL="https://api.spoon.gbrown.org"
NEXT_PUBLIC_DEPLOYMENT_URL="https://api.spoon.gbrown.org"
NEXT_PUBLIC_PLAUSIBLE_URL="https://plausible.gbrown.org"
NEXT_PUBLIC_SENTRY_DSN="https://[email protected]/8"
NEXT_PUBLIC_SENTRY_ORG="sentry"
NEXT_PUBLIC_SENTRY_PROJECT_NAME="spoon-nextjs"
NEXT_PUBLIC_SENTRY_URL="https://sentry.gbrown.org"
NEXT_PUBLIC_SITE_URL="https://spoon.gbrown.org"
NODE_ENV="production"
POSTGRES_CONTAINER_NAME="spoon-postgres"
POSTGRES_DB="spoon_convex"
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_PORT="5432"
POSTGRES_URL=CHANGE_ME
POSTGRES_USER="spoon"
REDACT_LOGS_TO_CLIENT="true"
SENTRY_AUTH_TOKEN=CHANGE_ME
SITE_PROXY_PORT="3211"
SPOON_ENCRYPTION_KEY=CHANGE_ME
SPOON_WORKER_TOKEN=CHANGE_ME
USESEND_API_KEY=CHANGE_ME
USESEND_FROM_EMAIL="Spoon Admin <[email protected]>"
USESEND_URL="https://usesend.gbrown.org"
SPOON_AGENT_JOB_IMAGE=git.gbrown.org/gib/spoon-agent-job:latest
SPOON_AGENT_JOB_TIMEOUT_MS="1800000"
SPOON_AGENT_MAX_CONCURRENT_JOBS="1"
SPOON_AGENT_NETWORK=nginx-bridge
SPOON_AGENT_RUNTIME=docker
SPOON_AGENT_WORKDIR=/var/lib/spoon-agent/work
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
+114
View File
@@ -0,0 +1,114 @@
networks:
nginx-bridge:
external: true
services:
spoon-next:
image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest
container_name: ${NEXT_CONTAINER_NAME}
hostname: ${NEXT_CONTAINER_NAME}
domainname: ${NEXT_DOMAIN}
networks:
- ${NETWORK:-nginx-bridge}
pull_policy: missing
environment:
- NODE_ENV=${NODE_ENV}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
- NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
- SPOON_AGENT_WORKER_URL=${SPOON_AGENT_WORKER_URL:-http://spoon-agent-worker:3921}
- SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN}
- SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN}
depends_on:
- spoon-backend
labels:
- com.centurylinklabs.watchtower.enable=true
tty: true
stdin_open: true
restart: unless-stopped
spoon-agent-worker:
image: git.gbrown.org/gib/spoon-agent-worker:latest
container_name: spoon-agent-worker
hostname: spoon-agent-worker
domainname: worker.${NEXT_DOMAIN:-spoon.gbrown.org}
networks:
- ${NETWORK:-nginx-bridge}
pull_policy: missing
environment:
- GITHUB_APP_ID=${GITHUB_APP_ID}
- GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
- NEXT_PUBLIC_CONVEX_URL=https://api.spoon.gbrown.org
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker}
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-git.gbrown.org/gib/spoon-agent-job:latest}
- SPOON_AGENT_RUNTIME=docker
- SPOON_AGENT_NETWORK=${NETWORK:-nginx-bridge}
- SPOON_AGENT_WORKDIR=/var/lib/spoon-agent/work
- SPOON_AGENT_HOST_WORKDIR=/var/lib/spoon-agent/work
- SPOON_AGENT_WORKER_HTTP_PORT=${SPOON_AGENT_WORKER_HTTP_PORT:-3921}
- SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN}
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
- SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN}
volumes:
- /run/user/1000/podman/podman.sock:/var/run/docker.sock
- ./volumes/agent-work:/var/lib/spoon-agent/work:z
labels:
- com.centurylinklabs.watchtower.enable=true
tty: true
stdin_open: true
restart: unless-stopped
security_opt:
- label:disable
spoon-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-spoon-backend}
hostname: ${BACKEND_CONTAINER_NAME:-spoon-backend}
domainname: ${BACKEND_DOMAIN:-convex.spoon.gbrown.org}
networks:
- ${NETWORK:-nginx-bridge}
volumes:
- ./volumes/convex:/convex/data:z
pull_policy: missing
environment:
- INSTANCE_NAME=${INSTANCE_NAME}
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${SITE_PROXY_PORT:-3211}}
- DISABLE_BEACON=${DISABLE_BEACON:-true}
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true}
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false}
- POSTGRES_URL=${POSTGRES_URL}
labels:
- com.centurylinklabs.watchtower.enable=true
stdin_open: true
tty: true
restart: unless-stopped
healthcheck:
test: curl -f http://localhost:3210/version
interval: 5s
start_period: 10s
stop_grace_period: 10s
stop_signal: SIGINT
spoon-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-spoon.gbrown.org}}
networks:
- ${NETWORK:-nginx-bridge}
pull_policy: missing
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${PORT:-3210}}
depends_on:
spoon-backend:
condition: service_healthy
labels:
- com.centurylinklabs.watchtower.enable=true
stdin_open: true
tty: true
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Spoon
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Spoon
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
+58
View File
@@ -0,0 +1,58 @@
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
# Keep this file up-to-date when you add new variables to \`.env\`.
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
## Next App ##
NODE_ENV=production
SENTRY_AUTH_TOKEN=CHANGE_ME
PAYLOAD_SECRET=CHANGE_ME
PAYLOAD_DB_URL=CHANGE_ME
NEXT_PUBLIC_SITE_URL=https://stpeteit.com
NEXT_PUBLIC_CONVEX_URL=https://api.stpeteit.com # convex-backend:3210
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.gbrown.org
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/5
NEXT_PUBLIC_SENTRY_URL=https://sentry.gbrown.org
NEXT_PUBLIC_SENTRY_ORG=sentry
NEXT_PUBLIC_SENTRY_PROJECT_NAME=stpeteit-next
## Convex ##
CONVEX_SELF_HOSTED_URL=https://api.stpeteit.com # convex-backend:3210
CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME
# Convex Auth
CONVEX_SITE_URL=https://stpeteit.com # convex-backend:3211
USESEND_API_KEY=CHANGE_ME
USESEND_URL=https://usesend.gbrown.org
USESEND_FROM_EMAIL='St Pete IT Admin <[email protected]>'
AUTH_AUTHENTIK_ID=CHANGE_ME
AUTH_AUTHENTIK_SECRET=CHANGE_ME
AUTH_AUTHENTIK_ISSUER=https://auth.gbrown.org/application/o/stpeteit/
## Docker Compose Variables for Next App ##
NETWORK=nginx-bridge
NEXT_CONTAINER_NAME=stpeteit-next
NEXT_DOMAIN=stpeteit.com
#NEXT_PORT=3000
## Docker Compose Variables for Self hosted Convex ##
BACKEND_TAG=latest
DASHBOARD_TAG=latest
BACKEND_CONTAINER_NAME=stpeteit-backend
DASHBOARD_CONTAINER_NAME=stpeteit-dashboard
BACKEND_DOMAIN=convex.stpeteit.com
DASHBOARD_DOMAIN=dashboard.stpeteit.com
INSTANCE_NAME=stpeteit-convex
#INSTANCE_SECRET=
CONVEX_CLOUD_ORIGIN=https://api.stpeteit.com
CONVEX_SITE_ORIGIN=https://convex.stpeteit.com
NEXT_PUBLIC_DEPLOYMENT_URL=https://api.stpeteit.com
DISABLE_BEACON=true
REDACT_LOGS_TO_CLIENT=true
DO_NOT_REQUIRE_SSL=true
POSTGRES_URL=CHANGE_ME
#BACKEND_PORT=
#DASHBOARD_PORT
#SITE_PROXY_PORT=
#ACTIONS_USER_TIMEOUT_SECS=
#RUST_LOG=
#RUST_BACKTRACE=
+101
View File
@@ -0,0 +1,101 @@
networks:
nginx-bridge: # Change to network you plan to use
external: true
services:
stpeteit-next:
image: git.gbrown.org/gib/stpeteit-next:latest
container_name: stpeteit-next
hostname: stpeteit-next
domainname: ${NEXT_DOMAIN}
networks: ['${NETWORK:-nginx-bridge}']
#ports: ['${NEXT_PORT}:${NEXT_PORT}']
environment:
- NODE_ENV=${NODE_ENV:-development}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- PAYLOAD_SECRET=${PAYLOAD_SECRET}
- PAYLOAD_DB_URL=${PAYLOAD_DB_URL}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.stpeteit.com}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
- NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
labels: ['com.centurylinklabs.watchtower.enable=true']
depends_on: ['stpeteit-backend']
tty: true
stdin_open: true
restart: unless-stopped
stpeteit-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-stpeteit-backend}
hostname: ${BACKEND_CONTAINER_NAME:-stpeteit-backend}
domainname: ${BACKEND_DOMAIN:-convex.stpeteit.com}
networks: ['${NETWORK:-nginx-bridge}']
#user: '1000:1000'
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
volumes: [./data:/convex/data:z]
labels: ['com.centurylinklabs.watchtower.enable=true']
environment:
- INSTANCE_NAME
#- INSTANCE_SECRET
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}}
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${SITE_PROXY_PORT:-3211}}
- DISABLE_BEACON=${DISABLE_BEACON:-true}
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true}
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false}
- POSTGRES_URL=${POSTGRES_URL}
stdin_open: true
tty: true
restart: unless-stopped
healthcheck:
test: curl -f http://localhost:3210/version
interval: 5s
start_period: 10s
stop_grace_period: 10s
stop_signal: SIGINT
stpeteit-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.stpete.com}}
networks: ['${NETWORK:-nginx-bridge}']
#user: 1000:1000
#ports: ['${DASHBOARD_PORT:-6791}:6791']
labels: ['com.centurylinklabs.watchtower.enable=true']
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${PORT:-3210}}
depends_on:
stpeteit-backend:
condition: service_healthy
stdin_open: true
tty: true
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
#convexmonorepo-postgresql:
#image: postgres:17
#container_name: ${POSTGRES_CONTAINER_NAME:-convexmonorepo-postgres}
#hostname: ${POSTGRES_CONTAINER_NAME:-convexmonorepo-postgres}
#domainname: postgres.${NEXT_DOMAIN:-convexmonorepo.gbrown.org}
#networks: ['${NETWORK:-nginx-bridge}']
#ports: ['5432:5432']
#environment:
#- POSTGRES_USER=${POSTGRES_USER:-convexmonorepo}
#- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
#- POSTGRES_DB=${POSTGRES_DB:-convexmonorepo_payload}
#labels: ['com.centurylinklabs.watchtower.enable=true']
#volumes: ['./volumes/postgres:/var/lib/postgresql/data:Z']
#tty: true
#stdin_open: true
#restart: unless-stopped
#healthcheck:
#test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
#start_period: 20s
#interval: 30s
#retries: 5
#timeout: 5s
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: StPeteIT
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/StPeteIT
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
+88
View File
@@ -0,0 +1,88 @@
# Tools — small self-hosted utilities. VPS (ROOTLESS PODMAN) port of the home server's
# ~/Server/tools stack, migrated 2026-08-12.
#
# Rootless adaptations:
# - :Z on every bind mount (SELinux Enforcing). All mounts here are exclusive to a
# single container, so uppercase :Z is correct -- see AGENTS.md §6.
# - No ports published: NPM proxies to each container by name over nginx-bridge.
#
# All four images start as root and drop privileges internally, so no userns_mode is
# needed (AGENTS.md §3.2).
networks:
nginx-bridge:
external: true
services:
convertx:
image: ghcr.io/c4illin/convertx:latest
container_name: convertx
hostname: convertx
domainname: convert.tools.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- ALLOW_UNAUTHENTICATED=true
- ACCOUNT_REGISTRATION=false
- HIDE_HISTORY=true
volumes:
- ./volumes/convertx:/app/data:Z
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
restart: unless-stopped
it-tools:
image: docker.io/corentinth/it-tools:latest
container_name: it-tools
hostname: it-tools
domainname: tools.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "true"
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']
environment:
- TZ=America/New_York
- PUID=1000
- PGID=1000
- VITE_BRAND_NAME="GibPDF"
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:8080']
interval: 30s
timeout: 10s
retries: 3
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Tools
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Tools
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+26
View File
@@ -0,0 +1,26 @@
networks:
nginx-bridge:
external: true
services:
uptime:
image: louislam/uptime-kuma:latest
container_name: uptime
hostname: uptime
domainname: uptime.gbrown.org
networks: [nginx-bridge]
environment:
- TZ=America/New_York
volumes:
- ./volume:/app/data:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock
labels:
- "com.centurylinklabs.watchtower.enable=true"
security_opt: ['seccomp:unconfined','label:disable']
# WireGuard address only — this admin UI has no business on the public
# interface. It has its own login and the Hetzner firewall does not admit this
# port, so this is defence in depth: it removes the dependency on a firewall rule
# set that lives in a web console. See AGENTS.md §13.2.
ports: ['192.168.2.2:3001:3001']
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Uptime
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Uptime
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+22
View File
@@ -0,0 +1,22 @@
API_RATE_LIMIT=1
AUTH_AUTHENTIK_ID=CHANGE_ME
AUTH_AUTHENTIK_SECRET=CHANGE_ME
AUTH_AUTHENTIK_ISSUER="https://auth.gbrown.org/application/o/gibsend/"
AWS_ACCESS_KEY=CHANGE_ME
AWS_SECRET_KEY=CHANGE_ME
AWS_DEFAULT_REGION=us-east-2
DATABASE_URL=CHANGE_ME
GITHUB_ID=Ov23liz34colyJed9t9S
GITHUB_SECRET=CHANGE_ME
MINIO_ROOT_USER=usesend
MINIO_ROOT_PASSWORD=CHANGE_ME
NEXTAUTH_SECRET=CHANGE_ME
NEXT_PUBLIC_IS_CLOUD=false
POSTGRES_USER=usesend
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=usesend
REDIS_URL=redis://usesend-redis:6379
SMTP_HOST=smtp.usesend.gbrown.org
SMTP_USER=usesend
USESEND_PORT=3000
USESEND_URL=https://usesend.gbrown.org
+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,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: UseSend
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/UseSend
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 @@
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
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Vaultwarden
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Vaultwarden
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# update-containers — pull new images and restart the services that changed.
#
# REPLACES WATCHTOWER. Written 2026-08-17 after watchtower took gitea down for
# three days.
#
# WHY NOT WATCHTOWER: podman-compose puts every project in a POD. Watchtower does
# not update through compose — it builds a replacement container from the image
# plus the old container's config, and that replacement lands OUTSIDE the pod.
# On 2026-08-14 it did exactly that to gitea, reported `failed=0 updated=1`, and
# left it returning 502 until someone noticed three days later.
#
# This script never touches containers directly. It pulls the image and then
# restarts the SYSTEMD UNIT, so podman-compose rebuilds the project the same way
# it does at boot — pod, network aliases, published ports and all.
#
# ─────────────────────────────────────────────────────────────────────────────
# USAGE
# update-containers # every service except SKIP (what the timer runs)
# update-containers gitea # one service, e.g. from CI after a push
# update-containers --dry-run # show what WOULD update, change nothing
# update-containers --list # show services and their images, then exit
#
# Runs nightly at 00:00 via podman-update.timer (server/systemd/, enabled by
# setup-server). Log: ~/Server/logs/update-containers.log
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
# ═════════════════════════════════════════════════════════════════════════════
# CONFIG — this is the part you edit
# ═════════════════════════════════════════════════════════════════════════════
# Services that must NEVER update unattended. Add a service here to freeze it.
#
# postgresql — restarting it drops all databases at once, and every service
# with them. Minor 17.x bumps are safe in principle but not worth
# doing at midnight unobserved. Also: the image tag is pinned to
# pgvector/pgvector:pg17-trixie for collation reasons — an
# unattended change of base could corrupt text indexes.
# authentik — runs IRREVERSIBLE database migrations on startup. Rolling back
# the image does not roll back the schema; you would need a restore.
#
# Update these two by hand, after a pg_dump:
# podman pull <image> && systemctl --user restart podman-<name>.service
SKIP=(postgresql authentik watchtower)
# Services built locally rather than pulled. There is no registry image to check,
# so pulling is pointless — they are skipped automatically when no `image:` line
# resolves, but listing them here keeps the log quiet and the intent obvious.
LOCAL_BUILD=(bang completeuphoria sierraandtyler)
# ⚠️ NOTE ON GITEA: it is deliberately NOT in SKIP. The three-day outage was caused
# by watchtower's recreate mechanism, not by the act of updating — restarting the
# unit is the safe path this script uses. But gitea DOES run database migrations on
# version upgrades. If you would rather approve those yourself, move `gitea` into
# SKIP above; nothing else needs changing.
SERVER_DIR="$HOME/Server"
LOG_DIR="$SERVER_DIR/logs"
LOG="$LOG_DIR/update-containers.log"
# ═════════════════════════════════════════════════════════════════════════════
mkdir -p "$LOG_DIR"
DRY_RUN=0
ONLY=""
case "${1:-}" in
--dry-run) DRY_RUN=1 ;;
--list) LIST=1 ;;
--help|-h) sed -n '19,27p' "$0"; exit 0 ;;
"") ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*) ONLY="$1" ;;
esac
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG"; }
in_list() { local n="$1"; shift; local x; for x in "$@"; do [ "$x" = "$n" ] && return 0; done; return 1; }
# Units that are podman-compose services. Excludes podman's own helper units,
# which are not services and must stay disabled.
list_units() {
systemctl --user list-unit-files 'podman-*.service' --no-legend 2>/dev/null \
| awk '{print $1}' \
| grep -vE 'podman-(auto-update|restart|clean-transient|kube@|user-wait-network-online|update)' \
| sed 's/^podman-//; s/\.service$//' \
| sort
}
# Images declared in a service's compose.yml. Ignores commented lines and the
# `build:` stanza, which has no pullable image.
#
# Image tags frequently reference variables from the service's .env, e.g.
# ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
# git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest
# Those must be expanded or the pull is attempted against a literal "${...}" and
# always fails. We expand in a SUBSHELL so the .env cannot leak into this script's
# environment (these files contain database passwords and API keys).
images_for() {
local dir="$1"
[ -f "$dir/compose.yml" ] || return 0
grep -oE '^[[:space:]]*image:[[:space:]]*[^[:space:]#]+' "$dir/compose.yml" 2>/dev/null \
| sed -E 's/^[[:space:]]*image:[[:space:]]*//' \
| while IFS= read -r img; do
case "$img" in
*'${'*) ( set -a; [ -f "$dir/.env" ] && . "$dir/.env" >/dev/null 2>&1; set +a
eval "printf '%s\\n' \"$img\"" 2>/dev/null ) ;;
*) printf '%s\n' "$img" ;;
esac
done \
| grep -v '\${' \
| sort -u
}
workdir_for() {
systemctl --user show "podman-$1.service" -p WorkingDirectory --value 2>/dev/null
}
# ── --list ───────────────────────────────────────────────────────────────────
if [ "${LIST:-0}" = "1" ]; then
for svc in $(list_units); do
printf '%-22s' "$svc"
if in_list "$svc" "${SKIP[@]}"; then echo "SKIP (frozen)"; continue; fi
if in_list "$svc" "${LOCAL_BUILD[@]}"; then echo "SKIP (built locally)"; continue; fi
echo "$(images_for "$(workdir_for "$svc")" | tr '\n' ' ')"
done
exit 0
fi
# ── main ─────────────────────────────────────────────────────────────────────
# ${DRY_RUN:+...} would expand even when DRY_RUN=0, because "0" is a non-empty
# string. Build the label explicitly instead.
dry_label=""; [ "$DRY_RUN" -eq 1 ] && dry_label=" [DRY RUN]"
log "=== update run started${ONLY:+ (single service: $ONLY)}$dry_label ==="
updated=(); failed=(); checked=0
for svc in $(list_units); do
[ -n "$ONLY" ] && [ "$svc" != "$ONLY" ] && continue
if [ -z "$ONLY" ]; then
in_list "$svc" "${SKIP[@]}" && { log " $svc: frozen (SKIP list)"; continue; }
in_list "$svc" "${LOCAL_BUILD[@]}" && continue
fi
# ⚠️ NEVER resurrect a service that is deliberately stopped. `systemctl restart`
# STARTS an inactive unit — without this guard, a new n8n image would silently
# bring n8n back up months after it was intentionally shut down.
if [ "$(systemctl --user is-active "podman-$svc.service")" != "active" ]; then
[ -n "$ONLY" ] && log " $svc: unit is not active — refusing to start it"
continue
fi
dir="$(workdir_for "$svc")"
[ -d "$dir" ] || { log " $svc: no working directory, skipped"; continue; }
mapfile -t imgs < <(images_for "$dir")
[ "${#imgs[@]}" -eq 0 ] && continue
checked=$((checked+1))
changed=0
for img in "${imgs[@]}"; do
# A digest-pinned image can never change; pulling it is wasted work.
case "$img" in *@sha256:*) continue ;; esac
if ! podman pull -q "$img" >/dev/null 2>&1; then
log " $svc: pull failed for $img (private registry? built locally?)"
continue
fi
disk_id="$(podman image inspect "$img" --format '{{.Id}}' 2>/dev/null)"
[ -n "$disk_id" ] || continue
# Compare the image on disk against what each RUNNING CONTAINER is actually
# using — not against the pre-pull image ID.
#
# Comparing before/after the pull looks equivalent but is subtly broken: a
# --dry-run (or any earlier pull) fetches the new image, so a later real run
# sees before == after, concludes "no change", and never restarts. The
# service then runs the OLD image forever with the new one sitting on disk,
# invisible. Anchoring on the running container makes the check idempotent
# and self-healing — it reports drift no matter who pulled, or when.
#
# ⚠️ Use `inspect .Image`, NOT `ps --format {{.ImageID}}`. The former returns
# the full 64-char ID matching `image inspect .Id`; the latter returns a
# 12-char short ID, so comparing them is ALWAYS unequal and every service
# looks like it needs an update.
while IFS='|' read -r cname cimgname cimgid; do
[ "$cimgname" = "$img" ] || continue
if [ "$cimgid" != "$disk_id" ]; then
log " $svc: $cname on ${cimgid:0:12} -> new image ${disk_id:0:12} ($img)"
changed=1
fi
done < <(podman ps --filter "label=com.docker.compose.project=$svc" --format '{{.Names}}' 2>/dev/null \
| while IFS= read -r n; do
podman inspect "$n" --format '{{.Name}}|{{.ImageName}}|{{.Image}}' 2>/dev/null
done)
done
[ "$changed" -eq 0 ] && continue
if [ "$DRY_RUN" -eq 1 ]; then
log " $svc: would restart (dry run)"
updated+=("$svc")
continue
fi
# THE IMPORTANT LINE: restart the unit so podman-compose rebuilds the project
# (pod, networks, aliases, ports). Never recreate the container directly.
if systemctl --user restart "podman-$svc.service" 2>>"$LOG"; then
sleep 5
if [ "$(systemctl --user is-active "podman-$svc.service")" = "active" ]; then
log " $svc: RESTARTED ok"
updated+=("$svc")
else
log " $svc: ⚠ RESTARTED BUT UNIT NOT ACTIVE"
failed+=("$svc")
fi
else
log " $svc: ⚠ RESTART FAILED"
failed+=("$svc")
fi
done
# Reclaim layers the old images left behind. Dangling only — never touches an
# image a container still references.
if [ "$DRY_RUN" -eq 0 ] && [ "${#updated[@]}" -gt 0 ]; then
freed="$(podman image prune -f 2>/dev/null | tail -1)"
[ -n "$freed" ] && log " pruned: $freed"
fi
log "=== done: $checked checked, ${#updated[@]} updated${updated:+ (${updated[*]})}, ${#failed[@]} failed${failed:+ (${failed[*]})} ==="
# Non-zero if anything failed, so the systemd unit shows as failed and
# `systemctl --user --failed` surfaces it.
[ "${#failed[@]}" -eq 0 ]
+22
View File
@@ -0,0 +1,22 @@
[Unit]
# Nightly image updates. Replaces watchtower, which recreated containers outside
# their podman-compose pod and took gitea down for three days — see the header
# of server/scripts/update-containers.
Description=Pull new container images and restart changed services
Documentation=https://git.gbrown.org/gib/Panama
# Needs the network and the podman socket path to exist.
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# %h rather than a literal home so the unit works for whichever user linked it.
# The one assumption is the standard checkout path; a machine with PANAMA_PATH
# somewhere else edits this line, and setup-server will say so when the script
# is not where this points.
ExecStart=%h/.local/share/Panama/server/scripts/update-containers
# Pulling ~20 images over a slow registry can take a while; do not kill it early.
TimeoutStartSec=3600
# The script logs to ~/Server/logs/update-containers.log as well as the journal.
StandardOutput=journal
StandardError=journal
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Nightly container image updates at midnight
Documentation=https://git.gbrown.org/gib/Panama
[Timer]
# Midnight local time.
OnCalendar=*-*-* 00:00:00
# If the machine was off at midnight, run once after it comes back rather than
# skipping the day entirely.
Persistent=true
# Avoid every timer on the box firing at the same instant.
RandomizedDelaySec=300
[Install]
# User timers need linger, which setup-server enables.
WantedBy=timers.target
+35
View File
@@ -0,0 +1,35 @@
# Which kind of machine this is: 'desktop' or 'server'. Sourced, not run.
#
# The role is the one interview answer Panama keeps. Everything else is
# transient by design because it is personal -- a name, an email -- and
# re-asking costs less than a state file that drifts. The role is neither
# personal nor re-askable: `panama update` runs with no interview at all, and
# a server updated as a desktop would acquire Hyprland, so the answer has to
# outlive the run that gave it.
#
# Precedence: an exported PANAMA_ROLE (a fresh install, where the interview
# just asked) beats the recorded file (every later run), which beats the
# default. The default is desktop because every machine that predates roles
# is one.
PANAMA_ROLE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/role"
panama_role() {
local role="${PANAMA_ROLE:-}"
if [[ -z "$role" && -r "$PANAMA_ROLE_FILE" ]]; then
role="$(<"$PANAMA_ROLE_FILE")"
fi
case "$role" in
server) printf 'server' ;;
*) printf 'desktop' ;;
esac
}
# Anything but 'server' records 'desktop' rather than erroring: the recorded
# file must never hold a value panama_role would refuse to read back.
panama_role_record() {
local role="$1"
[[ "$role" == server ]] || role=desktop
mkdir -p "$(dirname "$PANAMA_ROLE_FILE")"
printf '%s\n' "$role" >"$PANAMA_ROLE_FILE"
}
+37
View File
@@ -0,0 +1,37 @@
# Every Panama machine, desktop or server. This is the shell environment
# itself -- the tools config/bash/shell and the aliases assume -- plus the
# few things both roles genuinely share. Desktop-only tools live in
# initial-packages; server-only ones in server-packages.
awk
bat
btop
curl
# `dnf copr`, which install-packages needs to enable the Hyprland COPR. A
# machine that has used copr before has it; a fresh Workstation install does
# not, and the failure would cost the entire desktop.
dnf5-plugins
eza
fontconfig
fzf
gh
git-all
gum
jq
neovim
# config/bash/shell initialises the prompt with this.
oh-my-posh
# ssh-keygen, which setup-identity uses to create a key on request.
openssh
openssl
# Rootless containers: the compose services on a server, the speech-to-text
# quadlet and the Containers settings page on a desktop.
podman
# What provides `podman compose` on Fedora; without it every unit's
# ExecStart resolves to an error about a missing external provider.
podman-compose
python3-dnf
ripgrep
tmux
unzip
wget
zoxide
-3
View File
@@ -24,14 +24,11 @@ python3-questionary
python3-pyperclip python3-pyperclip
php php
php-fpm php-fpm
# Rootless containers, and the backend for the Containers settings page.
podman
python3-devel python3-devel
python3-gobject python3-gobject
python3-tkinter python3-tkinter
python3-torch python3-torch
python3-virtualenv python3-virtualenv
ripgrep
ruby ruby
sqlite3 sqlite3
# vimx: clipboard-capable vim, which the shipped `vim` alias and the vimrc # vimx: clipboard-capable vim, which the shipped `vim` alias and the vimrc
+3 -26
View File
@@ -1,38 +1,15 @@
awk # Desktop machines only. What every machine gets -- the shell environment
bat # both roles share -- moved to core-packages; this is the remainder that only
btop # makes sense with a screen, a session, or a person developing at it.
cargo cargo
curl
# `dnf copr`, which install-packages needs to enable the Hyprland COPR. A
# machine that has used copr before has it; a fresh Workstation install does
# not, and the failure would cost the entire desktop.
dnf5-plugins
eza
# install-packages installs flatpaks with it; Fedora Workstation ships it, # install-packages installs flatpaks with it; Fedora Workstation ships it,
# but a minimal install does not. # but a minimal install does not.
flatpak flatpak
fontconfig
fwupd fwupd
fzf
gh
git-all
gum
jq
kitty kitty
ksshaskpass ksshaskpass
libselinux-utils libselinux-utils
neovim
# config/bash/shell initialises the prompt with this.
oh-my-posh
# ssh-keygen, which setup-identity uses to create a key on request.
openssh
openssl
pciutils pciutils
python3-dnf
python3-neovim python3-neovim
rustup rustup
tmux
unzip
wget
wireguard-tools wireguard-tools
zoxide
+13
View File
@@ -0,0 +1,13 @@
# Only on role=server, on top of core-packages. Deliberately short: a server
# is the shell environment plus containers, and almost everything it needs is
# already core.
#
# setup-server opens 80/443/81 through it. Fedora Server ships it; a minimal
# cloud image may not, and a server whose firewall step silently no-ops is a
# server somebody believes is firewalled.
firewalld
# Node comes through nvm here for the same reason as on the desktop --
# config/bash/shell switches versions per project from .nvmrc -- and because
# install-packages puts Codex on with npm, agents' MCP servers want a node,
# and a system nodejs earlier on PATH would win every switch.
nvm
+155 -112
View File
@@ -73,6 +73,148 @@ PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
# shellcheck source=../lib/extras-catalog # shellcheck source=../lib/extras-catalog
source "$PANAMA_PATH/setup/lib/extras-catalog" source "$PANAMA_PATH/setup/lib/extras-catalog"
# Which machine this is. A server takes the short path below: core tools,
# node, the agents -- no third-party repos, no desktop, no flatpaks.
# shellcheck source=../lib/machine-role
source "$PANAMA_PATH/setup/lib/machine-role"
ROLE="$(panama_role)"
# One list, installed the way every list is installed: --skip-unavailable so a
# single rotted name cannot cost the transaction, then report_missing so a
# skipped name is a warning somebody reads.
install_list() {
local file="$PANAMA_PATH/setup/packages/$1" label="$2" packages
if [[ -f "$file" ]]; then
packages=$(packages_in "$file")
log "Installing $label Packages"
echo -e "Includes the following packages:"
echo -e "$(<"$file")"
sudo dnf install -y --skip-unavailable $packages > /dev/null
report_missing "$file"
log "$label packages installed!"
else
log "Package list was not in specified path: $file"
fi
}
# --- Node and pnpm, through nvm ----------------------------------------------
#
# nvm is a shell function rather than a binary, so it has to be sourced before
# it can be used at all -- and its script reads variables that `set -u` above
# treats as fatal, so the strictness is lifted for exactly that source and put
# straight back.
#
# Deliberately not dnf's nodejs: config/bash/shell switches Node per project
# from .nvmrc, and a system Node earlier on PATH would win every switch, leaving
# `nvm use` looking like it did nothing.
#
# pnpm goes inside the nvm-managed Node rather than beside it as its own dnf
# package, so it travels with the version it belongs to instead of outliving it.
setup_node() {
if [[ -s /etc/profile.d/nvm.sh ]]; then
log "Installing the latest Node LTS through nvm"
set +u
# shellcheck source=/dev/null
source /etc/profile.d/nvm.sh
if nvm install --lts >/dev/null 2>&1; then
nvm alias default 'lts/*' >/dev/null 2>&1 || true
npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); }
log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)"
else
log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)")
fi
set -u
else
log "nvm is not installed, so Node was not set up"
fi
}
# --- Applications no repository packages -------------------------------------
#
# Everything else Panama installs comes from dnf or Flathub. These do not
# exist in either, so each is an explicit exception with a reason, and each is
# skipped when already present so a re-run costs nothing.
#
# None of them pins a version. sunhat pinned URLs -- upscayl 2.11.5, LACT 0.5.4,
# a fedora-40 RPM -- and every one of them was a 404 within a release cycle. An
# installer that resolves "latest" keeps working; one that names a version rots.
#
# A failure here is logged and stepped over rather than aborting: an
# unreachable third-party host should not cost the rest of the run.
# Bun: the JavaScript runtime and package manager. No RPM, no flatpak.
install_bun() {
if [[ -x "$HOME/.bun/bin/bun" ]]; then
log "Bun already installed at \"$HOME/.bun/bin/bun\""
else
log "Installing Bun via curl..."
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); }
fi
}
# Claude Code: Anthropic's CLI. The official installer keeps itself updated
# afterwards, so this runs once and then never needs to again.
install_claude_code() {
if command -v claude >/dev/null 2>&1; then
log "Claude Code already installed at \"$(command -v claude)\""
else
log "Installing Claude Code via the official installer..."
curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || { log "Claude Code install failed; skipping"; softly_failed+=("Claude Code"); }
fi
}
# Codex: OpenAI's CLI. Distributed through npm, which is why this runs after
# setup_node -- the nvm-managed Node is the one it should land in.
install_codex() {
if command -v codex >/dev/null 2>&1; then
log "Codex already installed at \"$(command -v codex)\""
elif command -v npm >/dev/null 2>&1; then
log "Installing Codex via npm..."
npm install -g @openai/codex >/dev/null 2>&1 || { log "Codex install failed; skipping"; softly_failed+=("Codex"); }
else
log "npm is not available, so Codex was not installed"; softly_failed+=("Codex")
fi
}
# --- What was stepped over ---------------------------------------------------
#
# Tolerating a failure is only better than aborting on it if somebody is told.
# The whole point of surviving a soft failure is that the rest gets installed
# anyway -- but a machine missing something should say so once, here, rather
# than be discovered a week later.
report_soft_failures() {
if (( ${#softly_failed[@]} > 0 )); then
log "Installed, but these were stepped over:"
printf ' - %s\n' "${softly_failed[@]}"
log "None of them stops the machine, but this run is not recorded as"
log "complete, so the next 'panama update' tries them again."
# A step that did not complete has not happened. Exiting non-zero is what
# keeps ./install from stamping the packages hash over the gaps -- stamped,
# they would never be retried (the hash-skip would say nothing changed).
exit 1
fi
}
# --- The server path ---------------------------------------------------------
#
# Everything a server runs is above this line plus the lists it installs. No
# RPM Fusion, no Terra, no COPR, no multimedia, no flatpaks: those exist for a
# desktop, and every one of them is a network dependency and a failure mode a
# headless machine has no reason to carry.
if [[ "$ROLE" == server ]]; then
echo -e "\n--- Installing packages (server) ---"
log "Updating all packages. This may take a while"
sudo dnf update -y --refresh > /dev/null
install_list core-packages "Core"
install_list server-packages "Server"
setup_node
install_bun
install_claude_code
install_codex
report_soft_failures
exit 0
fi
echo -e "\n--- Installing Repositories ---" echo -e "\n--- Installing Repositories ---"
log "Installing RPM Fusion Free and Nonfree Repositories" log "Installing RPM Fusion Free and Nonfree Repositories"
sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /dev/null
@@ -105,37 +247,14 @@ echo -e "\n--- Installing relevant packages ---"
log "Updating all packages. This may take a while" log "Updating all packages. This may take a while"
sudo dnf update -y --refresh > /dev/null sudo dnf update -y --refresh > /dev/null
# --- Install all initial packages --- # --- Install the shared core, then the desktop-only lists ---
PACKAGES_FILE="$PANAMA_PATH/setup/packages/initial-packages" # --skip-unavailable throughout (inside install_list): dnf5 refuses a whole
if [[ -f "$PACKAGES_FILE" ]]; then # transaction over one missing name, so a single rotted entry used to cost
INITIAL_PACKAGES=$(packages_in "$PACKAGES_FILE") # every package in a list -- and the desktop below never installed. The
log "Installing Initial Packages" # skipped names are reported afterwards rather than silently dropped.
echo -e "Includes the following packages:" install_list core-packages "Core"
echo -e "$(<"$PACKAGES_FILE")" install_list initial-packages "Initial"
# --skip-unavailable: dnf5 refuses a whole transaction over one missing install_list desktop-packages "Desktop"
# name, so a single rotted entry in this list used to cost every package
# in it -- and the desktop below never installed. The skipped names are
# reported afterwards rather than silently dropped.
sudo dnf install -y --skip-unavailable $INITIAL_PACKAGES > /dev/null
report_missing "$PACKAGES_FILE"
log "Initial packages installed!"
else
log "Package list was not in specified path: $PACKAGES_FILE"
fi
# --- Install Desktop Packages ---
DESKTOP_FILE="$PANAMA_PATH/setup/packages/desktop-packages"
if [[ -f "$DESKTOP_FILE" ]]; then
DESKTOP_PACKAGES=$(packages_in "$DESKTOP_FILE")
log "Installing Desktop Packages"
echo -e "Includes the following packages:"
echo -e "$(<"$DESKTOP_FILE")"
sudo dnf install -y --skip-unavailable $DESKTOP_PACKAGES > /dev/null
report_missing "$DESKTOP_FILE"
log "Desktop packages installed!"
else
log "Package list was not in specified path: $DESKTOP_FILE"
fi
# --- Install the Hyprland desktop --- # --- Install the Hyprland desktop ---
# #
@@ -212,70 +331,10 @@ else
log "Package list was not in specified path: $DEV_FILE" log "Package list was not in specified path: $DEV_FILE"
fi fi
# --- Node and pnpm, through nvm ---------------------------------------------- setup_node
# install_bun
# nvm is a shell function rather than a binary, so it has to be sourced before install_claude_code
# it can be used at all -- and its script reads variables that `set -u` above install_codex
# treats as fatal, so the strictness is lifted for exactly that source and put
# straight back.
#
# Deliberately not dnf's nodejs: config/bash/shell switches Node per project
# from .nvmrc, and a system Node earlier on PATH would win every switch, leaving
# `nvm use` looking like it did nothing.
#
# pnpm goes inside the nvm-managed Node rather than beside it as its own dnf
# package, so it travels with the version it belongs to instead of outliving it.
if [[ -s /etc/profile.d/nvm.sh ]]; then
log "Installing the latest Node LTS through nvm"
set +u
# shellcheck source=/dev/null
source /etc/profile.d/nvm.sh
if nvm install --lts >/dev/null 2>&1; then
nvm alias default 'lts/*' >/dev/null 2>&1 || true
npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); }
log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)"
else
log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)")
fi
set -u
else
log "nvm is not installed, so Node was not set up"
fi
# --- Applications no repository packages -------------------------------------
#
# Everything else Panama installs comes from dnf or Flathub. These four do not
# exist in either, so each is an explicit exception with a reason, and each is
# skipped when already present so a re-run costs nothing.
#
# Claude Desktop is the half-exception: a repository does carry it, just not one
# Fedora or Flathub knows about, so what is exceptional there is adding the
# repository rather than installing around one.
#
# None of them pins a version. sunhat pinned URLs -- upscayl 2.11.5, LACT 0.5.4,
# a fedora-40 RPM -- and every one of them was a 404 within a release cycle. An
# installer that resolves "latest" keeps working; one that names a version rots.
#
# A failure here is logged and stepped over rather than aborting: this stage has
# already installed the desktop by this point, and an unreachable third-party
# host should not cost you that.
# Bun: the JavaScript runtime and package manager. No RPM, no flatpak.
if [[ -x "$HOME/.bun/bin/bun" ]]; then
log "Bun already installed at \"$HOME/.bun/bin/bun\""
else
log "Installing Bun via curl..."
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); }
fi
# Claude Code: Anthropic's CLI. The official installer keeps itself updated
# afterwards, so this runs once and then never needs to again.
if command -v claude >/dev/null 2>&1; then
log "Claude Code already installed at \"$(command -v claude)\""
else
log "Installing Claude Code via the official installer..."
curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || { log "Claude Code install failed; skipping"; softly_failed+=("Claude Code"); }
fi
# Claude Desktop: Anthropic ships macOS and Windows only, so this is a community # Claude Desktop: Anthropic ships macOS and Windows only, so this is a community
# RPM built from the official release. Panama used to build it from source -- it # RPM built from the official release. Panama used to build it from source -- it
@@ -406,20 +465,4 @@ for extra in ${PANAMA_EXTRAS:-}; do
fi fi
done done
# --- What was stepped over --------------------------------------------------- report_soft_failures
#
# Tolerating a failure is only better than aborting on it if somebody is told.
# This stage now survives a codec swap that finds nothing to swap, and the whole
# point of surviving it is that the desktop gets installed anyway -- but a
# machine missing its video codecs should say so once, here, rather than be
# discovered a week later by a video that will not play.
if (( ${#softly_failed[@]} > 0 )); then
log "Installed, but these were stepped over:"
printf ' - %s\n' "${softly_failed[@]}"
log "None of them stops the desktop, but this run is not recorded as"
log "complete, so the next 'panama update' tries them again."
# A step that did not complete has not happened. Exiting non-zero is what
# keeps ./install from stamping the packages hash over the gaps -- stamped,
# they would never be retried (the hash-skip would say nothing changed).
exit 1
fi
+48 -9
View File
@@ -42,6 +42,24 @@ yes_no() { gum confirm --default=false "$1"; }
# ── Machine ────────────────────────────────────────────────────────────────── # ── Machine ──────────────────────────────────────────────────────────────────
heading "This machine" heading "This machine"
# The role decides most of what follows: a server is never asked about NVIDIA
# drivers or Steam, and a desktop is never asked about compose services. It is
# also the one answer that outlives the run -- install records it durably,
# because `panama update` asks nothing and still has to know which machine it
# is updating. See setup/lib/machine-role.
#
# PANAMA_ROLE_PRESET is how `./install --server` answers this without a prompt,
# for the curl-onto-a-fresh-VPS path where the caller already said what the
# machine is. An empty or escaped choice falls back to desktop, which is what
# every Panama machine was before roles existed.
role="${PANAMA_ROLE_PRESET:-}"
if [[ -z "$role" ]]; then
role="$(gum choose --header "What is this machine?" "desktop" "server")" || role=""
fi
[[ "$role" == server ]] || role=desktop
record PANAMA_ROLE "$role"
current_hostname="$(hostname)" current_hostname="$(hostname)"
printf 'Current hostname: %s\n' "$current_hostname" printf 'Current hostname: %s\n' "$current_hostname"
new_hostname="" new_hostname=""
@@ -94,11 +112,23 @@ record PANAMA_SSH_KEY "$ssh_key"
# machine rather than an answer to a hypothetical. A machine with no NVIDIA card # machine rather than an answer to a hypothetical. A machine with no NVIDIA card
# is never asked about drivers, and one with nothing to remove is never asked # is never asked about drivers, and one with nothing to remove is never asked
# about removing it. # about removing it.
#
heading "Hardware" # A server is asked none of it. The stages these answers feed --
# install-hardware, the debloat removal -- do not run on the server path at
# all, and a question whose answer nothing consumes is a control that lies.
# The defaults are still recorded so the answers file has the same shape
# either way.
nvidia=no nvidia=no
mok_hash="" mok_hash=""
installed=()
firmware=no
debloat=no
if [[ "$role" != server ]]; then
heading "Hardware"
nvidia_card="$(lspci 2>/dev/null | grep -iE 'vga compatible|3d controller' | grep -i nvidia | sed 's/.*: //' | head -1)" nvidia_card="$(lspci 2>/dev/null | grep -iE 'vga compatible|3d controller' | grep -i nvidia | sed 's/.*: //' | head -1)"
if [[ -n "$nvidia_card" ]]; then if [[ -n "$nvidia_card" ]]; then
@@ -139,13 +169,9 @@ if [[ -n "$nvidia_card" ]]; then
else else
printf 'No NVIDIA card found.\n' printf 'No NVIDIA card found.\n'
fi fi
record PANAMA_NVIDIA "$nvidia"
record PANAMA_MOK_HASH "$mok_hash"
# The stage that removes them owns the list, so there is one copy of it. # The stage that removes them owns the list, so there is one copy of it.
debloat=no
mapfile -t removable < <("$(dirname "${BASH_SOURCE[0]}")/install-hardware" --debloat-list) mapfile -t removable < <("$(dirname "${BASH_SOURCE[0]}")/install-hardware" --debloat-list)
installed=()
for package in "${removable[@]}"; do for package in "${removable[@]}"; do
rpm -q "$package" >/dev/null 2>&1 && installed+=("$package") rpm -q "$package" >/dev/null 2>&1 && installed+=("$package")
done done
@@ -154,14 +180,18 @@ if (( ${#installed[@]} > 0 )); then
debloat=yes debloat=yes
fi fi
fi fi
record PANAMA_DEBLOAT "$debloat"
firmware=no
if command -v fwupdmgr >/dev/null 2>&1; then if command -v fwupdmgr >/dev/null 2>&1; then
if yes_no "Update firmware with fwupdmgr?"; then if yes_no "Update firmware with fwupdmgr?"; then
firmware=yes firmware=yes
fi fi
fi fi
fi # role != server
record PANAMA_NVIDIA "$nvidia"
record PANAMA_MOK_HASH "$mok_hash"
record PANAMA_DEBLOAT "$debloat"
record PANAMA_FIRMWARE "$firmware" record PANAMA_FIRMWARE "$firmware"
# ── Applications ───────────────────────────────────────────────────────────── # ── Applications ─────────────────────────────────────────────────────────────
@@ -175,10 +205,12 @@ record PANAMA_FIRMWARE "$firmware"
# applications nobody chose, on a machine whose owner answered a question they # applications nobody chose, on a machine whose owner answered a question they
# thought was about something else. # thought was about something else.
extras=""
if [[ "$role" != server ]]; then
heading "Applications" heading "Applications"
extras_dir="$(dirname "${BASH_SOURCE[0]}")/../packages/extras" extras_dir="$(dirname "${BASH_SOURCE[0]}")/../packages/extras"
extras=""
if [[ -d "$extras_dir" ]]; then if [[ -d "$extras_dir" ]]; then
mapfile -t categories < <(for file in "$extras_dir"/*; do mapfile -t categories < <(for file in "$extras_dir"/*; do
[[ -f "$file" ]] && basename "$file" [[ -f "$file" ]] && basename "$file"
@@ -189,6 +221,8 @@ if [[ -d "$extras_dir" ]]; then
extras="${extras% }" extras="${extras% }"
fi fi
fi fi
fi # role != server
record PANAMA_EXTRAS "$extras" record PANAMA_EXTRAS "$extras"
# ── Personal content ───────────────────────────────────────────────────────── # ── Personal content ─────────────────────────────────────────────────────────
@@ -227,17 +261,22 @@ shown() { [[ -n "$1" ]] && printf '%s' "$1" || printf 'unchanged'; }
heading "Ready" heading "Ready"
gum style --border rounded --padding "0 1" "$( gum style --border rounded --padding "0 1" "$(
printf 'Role %s\n' "$role"
printf 'Hostname %s\n' "${new_hostname:-"$current_hostname (unchanged)"}" printf 'Hostname %s\n' "${new_hostname:-"$current_hostname (unchanged)"}"
printf 'Git name %s\n' "$(shown "$git_name")" printf 'Git name %s\n' "$(shown "$git_name")"
printf 'Git email %s\n' "$(shown "$git_email")" printf 'Git email %s\n' "$(shown "$git_email")"
printf 'Git editor %s\n' "$(shown "$git_editor")" printf 'Git editor %s\n' "$(shown "$git_editor")"
printf 'GitHub %s\n' "$([[ "$gh_login" == yes ]] && echo "sign in" || echo "no change")" printf 'GitHub %s\n' "$([[ "$gh_login" == yes ]] && echo "sign in" || echo "no change")"
printf 'SSH key %s\n' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")" printf 'SSH key %s\n' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")"
# Hardware and extras were never asked on a server, and a summary line for
# a question that was not asked reads as a decision that was not made.
if [[ "$role" != server ]]; then
printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")" printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")"
printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enroll a key" || echo "no change")" printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enroll a key" || echo "no change")"
printf 'Fedora apps %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")" printf 'Fedora apps %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")"
printf 'Firmware %s\n' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")" printf 'Firmware %s\n' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")"
printf 'Extras %s\n' "${extras:-none}" printf 'Extras %s\n' "${extras:-none}"
fi
printf 'Personal %s' "$([[ "$user_content" == yes ]] && echo "link user/ into home" || echo "not linked")" printf 'Personal %s' "$([[ "$user_content" == yes ]] && echo "link user/ into home" || echo "not linked")"
)" )"
+70 -42
View File
@@ -20,6 +20,13 @@ CONFIG="$HOME/.config"
# every symlink below fail silently while the stage still reported success. # every symlink below fail silently while the stage still reported success.
mkdir -p "$PANAMA_OLD" "$CONFIG" mkdir -p "$PANAMA_OLD" "$CONFIG"
# Which machine this is. A server links the shell environment -- bash, vim,
# the universal dirs below, the tmux and btop theming, the hook samples --
# and then stops: everything after the early exit assumes a screen.
# shellcheck source=../lib/machine-role
source "$PANAMA_PATH/setup/lib/machine-role"
ROLE="$(panama_role)"
# --- Bashrc --- # --- Bashrc ---
echo -e "\n--- Replacing .bashrc ---" echo -e "\n--- Replacing .bashrc ---"
# Backup existing .bashrc if it's a regular file # Backup existing .bashrc if it's a regular file
@@ -45,8 +52,21 @@ ln -s "$PANAMA_BASH/.bashrc" "$HOME/.bashrc"
# #
# gnome-control-center is a separate matter and stays declared: Panama's own # gnome-control-center is a separate matter and stays declared: Panama's own
# Settings hands off to it for the panels it deliberately does not own. # Settings hands off to it for the panels it deliberately does not own.
dirs=("espanso" "ghostty" "gtk-3.0" "gtk-4.0" "hypr" "kitty" "nvim" \ #
"quickshell" "tmux" "uwsm" "vicinae" "wofi" "xdg-desktop-portal") # Split by role, and every directory under config/dot must be claimed by
# exactly one of these three lists -- universal, desktop, or handled (linked
# or consumed some other way below: btop exposes only themes, ohmyposh is
# read in place by config/bash/shell, panama's hook samples are copied, vim
# links a single file). tests/setup/dotfile-classification-contract fails
# when a new directory appears in none of them, because unclassified means
# silently absent from every server.
universal_dirs=("nvim" "tmux")
desktop_dirs=("espanso" "ghostty" "gtk-3.0" "gtk-4.0" "hypr" "kitty" \
"quickshell" "uwsm" "vicinae" "wofi" "xdg-desktop-portal")
handled_dirs=("btop" "ohmyposh" "panama" "vim")
dirs=("${universal_dirs[@]}")
[[ "$ROLE" == server ]] || dirs+=("${desktop_dirs[@]}")
# --- Vim vimrc --- # --- Vim vimrc ---
echo -e "\n--- Setting up vim ---" echo -e "\n--- Setting up vim ---"
@@ -107,6 +127,54 @@ elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME" log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
fi fi
# btop reads themes from its own config directory, but OWNS btop.conf -- it
# rewrites that file on exit -- so only the theme files are exposed, per file,
# and the config itself is left to btop. panama-theme-apps edits the single
# color_theme line in place.
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
mkdir -p "$BTOP_THEME_DIR"
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
[ -e "$btop_theme_src" ] || continue
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
if [ -L "$btop_theme_dst" ]; then
rm "$btop_theme_dst"
fi
if [ -e "$btop_theme_dst" ]; then
log "Keeping existing btop theme at $btop_theme_dst"
else
ln -s "$btop_theme_src" "$btop_theme_dst"
log "Linked btop theme → $btop_theme_dst"
fi
done
# Hook samples. Copied rather than symlinked, and only when absent: hooks are
# the user's own scripts, and ~/.config/panama is theirs too -- settings.json
# lives there. A symlinked directory would put their scripts in the repository
# working tree, which is the mistake the gtk bookmarks made.
PANAMA_HOOK_SAMPLES="$PANAMA_DOT/panama/hooks"
USER_HOOK_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks"
if [ -d "$PANAMA_HOOK_SAMPLES" ]; then
mkdir -p "$USER_HOOK_DIR"
for sample in "$PANAMA_HOOK_SAMPLES"/*.sample; do
[ -e "$sample" ] || continue
sample_dst="$USER_HOOK_DIR/$(basename "$sample")"
if [ -e "$sample_dst" ]; then
log "Keeping existing hook sample at $sample_dst"
else
cp "$sample" "$sample_dst"
log "Copied hook sample → $sample_dst"
fi
done
fi
# A server's dotfiles end here. Everything below assumes a session: lock
# screens, GTK, launcher themes, icons, wallpapers, Firefox chrome, desktop
# entries, quadlets for the desktop's own containers, file associations.
if [ "$ROLE" = server ]; then
log "Server role: desktop dotfiles skipped"
exit 0
fi
# hyprlock.conf is generated from a template on every color scheme change and # hyprlock.conf is generated from a template on every color scheme change and
# is not committed. Seed it so the FIRST lock of a fresh install is themed -- # is not committed. Seed it so the FIRST lock of a fresh install is themed --
# without it hyprlock falls back to its own defaults, which is a bare gray # without it hyprlock falls back to its own defaults, which is a bare gray
@@ -139,26 +207,6 @@ elif [ -r "$HYPRLOCK_TEMPLATE" ]; then
fi fi
fi fi
# btop reads themes from its own config directory, but OWNS btop.conf -- it
# rewrites that file on exit -- so only the theme files are exposed, per file,
# and the config itself is left to btop. panama-theme-apps edits the single
# color_theme line in place.
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
mkdir -p "$BTOP_THEME_DIR"
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
[ -e "$btop_theme_src" ] || continue
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
if [ -L "$btop_theme_dst" ]; then
rm "$btop_theme_dst"
fi
if [ -e "$btop_theme_dst" ]; then
log "Keeping existing btop theme at $btop_theme_dst"
else
ln -s "$btop_theme_src" "$btop_theme_dst"
log "Linked btop theme → $btop_theme_dst"
fi
done
# GTK3 has no include mechanism, so its settings.ini is generated whole from a # GTK3 has no include mechanism, so its settings.ini is generated whole from a
# template rather than layered. Without this, a fresh checkout has a template # template rather than layered. Without this, a fresh checkout has a template
# and no settings.ini, and GTK3 applications fall back to their built-in theme. # and no settings.ini, and GTK3 applications fall back to their built-in theme.
@@ -454,26 +502,6 @@ fi
# than by symlinking the directory itself, the same way the quadlets and # than by symlinking the directory itself, the same way the quadlets and
# desktop entries are: Nautilus writes nothing here today, but a directory # desktop entries are: Nautilus writes nothing here today, but a directory
# symlink into the repository is how machine state ends up in a tracked path. # symlink into the repository is how machine state ends up in a tracked path.
# Hook samples. Copied rather than symlinked, and only when absent: hooks are
# the user's own scripts, and ~/.config/panama is theirs too -- settings.json
# lives there. A symlinked directory would put their scripts in the repository
# working tree, which is the mistake the gtk bookmarks made.
PANAMA_HOOK_SAMPLES="$PANAMA_DOT/panama/hooks"
USER_HOOK_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks"
if [ -d "$PANAMA_HOOK_SAMPLES" ]; then
mkdir -p "$USER_HOOK_DIR"
for sample in "$PANAMA_HOOK_SAMPLES"/*.sample; do
[ -e "$sample" ] || continue
sample_dst="$USER_HOOK_DIR/$(basename "$sample")"
if [ -e "$sample_dst" ]; then
log "Keeping existing hook sample at $sample_dst"
else
cp "$sample" "$sample_dst"
log "Copied hook sample → $sample_dst"
fi
done
fi
PANAMA_NAUTILUS_DIR="$PANAMA_PATH/config/local/share/nautilus-python/extensions" PANAMA_NAUTILUS_DIR="$PANAMA_PATH/config/local/share/nautilus-python/extensions"
USER_NAUTILUS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/nautilus-python/extensions" USER_NAUTILUS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/nautilus-python/extensions"
if [ -d "$PANAMA_NAUTILUS_DIR" ]; then if [ -d "$PANAMA_NAUTILUS_DIR" ]; then
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# The server counterpart of link-dotfiles, and deliberately thin: ~/Server is
# created here, and everything about which services are linked into it is
# panama-server's job -- relink refreshes the symlinks of whatever this
# machine has enabled, and enabling something new is a decision a person makes
# with `panama server enable`, not something an installer infers.
set -euo pipefail
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
mkdir -p "$HOME/Server"
exec "$PANAMA_PATH/bin/panama-server" relink
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# What makes a Fedora machine able to run rootless compose services. Server
# role only -- ./install never runs this on a desktop. Idempotent throughout:
# every step checks the machine before touching it, so a re-run on a machine
# that already has all of this changes nothing and says so.
set -euo pipefail
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
# ── Linger ───────────────────────────────────────────────────────────────────
# Without it every user unit -- which is every service -- stops at logout and
# starts only at login, which on a server means "runs while somebody is SSHed
# in". Linger is what makes the user session a real init.
if loginctl show-user "$USER" 2>/dev/null | grep -q '^Linger=yes'; then
log "Linger already enabled for $USER"
else
log "Enabling linger for $USER"
sudo loginctl enable-linger "$USER"
fi
# ── Unprivileged ports from 80 ───────────────────────────────────────────────
# Rootless containers cannot bind 80/443 while the kernel reserves everything
# below 1024 for root. Lowering the floor to 80 is what lets the reverse proxy
# be a rootless container like everything else. A file in /etc/sysctl.d so it
# survives reboots; sysctl --system so it applies now.
SYSCTL_FILE=/etc/sysctl.d/99-rootless-ports.conf
SYSCTL_WANT='net.ipv4.ip_unprivileged_port_start=80'
if [[ -r "$SYSCTL_FILE" ]] && grep -qx "$SYSCTL_WANT" "$SYSCTL_FILE"; then
log "Unprivileged ports already start at 80 ($SYSCTL_FILE)"
else
log "Allowing unprivileged binds from port 80"
printf '%s\n' "$SYSCTL_WANT" | sudo tee "$SYSCTL_FILE" >/dev/null
sudo sysctl --system >/dev/null
fi
# ── Firewall ─────────────────────────────────────────────────────────────────
# 80 and 443 because everything is reverse-proxied; 81 for the proxy's own
# admin portal. Deliberately nothing else: a service needing another port open
# documents that in its own folder and it is opened by hand, because a list of
# per-service firewall holes maintained by an installer is a list nobody
# audits.
if systemctl is-active firewalld >/dev/null 2>&1; then
reload_needed=0
for port in 80 443 81; do
if sudo firewall-cmd --permanent --query-port="${port}/tcp" >/dev/null 2>&1; then
log "Port ${port}/tcp already open"
else
log "Opening port ${port}/tcp"
sudo firewall-cmd --permanent --add-port="${port}/tcp" >/dev/null
reload_needed=1
fi
done
(( reload_needed )) && sudo firewall-cmd --reload >/dev/null
else
log "firewalld is not active; no ports to open"
fi
# ── The shared container network ─────────────────────────────────────────────
# Every compose file expects nginx-bridge as an external network: the reverse
# proxy reaches each service by container name across it, and no service needs
# a published port of its own. External means compose will not create it, so
# somebody has to -- this is that somebody.
if podman network exists nginx-bridge 2>/dev/null; then
log "podman network nginx-bridge already exists"
else
log "Creating podman network nginx-bridge"
podman network create nginx-bridge >/dev/null
fi
# ── Nightly image updates ────────────────────────────────────────────────────
# server/scripts/update-containers, on a midnight timer. Linked rather than
# copied so a pull updates the machinery with everything else; see the script
# header for why this replaced watchtower.
UNIT_SRC="$PANAMA_PATH/server/systemd"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
mkdir -p "$UNIT_DIR" "$HOME/Server/logs"
for unit in podman-update.service podman-update.timer; do
src="$UNIT_SRC/$unit"
dst="$UNIT_DIR/$unit"
[[ -e "$src" ]] || { log "Missing $src; skipping the update timer"; continue; }
if [[ -L "$dst" ]]; then
rm "$dst"
elif [[ -e "$dst" ]]; then
mv "$dst" "$dst.pre-panama"
log "Kept the existing $unit as $unit.pre-panama"
fi
ln -s "$src" "$dst"
log "Linked $unit"
done
systemctl --user daemon-reload
if systemctl --user is-enabled podman-update.timer >/dev/null 2>&1; then
log "podman-update.timer already enabled"
else
log "Enabling podman-update.timer (nightly image updates)"
systemctl --user enable --now podman-update.timer
fi
+3 -1
View File
@@ -202,7 +202,9 @@ done
# the GNOME excision was caught by nothing until the array entry was removed too, # the GNOME excision was caught by nothing until the array entry was removed too,
# which was luck rather than a test. # which was luck rather than a test.
mapfile -t linked_dirs < <(sed -n '/^dirs=(/,/)/p' "$repo_dir/setup/scripts/link-dotfiles" \ # The array is assembled per role from universal_dirs and desktop_dirs, so
# those are what get read -- the assembly line itself carries no names.
mapfile -t linked_dirs < <(sed -n '/^universal_dirs=(/,/^handled_dirs=(.*)$/p' "$repo_dir/setup/scripts/link-dotfiles" \
| grep -oE '"[^"]+"' | tr -d '"') | grep -oE '"[^"]+"' | tr -d '"')
(( ${#linked_dirs[@]} > 0 )) \ (( ${#linked_dirs[@]} > 0 )) \
@@ -36,7 +36,7 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio
# authselect is on the list for the same reason: it manages Fedora's PAM and # authselect is on the list for the same reason: it manages Fedora's PAM and
# nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the # nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the
# fingerprint aliases in config/bash can rely on it without declaring it. # fingerprint aliases in config/bash can rely on it without declaring it.
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$' BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig|sysctl)$'
# bootctl and coredumpctl ship in systemd-udev, which every Fedora install # bootctl and coredumpctl ship in systemd-udev, which every Fedora install
# carries -- it is the udev half of systemd, not an optional tool. Declaring # carries -- it is the udev half of systemd, not an optional tool. Declaring
@@ -45,11 +45,12 @@ BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|fi
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|coredumpctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$' SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|coredumpctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
# Installed by install-packages itself rather than by a package list. Two # Installed by install-packages itself rather than by a package list. Two
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and # reasons, both deliberate: bun, claude and codex have no RPM or flatpak at
# node, npm and pnpm come from nvm on purpose -- a dnf nodejs earlier on PATH # all (codex comes through npm), and node, npm and pnpm come from nvm on
# would win every per-project `nvm use`, which is the whole point of having nvm. # purpose -- a dnf nodejs earlier on PATH would win every per-project
# `nvm use`, which is the whole point of having nvm.
# Anything added here needs a matching install block and a stated reason. # Anything added here needs a matching install block and a stated reason.
SELF_INSTALLED='^(bun|claude|node|npm|pnpm)$' SELF_INSTALLED='^(bun|claude|codex|node|npm|pnpm)$'
# Tools an alias may lean on without Panama installing them anywhere. The # Tools an alias may lean on without Panama installing them anywhere. The
# docker aliases serve the machines that run Docker by deliberate choice; # docker aliases serve the machines that run Docker by deliberate choice;
@@ -92,6 +93,7 @@ package_for() {
nvim) printf 'neovim' ;; nvim) printf 'neovim' ;;
fwupdmgr) printf 'fwupd' ;; fwupdmgr) printf 'fwupd' ;;
dnf4) printf 'python3-dnf' ;; dnf4) printf 'python3-dnf' ;;
firewall-cmd) printf 'firewalld' ;;
notify-send) printf 'libnotify' ;; notify-send) printf 'libnotify' ;;
wl-copy|wl-paste) printf 'wl-clipboard' ;; wl-copy|wl-paste) printf 'wl-clipboard' ;;
ssh-keygen) printf 'openssh' ;; ssh-keygen) printf 'openssh' ;;
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# This repository is public, and server/ describes real infrastructure. The
# rule that makes that safe has three parts, and each is pinned here because
# each fails silently:
#
# 1. No tracked file under server/ carries a secret. Compose files reference
# secrets as ${VAR} interpolations resolved from the .env beside them on
# the machine; .env.example names the variables with CHANGE_ME in place
# of every value that matters.
# 2. .gitignore keeps .env and data/ out of server/containers/ even when a
# cutover or a mistake puts one there. The live files belong in
# ~/Server/<Name>/, outside the checkout entirely -- the ignore is a
# seatbelt, and a seatbelt that got deleted should fail loudly.
# 3. Nothing named .env is tracked, full stop.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
server_dir="$repo_dir/server"
findings=()
note() { findings+=("$1"); }
[[ -d "$server_dir" ]] || { printf 'compose secrets contract: no server/ directory\n' >&2; exit 1; }
# ── 1. Tracked content is clean ──────────────────────────────────────────────
#
# Only tracked files: the live .env a cutover briefly leaves in a service
# directory is exactly what the gitignore exists for, and flagging it here
# would punish the ignore for working.
while IFS= read -r file; do
path="$repo_dir/$file"
[[ -f "$path" ]] || continue
# A secret-bearing key with a literal value. ${VAR} interpolations, empty
# values, the CHANGE_ME placeholder, and booleans (ALLOW_EMPTY_PASSWORD=yes
# is a switch, not a credential) are the allowed shapes; anything else
# after PASSWORD/SECRET/TOKEN/KEY is treated as a leak. Keys that merely
# configure where a secret lives (a *_FILE path, a key NAME) are not
# values.
while IFS= read -r hit; do
note "$file looks like it carries a secret: ${hit%%[=:]*}"
done < <(grep -inE '(password|secret|token|api_key|private_key|access_key)[a-z0-9_]*[[:space:]]*[:=]' "$path" 2>/dev/null \
| grep -vE '[:=][[:space:]]*["'"'"']?(\$\{|CHANGE_ME|(true|false|yes|no|[01])["'"'"']?[[:space:]]*$|["'"'"']?[[:space:]]*$)' \
| grep -viE '(_file|_path|_name|_key_name)[[:space:]]*[:=]' \
| grep -vE '^[0-9]+:[[:space:]]*#')
if grep -qE 'BEGIN [A-Z ]*PRIVATE KEY' "$path" 2>/dev/null; then
note "$file contains a private key"
fi
if grep -qE 'sk-ant-[A-Za-z0-9]|ghp_[A-Za-z0-9]{20}|xox[baprs]-[A-Za-z0-9]' "$path" 2>/dev/null; then
note "$file contains something that looks like an API token"
fi
done < <(git -C "$repo_dir" ls-files 'server/')
# ── 2. The ignore still stands ───────────────────────────────────────────────
#
# check-ignore consults the real gitignore for a path that need not exist, so
# this asserts the rule rather than the current absence of violations.
git -C "$repo_dir" check-ignore -q 'server/containers/AnyService/.env' \
|| note '.gitignore no longer covers .env under server/containers/'
git -C "$repo_dir" check-ignore -q 'server/containers/AnyService/data/dump.sql' \
|| note '.gitignore no longer covers data/ under server/containers/'
# ── 3. No .env is tracked ────────────────────────────────────────────────────
while IFS= read -r tracked; do
note "a live .env is tracked: $tracked"
done < <(git -C "$repo_dir" ls-files 'server/**/.env' 'server/.env')
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'compose secrets contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'compose secrets contract: PASS\n'
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Every service in the catalog has the same shape, because panama-server and
# the update timer both depend on it blindly:
#
# * a compose.yml, which is the only file enable symlinks into ~/Server
# * exactly one podman-*.service unit -- its filename is the unit's
# identity, so two would be an ambiguity and zero an unenableable service
# * a WorkingDirectory of %h/Server/<Name>, matching the directory enable
# creates -- a unit pointing anywhere else starts compose against a
# directory that has no .env and no data
# * every ${VAR} the compose interpolates without a default is named in
# .env.example, or the first enable renders a compose full of empty
# strings and the service runs misconfigured rather than failing
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
containers_dir="$repo_dir/server/containers"
findings=()
note() { findings+=("$1"); }
[[ -d "$containers_dir" ]] || { printf 'containers shape contract: PASS (no services yet)\n'; exit 0; }
shopt -s nullglob
for dir in "$containers_dir"/*/; do
name="$(basename "$dir")"
[[ -f "$dir/compose.yml" ]] || note "$name has no compose.yml"
units=("$dir"/*.service)
if (( ${#units[@]} == 0 )); then
note "$name has no unit file"
continue
elif (( ${#units[@]} > 1 )); then
note "$name has ${#units[@]} unit files; its identity is ambiguous"
continue
fi
unit="${units[0]}"
case "$(basename "$unit")" in
podman-*.service) ;;
*) note "$name's unit is not named podman-<name>.service: $(basename "$unit")" ;;
esac
grep -q "^WorkingDirectory=%h/Server/$name\$" "$unit" \
|| note "$name's unit does not work in %h/Server/$name"
grep -q 'podman compose' "$unit" \
|| note "$name's unit does not run podman compose"
# ${VAR} without a :- default has nowhere to come from but the .env, and
# the .env is seeded from .env.example -- so a variable the example does
# not name is one the first enable silently renders empty.
[[ -f "$dir/compose.yml" ]] || continue
while IFS= read -r var; do
[[ -n "$var" ]] || continue
if [[ ! -f "$dir/.env.example" ]]; then
note "$name interpolates \${$var} but has no .env.example"
continue
fi
grep -qE "^${var}=" "$dir/.env.example" \
|| note "$name interpolates \${$var}, which .env.example does not name"
done < <(grep -vE '^[[:space:]]*#' "$dir/compose.yml" 2>/dev/null \
| grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\}' \
| sed 's/^\${//; s/}$//' | sort -u)
done
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'containers shape contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'containers shape contract: PASS\n'
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# panama-server against a throwaway HOME, a fixture catalog, and a stubbed
# systemctl -- the behaviors that protect a machine's live services:
#
# * enable seeds .env from .env.example and REFUSES to start while a
# CHANGE_ME placeholder survives -- a service brought up with the
# placeholder as its database password runs, wrongly, forever
# * a completed enable links exactly the tracked files (compose.yml and the
# unit), reloads systemd, and enables the unit
# * disable removes the unit and nothing else: the .env and data stay
# * relink refreshes links for enabled services only, and names a service
# whose definition changed since the machine last looked -- without
# restarting anything
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
panama_server="$repo_dir/bin/panama-server"
findings=()
note() { findings+=("$1"); }
[[ -x "$panama_server" ]] || { printf 'panama-server contract: %s is not executable\n' "$panama_server" >&2; exit 1; }
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
home="$work/home"
catalog="$work/catalog"
stub_dir="$work/bin"
calls="$work/calls"
mkdir -p "$home" "$stub_dir" "$catalog/Example"
: >"$calls"
cat >"$catalog/Example/compose.yml" <<'YML'
services:
example:
image: docker.io/library/nginx:latest
environment:
DB_PASSWORD: ${DB_PASSWORD}
YML
cat >"$catalog/Example/.env.example" <<'ENV'
DB_PASSWORD=CHANGE_ME
ENV
cat >"$catalog/Example/podman-example.service" <<'UNIT'
[Unit]
Description=Podman Compose: Example
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Example
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
[Install]
WantedBy=default.target
UNIT
# The stub records every invocation; is-active answers 'inactive' so nothing
# here depends on a real systemd.
cat >"$stub_dir/systemctl" <<STUB
#!/usr/bin/env bash
printf 'systemctl %s\n' "\$*" >>"$calls"
case "\$*" in
*is-active*) echo inactive; exit 3 ;;
*is-enabled*) exit 1 ;;
esac
exit 0
STUB
cat >"$stub_dir/podman" <<STUB
#!/usr/bin/env bash
printf 'podman %s\n' "\$*" >>"$calls"
exit 0
STUB
chmod +x "$stub_dir/systemctl" "$stub_dir/podman"
run() {
HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$home/.local/state" \
PANAMA_SERVER_DIR="$catalog" PANAMA_SERVER_TARGET="$home/Server" \
PATH="$stub_dir:$PATH" "$panama_server" "$@"
}
unit_link="$home/.config/systemd/user/podman-example.service"
# ── enable stops on placeholders ─────────────────────────────────────────────
run enable Example >/dev/null 2>&1 \
&& note 'enable exited zero with CHANGE_ME still in the .env'
[[ -f "$home/Server/Example/.env" ]] \
|| note 'enable did not seed .env from .env.example'
[[ -L "$home/Server/Example/compose.yml" ]] \
|| note 'enable did not link compose.yml into ~/Server'
[[ -e "$unit_link" ]] \
&& note 'enable installed the unit despite the placeholder refusal'
grep -q 'systemctl .*enable' "$calls" \
&& note 'enable reached systemctl despite the placeholder refusal'
# ── a filled .env enables ────────────────────────────────────────────────────
printf 'DB_PASSWORD=s3cret\n' >"$home/Server/Example/.env"
: >"$calls"
run enable Example >/dev/null 2>&1 \
|| note 'enable failed with a completed .env'
[[ -L "$unit_link" ]] \
|| note 'enable did not link the unit into the systemd user directory'
grep -q 'systemctl --user daemon-reload' "$calls" \
|| note 'enable did not daemon-reload'
grep -q 'systemctl --user enable --now podman-example.service' "$calls" \
|| note 'enable did not enable --now the unit'
[[ "$(cat "$home/Server/Example/.env")" == "DB_PASSWORD=s3cret" ]] \
|| note 'enable rewrote an .env that already existed'
# ── relink names a changed definition, restarts nothing ──────────────────────
printf ' # a changed line\n' >>"$catalog/Example/compose.yml"
: >"$calls"
relink_out="$(run relink 2>&1)" || note 'relink failed'
grep -q 'Example' <<<"$relink_out" \
|| note 'relink did not name the service whose definition changed'
grep -q 'restart' "$calls" \
&& note 'relink restarted something; the restart is deliberately manual'
# A second relink with nothing new must not cry wolf.
relink_out="$(run relink 2>&1)" || note 'a repeat relink failed'
grep -q 'restart podman-example' <<<"$relink_out" \
&& note 'relink reports the same change twice'
# ── disable removes the unit and only the unit ───────────────────────────────
mkdir -p "$home/Server/Example/data"
printf 'precious\n' >"$home/Server/Example/data/keep"
: >"$calls"
run disable Example >/dev/null 2>&1 || note 'disable failed'
[[ -e "$unit_link" ]] \
&& note 'disable left the unit installed'
grep -q 'systemctl --user disable --now podman-example.service' "$calls" \
|| note 'disable did not stop the unit'
[[ -f "$home/Server/Example/.env" ]] \
|| note 'disable removed the .env'
[[ -f "$home/Server/Example/data/keep" ]] \
|| note 'disable removed data'
# ── an unknown service is a real answer ──────────────────────────────────────
run enable NoSuchThing >/dev/null 2>&1 \
&& note 'enabling an unknown service exited zero'
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'panama-server contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'panama-server contract: PASS\n'
+54 -9
View File
@@ -34,16 +34,61 @@ hyprland_at="$(line_of '^HYPR_FILE=')"
# ── Nothing fragile above the desktop ─────────────────────────────────────── # ── Nothing fragile above the desktop ───────────────────────────────────────
# #
# Named individually rather than by pattern: each is a command whose failure is # Named individually rather than by pattern: each is a command whose failure is
# survivable, and each one above the Hyprland block is a machine that boots to # survivable, and each one that EXECUTES above the Hyprland block is a machine
# nothing. # that boots to nothing.
#
# Executes, not appears: the fragile steps live in functions defined near the
# top (the server role calls them without ever reaching a desktop section),
# and a definition runs nothing. So function bodies are excluded from the
# position scan, and the desktop path's calls to those functions are required
# to sit below the desktop instead.
for fragile in 'dnf swap' 'groupupdate' 'group upgrade' 'gstreamer1-plugins' \ while IFS= read -r finding; do
'flatpak install' 'nvm install' 'curl -fsSL'; do [[ -n "$finding" ]] && note "$finding"
while read -r at; do done < <(python3 - "$installer" "$hyprland_at" <<'PY'
(( at < hyprland_at )) \ import re, sys
&& note "'$fragile' runs at line $at, above the desktop at line $hyprland_at"
done < <(grep -n -- "$fragile" "$installer" | grep -v '^\s*#' | cut -d: -f1) path, hypr = sys.argv[1], int(sys.argv[2])
done lines = open(path, encoding="utf-8").read().splitlines()
in_body = False
body = set()
for i, line in enumerate(lines, 1):
if not in_body and re.match(r'^[a-z_]+\(\)\s*\{', line):
in_body = True
body.add(i)
continue
if in_body:
body.add(i)
if line == '}':
in_body = False
fragile = ['dnf swap', 'groupupdate', 'group upgrade', 'gstreamer1-plugins',
'flatpak install', 'nvm install', 'curl -fsSL']
for i, line in enumerate(lines, 1):
if i >= hypr or i in body or line.strip().startswith('#'):
continue
for needle in fragile:
if needle in line:
print(f"'{needle}' runs at line {i}, above the desktop at line {hypr}")
# The desktop path still has to run the fragile helpers -- below the desktop.
# (The server path calls them above, inside a branch that exits before the
# desktop section; the exit is asserted back in bash.)
for call in ('setup_node', 'install_bun', 'install_claude_code', 'install_codex'):
calls = [i for i, line in enumerate(lines, 1)
if re.match(r'^\s*' + call + r'\s*$', line) and i not in body]
if not calls:
print(f"{call} is never called, so the desktop path skips it")
elif not any(i > hypr for i in calls):
print(f"{call} is only called above the desktop")
PY
)
# The server branch is what excuses fragile calls above the desktop, and only
# because it never falls through into the desktop section.
sed -n '/^if \[\[ "\$ROLE" == server \]\]; then/,/^fi/p' "$installer" | grep -q '^\s*exit 0' \
|| note 'the server branch does not exit before the desktop section'
# ── Everything fragile is actually tolerated ──────────────────────────────── # ── Everything fragile is actually tolerated ────────────────────────────────
# #
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Every directory under config/dot is claimed by exactly one of
# link-dotfiles' three lists -- universal (both roles), desktop, or handled
# (linked or consumed some other way in that script). The failure this
# prevents is quiet: a new dot directory added to the repo but classified
# nowhere links on no server, or on every server, depending on which list the
# author forgot -- and nothing says so until a machine is missing its config.
#
# The lists are lifted from the script itself rather than restated here, so
# renaming them fails loudly instead of freezing a stale copy.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
link_dotfiles="$repo_dir/setup/scripts/link-dotfiles"
dot_dir="$repo_dir/config/dot"
findings=()
note() { findings+=("$1"); }
lists="$(sed -n '/^universal_dirs=(/,/^handled_dirs=(.*)$/p' "$link_dotfiles")"
if [[ -z "$lists" ]]; then
printf 'dotfile classification contract: link-dotfiles no longer defines the three lists\n' >&2
exit 1
fi
eval "$lists"
claimed=("${universal_dirs[@]}" "${desktop_dirs[@]}" "${handled_dirs[@]}")
for dir in "$dot_dir"/*/; do
name="$(basename "$dir")"
hits=0
for entry in "${claimed[@]}"; do
[[ "$entry" == "$name" ]] && hits=$((hits + 1))
done
if (( hits == 0 )); then
note "config/dot/$name is claimed by no list, so servers silently skip or acquire it"
elif (( hits > 1 )); then
note "config/dot/$name is claimed by $hits lists"
fi
done
for entry in "${claimed[@]}"; do
[[ -d "$dot_dir/$entry" ]] \
|| note "link-dotfiles classifies '$entry', which config/dot does not contain"
done
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'dotfile classification contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'dotfile classification contract: PASS (%d directories classified)\n' "${#claimed[@]}"
+4 -2
View File
@@ -35,7 +35,9 @@ consumed="$(grep -rhoE '\$\{PANAMA_[A-Z_]+' "$repo_dir"/setup/scripts/* "$instal
# which is the akmods certificate path with an override on it. Nothing sets that # which is the akmods certificate path with an override on it. Nothing sets that
# override outside the hardware contract, which needs a certificate it is allowed # override outside the hardware contract, which needs a certificate it is allowed
# to create -- enrolling the real one to find out what happens is not a test. # to create -- enrolling the real one to find out what happens is not a test.
INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR|PANAMA_MOK_CERT)$' # PANAMA_ROLE_PRESET is the --server flag on its way INTO the interview, not an
# answer out of it -- the answer it produces is PANAMA_ROLE, which is checked.
INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR|PANAMA_MOK_CERT|PANAMA_ROLE_PRESET)$'
while read -r key; do while read -r key; do
[[ -n "$key" ]] || continue [[ -n "$key" ]] || continue
@@ -67,7 +69,7 @@ grep -qE 'mktemp' "$install_script" \
|| note 'install does not create the answers file with mktemp' || note 'install does not create the answers file with mktemp'
# Declining must stop the run rather than count as one failed stage among five. # Declining must stop the run rather than count as one failed stage among five.
grep -qE 'if ! "\$PANAMA_PATH/setup/scripts/interview"; then' "$install_script" \ grep -qE 'if ! [A-Z_="$ ]*"\$PANAMA_PATH/setup/scripts/interview"; then' "$install_script" \
|| note 'install does not treat a declined interview as fatal' || note 'install does not treat a declined interview as fatal'
# ── 4. A real run, with gum stubbed ────────────────────────────────────────── # ── 4. A real run, with gum stubbed ──────────────────────────────────────────
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# The machine role: the one interview answer Panama keeps.
#
# What is pinned, and why each half matters:
#
# * The interview asks the role first and a server is never asked the
# desktop's questions -- hardware and extras feed stages the server path
# does not run, and a question nothing consumes is a control that lies.
# * `--server` presets the answer without a prompt, because the fresh-VPS
# path runs from a curl with nobody to interview twice.
# * setup/lib/machine-role reads env over file over default, and records
# only values it can read back -- an unreadable role file must degrade to
# desktop, never to an error, because every pre-role machine has none.
# * install runs different stages per role, and the server list must never
# silently reacquire a desktop stage (or the reverse).
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
interview="$repo_dir/setup/scripts/interview"
install_script="$repo_dir/install"
machine_role="$repo_dir/setup/lib/machine-role"
boot="$repo_dir/boot"
findings=()
note() { findings+=("$1"); }
# ── The helper, sandboxed ────────────────────────────────────────────────────
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
role_of() {
env -u PANAMA_ROLE XDG_STATE_HOME="$work/state" ${1:+PANAMA_ROLE="$1"} \
bash -c "source '$machine_role'; panama_role"
}
[[ "$(role_of "")" == desktop ]] \
|| note 'with no file and no env, the role is not desktop'
env XDG_STATE_HOME="$work/state" \
bash -c "source '$machine_role'; panama_role_record server"
[[ "$(role_of "")" == server ]] \
|| note 'a recorded server role does not read back'
[[ "$(role_of desktop)" == desktop ]] \
|| note 'an exported PANAMA_ROLE does not win over the recorded file'
printf 'gibberish\n' >"$work/state/panama/role"
[[ "$(role_of "")" == desktop ]] \
|| note 'a role file with an unknown value does not degrade to desktop'
env XDG_STATE_HOME="$work/state" \
bash -c "source '$machine_role'; panama_role_record nonsense"
[[ "$(role_of "")" == desktop ]] \
|| note 'recording an unknown value does not degrade to desktop'
# ── The interview, per role ──────────────────────────────────────────────────
#
# The same stub the interview contract stands up, plus `choose` so the role
# question is answerable. GUM_STUB_CHOOSE also feeds the extras checklist on
# a desktop run, which is why the desktop case leaves it empty.
stub_dir="$work/bin"
mkdir -p "$stub_dir"
cat >"$stub_dir/gum" <<'STUB'
#!/usr/bin/env bash
case "$1" in
input) printf '%s\n' "$GUM_STUB_INPUT" ;;
confirm) [[ "$GUM_STUB_CONFIRM" == yes ]] ;;
choose) printf '%s\n' "$GUM_STUB_CHOOSE" ;;
style) shift; printf '%s\n' "${@: -1}" ;;
*) exit 0 ;;
esac
STUB
chmod +x "$stub_dir/gum"
answers="$work/answers"
ask_interview() {
: >"$answers"
GUM_STUB_INPUT="x" GUM_STUB_CONFIRM=yes GUM_STUB_CHOOSE="${2:-}" \
PANAMA_ANSWERS="$answers" PANAMA_ROLE_PRESET="${1:-}" \
PATH="$stub_dir:$PATH" bash "$interview" >/dev/null 2>&1
}
answered() {
( # shellcheck source=/dev/null
source "$answers"; printf '%s' "${!1:-}" )
}
ask_interview "" server \
|| note 'the interview fails when the role question answers server'
[[ "$(answered PANAMA_ROLE)" == server ]] \
|| note 'choosing server does not record PANAMA_ROLE=server'
[[ "$(answered PANAMA_EXTRAS)" == "" ]] \
|| note 'a server was asked the extras question'
[[ "$(answered PANAMA_NVIDIA)" == no ]] \
|| note 'a server run does not record the hardware defaults'
ask_interview "" "" \
|| note 'the interview fails when the role question is escaped'
[[ "$(answered PANAMA_ROLE)" == desktop ]] \
|| note 'an escaped role question does not default to desktop'
ask_interview server desktop \
|| note 'the interview fails under a --server preset'
[[ "$(answered PANAMA_ROLE)" == server ]] \
|| note 'PANAMA_ROLE_PRESET=server does not win: the prompt was asked anyway'
# ── install runs the right stages ────────────────────────────────────────────
#
# Read from the source rather than run: the stage lists are data, and what
# matters is which names each role's list carries.
# Only the two literal lists: the upgrade filter reassigns STAGES from a
# variable, which is not a role's stage list.
server_stages="$(sed -n 's/^ STAGES=(\(.*\))$/\1/p' "$install_script" | grep '^install-packages' | head -1)"
desktop_stages="$(sed -n 's/^ STAGES=(\(.*\))$/\1/p' "$install_script" | grep '^install-packages' | tail -1)"
for stage in install-packages link-dotfiles link-user setup-server link-server setup-identity; do
grep -qw "$stage" <<<"$server_stages" \
|| note "the server stage list is missing $stage"
done
for stage in install-hardware change-settings link-skills link-vicinae-scripts; do
grep -qw "$stage" <<<"$server_stages" \
&& note "the server stage list acquired the desktop stage $stage"
done
for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware; do
grep -qw "$stage" <<<"$desktop_stages" \
|| note "the desktop stage list lost $stage"
done
grep -q -- '--server)' "$install_script" \
|| note 'install does not accept --server'
grep -q 'panama_role_record' "$install_script" \
|| note 'install never records the role, so panama update cannot know it'
# ── boot's front door ────────────────────────────────────────────────────────
grep -q -- '--server) SERVER=1' "$boot" \
|| note 'boot does not accept --server'
grep -q 'not root' "$boot" \
|| note 'boot no longer refuses a root run without --server'
grep -q 'runuser' "$boot" \
|| note 'the root bootstrap does not hand off to the created user'
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'role contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'role contract: PASS\n'
+14 -5
View File
@@ -257,14 +257,23 @@ fi
# ── The stage runs, in the one order that gives personal skills precedence ── # ── The stage runs, in the one order that gives personal skills precedence ──
# Checked against every literal per-role stage list. The server list carries
# no link-skills at all (its three skills operate the desktop), so the demand
# is: at least one list runs it, and any list that runs it runs it between
# link-dotfiles and link-user.
python3 - "$installer" <<'PY' || note 'link-skills is not in STAGES between link-dotfiles and link-user' python3 - "$installer" <<'PY' || note 'link-skills is not in STAGES between link-dotfiles and link-user'
import re, sys import re, sys
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES=")) lines = [l.strip() for l in open(sys.argv[1], encoding="utf-8")
stages = re.findall(r"[\w-]+", line) if l.strip().startswith("STAGES=(") and "upgrade_stages" not in l]
for name in ("link-dotfiles", "link-skills", "link-user"): anywhere = False
if name not in stages: for line in lines:
stages = re.findall(r"[\w-]+", line)
if "link-skills" not in stages:
continue
anywhere = True
if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"):
raise SystemExit(1) raise SystemExit(1)
if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"): if not anywhere:
raise SystemExit(1) raise SystemExit(1)
PY PY
+11 -4
View File
@@ -166,12 +166,19 @@ grep -q 'PANAMA_USER_CONTENT' "$interview" \
|| note 'the interview never asks about personal content' || note 'the interview never asks about personal content'
# Order matters: link-user must land the tracked espanso identity before # Order matters: link-user must land the tracked espanso identity before
# setup-identity would seed one from the interview answers. # setup-identity would seed one from the interview answers. Checked in every
# role's literal stage list (the upgrade filter reassigns from a variable and
# is not a list).
python3 - "$installer" <<'PY' || note 'link-user does not run before setup-identity' python3 - "$installer" <<'PY' || note 'link-user does not run before setup-identity'
import re, sys import re, sys
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES=")) lines = [l.strip() for l in open(sys.argv[1], encoding="utf-8")
stages = re.findall(r"[\w-]+", line) if l.strip().startswith("STAGES=(") and "upgrade_stages" not in l]
if stages.index("link-user") > stages.index("setup-identity"): if not lines:
raise SystemExit(1)
for line in lines:
stages = re.findall(r"[\w-]+", line)
if "link-user" in stages and "setup-identity" in stages:
if stages.index("link-user") > stages.index("setup-identity"):
raise SystemExit(1) raise SystemExit(1)
PY PY