Handoff summary
Locked architecture decisions, current repo layout, and outstanding work.
What this project is
A personal infrastructure monorepo (github.com/khensolomon/lethil) that consolidates server-provisioning scripts, static-site/SPA sources, GitHub Actions dispatch workflows, and operator helper scripts into one place. The goal is to replace hand-typed terminal commands (error-prone, inconsistent across machines) with dispatchable, repeatable tasks — while every script remains independently runnable by hand.
The owner is a solo developer (khensolomon). Production runs Django + MySQL apps in Docker Swarm behind a Cloudflare Tunnel, on a server reached via that tunnel.
Architecture decisions (LOCKED)
Reverse-proxy / landing
- Nginx Proxy Manager (NPM) removed entirely. It was doing nothing useful: TLS terminates at Cloudflare’s edge, and the tunnel routes host→app via
localhost:<port>. Its only remaining role (a landing page) was fragile. - Replaced by vanilla
nginx:alpineat/opt/landing/, serving/opt/bucket/htmlas the catch-all landing page (port 80). One server block,try_files $uri $uri/ /index.html. No admin UI, no DB, no template-injection fragility. - Cloudflare Tunnel is its own compose stack at
/opt/cloudflare-tunnel/,network_mode: host(solocalhostinside the container is the Docker host). Routes: specific hostnames → app ports; catch-all →localhost:80(landing).
setup.py (server bootstrap)
- The CORE. Workflows/runner are dumb dispatchers — they pass parameters and report done/error/why. Same script runs identically by hand.
admin_subdomainsdict inmain()is the single source of truth for admin subdomains. Each entry:{"service": ..., "protect_with_access_app": bool, "app_name": ...}. Drives DNS + tunnel ingress + Cloudflare Access app + post-install summary. Rename a key → everything follows.--app-domainaccepts BOTH repeated flags AND one semicolon-separated value ("a.com:http://localhost:80;b.com:http://localhost:81") for CI use from a single secret.- rclone installed unconditionally; R2 remote written to
~/.config/rclone/rclone.conf(ownerSUDO_USER, mode 600) only if R2 creds supplied. usermod -aG dockeradded;newgrp dockerdocumented (cannot run from inside the script — only affects the shell that calls it).- Firewall: 22 (closed after tunnel verified), 80 (landing), 443 (no host service; Cloudflare terminates TLS; kept as no-op).
secrets.py (.env → GitHub Secrets) — GENERALIZED & MERGED ✅
- ONE script now serves both lethil AND the Django app repos (previously two separate scripts; merged per owner’s requirement).
- Git-root aware: run from anywhere inside a repo, finds that repo’s
.env. - Zone model via marker lines of the form
#@ <mode>:- Bundle (content before first marker) → concatenated, pushed as ONE secret
ENV_BASE(name is LOAD-BEARING — consumed byaction.yml/deploy.ymlin app repos; do not rename). #@ individual→ each key pushed as its own secret. Path-1 generalization: pushes WHATEVER keys are in the zone — no hardcoded allowlist.SSH_PRIVATE_KEY_PATHis special-cased: the file it points to is read and its CONTENTS pushed asSSH_PRIVATE_KEY(path itself never pushed).#@ local→ never pushed; read by script only (REPO_OWNER,REPO_NAME).
- Bundle (content before first marker) → concatenated, pushed as ONE secret
- Markers must appear in order (bundle → individual → local); any may be absent. Unknown mode after
#@= fatal error (catches typos). Marker regex:^#@\s*([A-Za-z][A-Za-z0-9_-]*)\s*$. House style uses a space:#@ individual. - Sensitive masking is pattern-based (
_PWD,_PASSWORD,_SECRET,_KEY,_TOKEN,_PAT) plus always-maskENV_BASE,SSH_PRIVATE_KEY. - All original commands preserved & adapted:
--push(+--only,--dry-run,--force, stale detection),--status,--diff,--restore,--rotate,--check,--list,--env-preview,--init(rewritten to scaffold the generic three-zone skeleton),--env,--repo. - VERIFIED: produces identical secrets for a Django-shape
.env(ENV_BASE + SSH_PRIVATE_KEY + individual CF tokens) — backward compatible, won’t break live deploys. Also verified for lethil shape and for error cases (typo’d marker, out-of-order markers).
Repo conventions
apps/<name>/= static-site/SPA source. Deploy is GENERIC (build ifpackage.jsonpresent →dist/, else rsync as-is). The workflow owns build-and-deploy logic; no per-appbuild.py.default→/opt/bucket/html; others →/opt/bucket/<name>/.- A committed
config.ymlholds non-sensitive indexing (apps list, server profiles). No hardcoding in workflows; free-text inputs validated against the repo. me/directory was MERGED intoscript/(single helper-scripts dir).- Workflows v1 support SSH method only;
self-hostedandtunnelappear in dispatch forms as placeholders that error cleanly until implemented.
Repo structure (current state in the delivered zip)
lethil/
├── README.md ✅ front door
├── config.yml ✅ non-sensitive index
├── .env.example ✅ #@ marker format, canonical names
├── .gitignore ✅ ignores .env, dist/, node_modules, pycache
├── server/
│ ├── README.md ✅
│ └── setup.py ✅ current (NPM removed; tunnel+landing split;
│ admin_subdomains dict; ;-separated app-domain;
│ rclone; docker group)
├── apps/
│ ├── README.md ✅
│ └── default/ ✅ index.html, style.css, script.js (placeholders), README
├── script/
│ ├── README.md ✅ documents the merged secrets.py + zone model
│ └── secrets.py ✅ GENERALIZED, MERGED, VERIFIED
├── docs/
│ └── setup.md ✅ moved here; paths updated; GH Actions option added
│ (still needs pronoun/hype sweep + new flow section)
└── .github/workflows/
├── server-setup.yml ✅ SSH only; tunnel/self-hosted = placeholders
└── apps-deploy.yml ✅ SSH only; tunnel/self-hosted = placeholders
Delivered as lethil.zip in outputs.
DEFERRED to a separate future session (NOT now)
- Config-model naming rethink:
ENV_BASE/VAR_BASE/ENV_OVERRIDES(the three-layer merge in app-repoaction.yml) → toward “one CONFIG, per-environment override (default/ssh/tunnel/self-hosted)”. This touchesaction.yml+deploy.ymlin the APP repos (live production deploys) — needs its own focused session with app-repo context loaded.VAR_BASE/ENV_OVERRIDESare currently managed by hand in the GitHub UI. - Container-cleanup workflows (
clean-containers.yml,bulk-delete-containers.yml,DELETE_PACKAGES_TOKEN) stay in the APP repos, NOT lethil. (DELETE_PACKAGES_TOKENis a write-only GitHub PAT withdelete:packagesscope; its value can’t be read back.)
OUTSTANDING tasks (remaining Path A work)
- Implement
tunnel+self-hostedmethods in both workflows (server-setup.yml,apps-deploy.yml). Currently placeholders that error. SSH method already works. - Pronoun + hype-register sweep across ALL comments and docs: remove personal pronouns (you/your/we/I/my/they → impersonal: “the project”, “working directory”, “the Cloudflare account ID”); remove hype register (must/absolutely/100%/guarantee/tutorial-voice).
- New docs section: how
.envvalues flow to GitHub Secrets, which script reads what (the secrets.py zone model end-to-end). - Apply workflow display names:
name: Server · Provision,name: App · Deploy(Noun · Verb form chosen for sidebar legibility as workflows grow).
NEXT IMMEDIATE STEP
Implement the tunnel and self-hosted methods in server-setup.yml and apps-deploy.yml. Decisions still needed before writing:
- self-hosted: assumes a runner already registered on the target server (chicken-and-egg — only valid for re-provisioning an existing server, not first bootstrap). Repo-scoped runners only (owner uses a personal GitHub account, not an org).
- tunnel: SSH through
cloudflared access sshwith the service token; requires the tunnel + Access app to already exist (so also not valid for first bootstrap). Needs the service-token-id / service-token-secret available to the runner from secrets. - Confirm: which methods are valid for which workflow, and whether each workflow should refuse invalid method+context combos.
Key working-file locations
- Repo skeleton (authoritative current state):
lethil.zipin outputs; working copy at/home/claude/lethil/ - Latest standalone
setup.py+setup.md: also in outputs (mirror oflethil/server/setup.pyandlethil/docs/setup.md) - Original
secrets.pythat was adopted/generalized: was at/mnt/project/secrets.py - App-repo reference workflows (for the deferred config-model work):
deploy.yml,action.yml,clean-containers.yml,bulk-delete-containers.yml(were uploaded)
Verification commands (for the current secrets.py)
cd lethil
python3 -m py_compile script/secrets.py # compiles
python3 script/secrets.py --check # validates .env structure + gh auth
python3 script/secrets.py --push --dry-run # preview without pushing
A Django-shape .env (bundle content + #@ individual + #@ local) and a lethil-shape .env (no bundle, only individual + local) both parse and resolve correctly. SSH_PRIVATE_KEY_PATH → file contents as SSH_PRIVATE_KEY confirmed.