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>
110 lines
3.6 KiB
Docker
110 lines
3.6 KiB
Docker
# syntax=docker/dockerfile:1.7
|
|
#
|
|
# Guildhall production image — Elixir/Phoenix umbrella release.
|
|
# Multi-stage: builder produces a mix release; runtime is a slim debian
|
|
# carrying only the OTP release + runtime libs.
|
|
#
|
|
# Build context: the guildhall umbrella root.
|
|
# Target registry: git.guildhouse.dev/tking/guildhall:<tag>
|
|
|
|
# ---------- Stage 1: builder ---------------------------------------------
|
|
FROM hexpm/elixir:1.17.3-erlang-27.1.2-debian-bookworm-20241202-slim AS builder
|
|
|
|
ENV MIX_ENV=prod \
|
|
LANG=C.UTF-8 \
|
|
LC_ALL=C.UTF-8
|
|
|
|
RUN apt-get update -qq && \
|
|
apt-get install -y --no-install-recommends \
|
|
build-essential \
|
|
git \
|
|
ca-certificates \
|
|
curl \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
RUN mix local.hex --force && \
|
|
mix local.rebar --force
|
|
|
|
WORKDIR /app
|
|
|
|
# Dep resolution needs every apps/*/mix.exs in an umbrella. Copy them
|
|
# before any source so dep-layer cache survives source-only edits.
|
|
COPY mix.exs mix.lock ./
|
|
COPY config/config.exs config/prod.exs config/runtime.exs config/
|
|
COPY apps/guildhall_chronicle/mix.exs apps/guildhall_chronicle/
|
|
COPY apps/guildhall_graph_bridge/mix.exs apps/guildhall_graph_bridge/
|
|
COPY apps/guildhall_ops_db/mix.exs apps/guildhall_ops_db/
|
|
COPY apps/guildhall_orchestrator/mix.exs apps/guildhall_orchestrator/
|
|
COPY apps/guildhall_web/mix.exs apps/guildhall_web/
|
|
|
|
RUN mix deps.get --only prod && \
|
|
mix deps.compile
|
|
|
|
# Source — copied after dep layers so app-source changes don't bust
|
|
# the dep cache.
|
|
COPY apps/ apps/
|
|
|
|
# Asset pipeline for guildhall_web — tailwind + esbuild + phx.digest.
|
|
# The aliases in apps/guildhall_web/mix.exs define `assets.deploy` as
|
|
# `tailwind guildhall_web --minify` + `esbuild guildhall_web --minify`
|
|
# + `phx.digest`. `tailwind.install` and `esbuild.install` pull the
|
|
# binaries on first use.
|
|
COPY apps/guildhall_web/assets apps/guildhall_web/assets
|
|
RUN cd apps/guildhall_web && \
|
|
mix assets.setup && \
|
|
mix assets.deploy
|
|
|
|
# Compile the full umbrella + cut the release. Release name `guildhall`
|
|
# is derived from the umbrella mix.exs (no explicit releases: block, so
|
|
# it defaults to the project's apps_path-rooted umbrella name).
|
|
RUN mix compile --warnings-as-errors && \
|
|
mix release --overwrite
|
|
|
|
# ---------- Stage 2: runtime --------------------------------------------
|
|
FROM debian:bookworm-slim AS runtime
|
|
|
|
ENV LANG=en_US.UTF-8 \
|
|
LC_ALL=en_US.UTF-8 \
|
|
LANGUAGE=en_US:en
|
|
|
|
# Runtime deps the compiled release needs. locales for the en_US.UTF-8
|
|
# generation; libstdc++6 for the erlang ports; libncurses6 for the
|
|
# beam; openssl for tls; libsystemd0 for the logger integration some
|
|
# releases use; tini as a minimal pid-1 for reapability.
|
|
RUN apt-get update -qq && \
|
|
apt-get install -y --no-install-recommends \
|
|
openssl \
|
|
libncurses6 \
|
|
libstdc++6 \
|
|
libsystemd0 \
|
|
locales \
|
|
ca-certificates \
|
|
curl \
|
|
tini \
|
|
&& sed -i '/^# en_US.UTF-8 UTF-8/s/^# //' /etc/locale.gen \
|
|
&& locale-gen en_US.UTF-8 \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Non-root user. uid 1000 matches the rest of the cluster's convention.
|
|
RUN groupadd --system --gid 1000 guildhall && \
|
|
useradd --system --uid 1000 --gid guildhall --shell /usr/sbin/nologin \
|
|
--home /app --create-home guildhall
|
|
|
|
WORKDIR /app
|
|
|
|
# Release name `guildhall` produces the release at this path.
|
|
COPY --from=builder --chown=guildhall:guildhall /app/_build/prod/rel/guildhall /app
|
|
|
|
USER guildhall
|
|
|
|
ENV HOME=/app \
|
|
PHX_SERVER=true \
|
|
PORT=4000
|
|
|
|
EXPOSE 4000
|
|
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
|
CMD curl -fsS http://localhost:4000/health || exit 1
|
|
|
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
|
CMD ["/app/bin/guildhall", "start"]
|