Multi-stage Elixir/OTP Dockerfile, Kubernetes manifests following
Keycloak pattern, mix release migration module, and deploy runbook.
Target: guildhall.guildhouse.dev via Hetzner LB + Cloudflare (orange
cloud). Forgejo container registry at git.guildhouse.dev/tking/guildhall.
Not yet deployed; artifacts only. See DEPLOY-RUNBOOK.md for execution.
Artifacts produced:
- Dockerfile — multi-stage, Elixir 1.17.3 / OTP 27.1.2, debian-bookworm
builder + debian-bookworm-slim runtime. Dep-layer caching via
explicit apps/*/mix.exs copy before source. Asset pipeline runs
mix assets.setup + mix assets.deploy (tailwind + esbuild + phx.digest).
Non-root uid 1000, tini as pid-1, HEALTHCHECK against /health.
- .dockerignore — excludes _build/, deps/, k8s/, .git/, test artifacts,
and apps/guildhall_web/priv/static/assets/ (regenerated by phx.digest
inside the builder).
- apps/guildhall_web/.../router.ex — adds `/health` route under :api
pipeline. Unauthenticated by design (Kubernetes probes + LB target).
- apps/guildhall_web/.../controllers/health_controller.ex — shallow
health: Phoenix up + Ecto pool can `SELECT 1`. Returns 200 ok or 503
degraded with reason.
- apps/guildhall_ops_db/lib/guildhall/ops_db/release.ex — Release
module for migrations. `Guildhall.OpsDb.Release.migrate/0` and
`rollback/2`. Called from the migration Job via
`bin/guildhall eval`. Module path reflects actual repo location
(repo is `Guildhall.OpsDb.Repo` in `:guildhall_ops_db`, not the
prompt's suggested `Guildhall.Repo`).
Kubernetes manifests in k8s/ (numbered for apply order):
00-namespace.yaml — guildhall namespace w/ guildhouse labels
10-registry-secret-template.yaml — doc-only template for dockerconfigjson
20-postgres-pvc.yaml — 5Gi longhorn RWO
30-postgres-deployment.yaml — postgres:16, keycloak-matched resources
+ pg_isready probes, PGDATA subpath
40-postgres-service.yaml — ClusterIP :5432
50-guildhall-secrets-template.yaml — doc-only template for app + DB secrets
60-migration-job.yaml — ecto migration Job, name includes tag
for per-deploy uniqueness, TTL 24h
70-guildhall-deployment.yaml — RollingUpdate maxSurge 1 maxUnavailable 0,
/health probes, 200m/256Mi requests
and 1/1Gi limits, 5s preStop sleep
80-guildhall-service.yaml — LoadBalancer with exact Keycloak-
matched Hetzner annotations
(location nbg1, type lb11, name
guildhall, use-private-ip false),
port 80 origin (Cloudflare TLS)
- DEPLOY-RUNBOOK.md — 6-phase deploy sequence (build + push, cluster
prep, DB, migrate, app rollout, DNS + smoke), iteration helper with
sed-based tag-bump, rollback procedure (image rollback, schema
rollback via Release.rollback, full teardown), and v0.1 limitations
(Cloudflare-edge TLS not cluster-terminated; no Flux integration;
no OIDC wiring; no substrate CRD integration; single replica).
Decisions made during artifact production that weren't explicit in
the prompt:
- Release module name is `Guildhall.OpsDb.Release` (not
`Guildhall.Release`) matching the actual repo namespace. Migration
Job command adjusted to `Guildhall.OpsDb.Release.migrate()`.
- Dockerfile uses `-slim` builder variant (not the full bookworm
builder) to keep the builder stage closer to the runtime image
size, reducing multi-stage layer transfer during build.
- Asset compilation runs `mix assets.setup` before `mix assets.deploy`
so tailwind + esbuild binaries install cleanly inside the container
(the dev-only :runtime flag on those deps means they need explicit
install in a prod builder).
- tini added as pid-1 in the runtime stage. Not in the prompt, but
standard-practice for OTP containers to ensure signal propagation
and zombie reaping under Kubernetes.
- Rolling update strategy: maxSurge 1 / maxUnavailable 0 (zero-
downtime rollout at replicas=1; the new pod comes up alongside the
old, health-checks, then the old is terminated). Matches typical
single-replica LiveView pattern.
- preStop `sleep 5` — gives in-flight HTTP + LiveView connections a
grace window before termination.
- Hetzner LB annotations: verified exact set from cluster keycloak
service — location=nbg1, name=guildhall, type=lb11,
use-private-ip=false. The prompt asked about uses-proxyprotocol
and algorithm-type; neither is set on Keycloak's service and both
are omitted here for consistency.
- Migration Job name includes the tag (`guildhall-migrate-v0-1-0`) so
multiple deploys don't collide on Job name reuse. Runbook documents
the sed helper to bump both the image tag and the Job name for
subsequent deploys.
- Both exploratory docs (`DEPLOY-EXPLORATORY-2026-04-21.md`,
`FORGEJO-REGISTRY-INVESTIGATION-2026-04-21.md`) are currently
untracked in the repo. They're left out of this commit per the
prompt's explicit `git add` list. They can be committed separately
(or ignored) at Tyler's discretion.
Not done tonight (per prompt's NOT PERMITTED list):
- docker build / docker push
- kubectl apply of any manifest
- Forgejo PAT creation
- Cloudflare DNS changes
- git push (this commit is local-only pending review)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Tyler J King <tking@guildhouse.dev>
|
||
|---|---|---|
| .forgejo/workflows | ||
| apps | ||
| config | ||
| k8s | ||
| .dockerignore | ||
| .formatter.exs | ||
| .gitignore | ||
| AGENTS.md | ||
| DEPLOY-RUNBOOK.md | ||
| Dockerfile | ||
| mix.exs | ||
| mix.lock | ||
| README.md | ||
Guildhall
Ceremony orchestrator and governance UI — Elixir/Phoenix umbrella over substrate CRDs.
guildhall presents and coordinates; substrate decides
and enforces. The ceremony engine is a Rust Kubernetes operator
with CRDs and etcd-backed state. guildhall is the orchestrator:
it coordinates humans around those CRDs — notifying witnesses,
collecting signatures via LiveView, tracking status, rendering
dashboards.
┌────────────────────────────────────────────────────────┐
│ SUBSTRATE (Rust, K8s operators) — decides + enforces │
│ CeremonyEngine (CRD), AccordEvaluator (CRD), │
│ CorpusReconciler (CRD), PostureEvaluator (CRD), │
│ Chronicle collector (agent) │
└────────────────────┬───────────────────────────────────┘
│ watches CRDs + emits Chronicle events
▼
┌────────────────────────────────────────────────────────┐
│ GUILDHALL (Elixir/Phoenix) — orchestrates + presents │
│ CeremonyOrchestrator (workflow coordinator) │
│ AccordComposer (UI + submission) │
│ ArtifactBrowser (UI + lifecycle) │
│ PostureDashboard (visualization) │
│ ChronicleConsumer (projector + UI) │
└────────────────────────────────────────────────────────┘
Naming discipline: guildhall components are orchestrators
(workflow, coordination, presentation). The substrate components
are engines and reconcilers (enforcement, state-machine
advancement). Never call a guildhall component by a substrate
name.
Umbrella apps
| App | Role |
|---|---|
guildhall_web |
Phoenix LiveView UI — dashboards, ceremonies, artifacts, posture |
guildhall_orchestrator |
Watches substrate CRDs (future), notifies witnesses, broadcasts ceremony status over PubSub |
guildhall_ops_db |
Ecto schemas for the five Ops DB tables (per DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001) |
guildhall_graph_bridge |
Microsoft Graph API reconciler — Intune deployment (stub) |
guildhall_chronicle |
Chronicle event consumer + Ops DB projector (stub) |
Local development
Prerequisites
- Elixir 1.17.x + OTP 27 (via
miseorasdf) - Postgres 14+ running on
localhost:5432with apostgressuperuser (passwordpostgresfor dev)
First-time setup
mix deps.get
mix ecto.create
mix ecto.migrate
mix run apps/guildhall_ops_db/priv/repo/seeds.exs
Run the server
mix phx.server
Then visit:
- http://localhost:4000/ — governance dashboard
- http://localhost:4000/ceremonies — open ceremonies
- http://localhost:4000/artifacts — governed artifacts registry
Run tests
mix test
Configuration
Development defaults are in config/dev.exs (Postgres at
localhost:5432 as postgres/postgres, database
guildhall_dev). Production runtime configuration reads from
environment variables in config/runtime.exs:
| Env var | Purpose |
|---|---|
DATABASE_URL |
Postgres connection (required in prod) |
SECRET_KEY_BASE |
Phoenix cookie/session signing (required in prod) |
PHX_HOST |
Public hostname (default guildhall.guildhouse.dev) |
PHX_SERVER |
Set to true to run the HTTP server under mix release |
POOL_SIZE |
DB pool size (default 10) |
ECTO_IPV6 |
Set to true for IPv6 DB connections |
Commented placeholders exist for future sprints: KUBECONFIG
(substrate CRD watcher) and OIDC_ISSUER / OIDC_CLIENT_ID /
OIDC_CLIENT_SECRET (Keycloak auth).
Relationship to the rest of the stack
guildhall is one of the PaaS components (ROADMAP WS1). It sits
alongside:
substrate— the governance Rust crates + K8s operatorsbxnet-ops— theorg-opsCLI framework (reference fork: BXNet)guildhouse-mcp— MCP server for LLM mediator contextguildhouse-specs— the FFC specifications
See the design docs for the full picture:
- DESIGN-OPS-DB-CHAIN-OF-CUSTODY-0001 — Ops DB schema + self-hosted FFC threat model
- DESIGN-HFL-DB-ENFORCEMENT-0001 — BPF map ABI for DB governance
- DESIGN-ORG-OPS-FRAMEWORK-0001 — governed full-stack framework
- DESIGN-FORGE-WORKSPACE-0001 — governed workspace staging
- SPEC-CEREMONY-0001 — ceremony protocol
License
Apache 2.0.