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
13 KiB
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-reloads 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?" —
desktoporserverviagum choose../install --server(andboot --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 doctorand 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
gumis 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.
(
podmanmoves here from development-packages;podman-composeis what providespodman composeon 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-packagesbeside the Claude Code step — skip whencodexis on PATH,npm install -g @openai/codex(after nvm/node is available), soft-fail like Claude Code does. - The package-hash mechanic in
installis untouched; new files under-maxdepth 1join the hash automatically.
4. setup-server stage
Idempotent, like every stage; each item checks before acting:
loginctl enable-linger $USER./etc/sysctl.d/99-rootless-ports.conf→net.ipv4.ip_unprivileged_port_start=80, applied withsysctl --system(junior already has exactly this file).- firewalld: open 80, 443, 81 (NPM admin portal), permanent + runtime. Nothing else — reverse-proxy philosophy, ports open only when unavoidable.
podman network create nginx-bridgeif absent (every compose expects it,external: true).- Nightly image updates:
update-containers.shis adopted into the repo asserver/scripts/update-containers(no.sh, per repo rule — junior's copy is imported and itsExecStart/log paths updated), withserver/systemd/podman-update.serviceand.timersymlinked 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 inserver/containers/, with enabled/active state.enable <Name>— create~/Server/<Name>/; symlinkcompose.yml; seed~/Server/<Name>/.envfrom.env.examplewhen the example exists and the live file does not; if the seeded.envstill 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 pluspodman compose psper 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 thelink-serverstage and bypanama updateon 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:
- Ask for a username (default
gib). Create it withwheelif absent; if it exists, use it. - Ensure the user has a password (needed for sudo) —
passwdinteractively if none is set. - Copy root's
authorized_keysto the user when the user has none. - Offer sshd hardening (yes/no, default yes): drop-in under
/etc/ssh/sshd_config.d/withPermitRootLogin no,PasswordAuthentication no, then reload sshd. Skipped when already set. - Move/clone the checkout under the user's home, chown it, and re-exec
install --serveras 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:
- Copy
compose.ymlintoserver/containers/<Name>/. - Take the installed unit (
~/.config/systemd/user/podman-<name>.service) as truth; diff against the copy in the service dir and flag any divergence. - Move inline secrets out: secret-bearing values in the repo copy of
compose.ymlbecome${VAR}interpolations; extracted values are written to~/Server/<Name>/.envon junior (inert until cutover — the live compose still carries them inline);.env.examplewith placeholder values is committed. Services already using.env/env_fileget an.env.examplegenerated from the live.envwith values blanked. - Verify without touching the stack:
podman compose configrendered 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; thedata/standardization happens per service at cutover. - 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 underserver/contains an inline secret-looking value (theuser-content-contractpattern), when.gitignorestops covering.envunderserver/, or whengit ls-filesshows a tracked.env.containers-shape-contract— everyserver/containers/<Name>/carriescompose.yml+podman-<name>.service; every unit'sWorkingDirectoryis%h/Server/<Name>; every${VAR}used in a compose file appears in that service's.env.example.panama-server-contract— throwawayHOME, stubbedsystemctl/podmanon PATH:enablecreates the right symlinks and seeds.env; placeholder values block start;disableleaves data alone;relinkreports changed definitions.role-contract— the interview asks role first,--serverpresets it, the role file is written, stages filter correctly per role (via the stage scripts' static/stubbed seams).dotfile-classification-contract— every dir inconfig/dot/appears in exactly one of link-dotfiles' universal/desktop lists.- README: layout section gains
server/, the subcommand table gainspanama server, the pinned contract count moves —readme-contractand the interview/link-dotfiles contracts that assert today's behavior update in the same change.
Phasing
- Role plumbing — interview question, role file,
--server, package split, stage filters,setup-server, boot's root path. - Server skeleton —
server/tree,panama-server,link-server, update-timer adoption, contracts, README/docs. - Import — junior's 23 services into
server/containers/per §7. - 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 1–2 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.