Deploy lumnik
Installation
Get the hub running. The self-host path is one command; the Kubernetes path is the cloud track. Start here when you want a healthy stack before connectors, chat, or apps.
Before anything: the prerequisites checklist — what to prepare so no half-day is lost to a missing model or port.
Choose the path
Self-host
One command, local stack, fastest path to a real hub with real auth.
One HTTPS origin
Add the TLS façade when phones or LAN devices need one origin and one token issuer.
Connect the CLI
Set server and OIDC issuer once, then log in through device flow.
Managed cluster
Use the Helm chart when the target is a managed or scaled deployment.
Self-host — one command
On a Windows PC?
Follow Deploy on Windows instead — a from-zero walkthrough, WSL2 and Docker Desktop included, with the expected result spelled out after every step. The commands below are for a Linux or macOS shell.
git clone https://github.com/icreated/lumnik-open.git && cd lumnik-open
./deploy/selfhost/up.sh --build
Which edition this is — and what it does not include
icreated/lumnik-open is the open edition. After cloning, list the tree: there is no
lumnik-rag/ and no lumnik-llm-langchain/ module. That build ingests, fuses, and
serves views, processes, workflows, outputs and the métier PWA, and this page deploys it
— with one exception, the Ollama item under How to…, which an open-edition
install has nothing to point at. What it does not carry is the way into the Ask
rung: both chat doors
(POST /api/platform/rag/chat, behind lm ask and TUI :ask; POST /api/rag/chat,
behind the PWA chat), the semantic search over ingested chunks, and every
ChatPort/EmbeddingPort implementation. The ask pipeline itself — the SQL translation
and its five honesty guards — is native to the hub and stays; there is simply no door onto
it and no model behind it. The hub degrades gracefully rather than failing at boot: a hub
without those extensions still ingests, fuses and serves. They are closed extensions, not
part of this repository — the full map is The two editions.
The stack you get:
flowchart LR
lmop["lm — the integrator"] --> hub
phone["the métier's phone"] -.->|"https, via the façade"| caddy
subgraph machine["one machine — docker compose"]
caddy["caddy — one https origin<br/>(optional TLS façade)"]
hub["hub :8080<br/>API + PWA at /app/"]
kc["keycloak :8180<br/>realm lumnik"]
pg[("postgres<br/>internal")]
caddy -->|"/*"| hub
caddy -->|"/realms/*"| kc
hub --> pg
hub -.->|"OIDC"| kc
end
Run it with --build — it works for everyone; the plain pull path needs access to the
(pre-launch, private) published image. What the launcher does:
Generate secrets safely
It creates a gitignored, owner-only (mode 600) .env with fresh random secrets — Postgres, Keycloak admin, both crypto keys, and HUBADMIN_PASSWORD / INTEGRATOR_PASSWORD — and never overwrites an existing one. Back it up.
Build and start the stack
It builds the hub image from source (--build) or pulls the published one, starts hub + Postgres + Keycloak, and waits for the hub health check. Flyway migrations run at boot — the first boot can take a minute.
Mint per-install passwords
It sets the hubadmin and integrator passwords through the Keycloak admin API — a known credential must never survive an install — and prints the hub and Keycloak URLs.
You know it worked when: the launcher reports the stack healthy and prints both URLs.
The launcher, line by line
#!/usr/bin/env bash
# Stand up the self-host lumnik stack (hub + postgres + keycloak) with one command.
# On first run, generates .env with strong random secrets; pulls the published image
# (or --build compiles from source); waits for the hub to be healthy; prints access URLs.
set -euo pipefail
# repo root = two levels up from deploy/selfhost/up.sh
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
# Preflight: every tool this script needs, checked NOW — a missing one must fail
# loud here, not half-succeed at the end. (First real Windows install: a minimal
# WSL Ubuntu lacked curl/python3; the stack came up healthy but both realm
# accounts stayed locked, behind a warn nobody read.)
missing=""
# The full inventory of external commands this script calls (grep-audited) — the
# claim "checked up front" must be true, not approximate.
for tool in docker curl python3 openssl awk grep sed cut mv cp chmod sleep seq dirname basename; do
command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
done
if [[ -n "$missing" ]]; then
{
echo "!! up.sh needs these tools, not found on this machine:$missing"
echo " Debian/Ubuntu (incl. WSL): sudo apt update && sudo apt install -y curl python3 openssl gawk"
echo " Docker: https://docs.docker.com/engine/install/ (on Windows: Docker Desktop, with WSL integration enabled)"
} >&2
exit 1
fi
if ! docker compose version >/dev/null 2>&1; then
echo "!! Docker is installed but 'docker compose' (v2 plugin) is not available — install the compose plugin, or on Windows enable Docker Desktop's WSL integration" >&2
exit 1
fi
COMPOSE=(docker compose -f docker-compose.selfhost.yml)
BUILD=0
for arg in "$@"; do
case "$arg" in
--build) BUILD=1 ;;
-h|--help) echo "Usage: $(basename "$0") [--build] (--build compiles the hub from source instead of pulling)"; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
# 1) ensure .env with secrets (never overwrite an existing one)
if [[ ! -f .env ]]; then
echo "==> no .env found — generating one with fresh random secrets"
[[ -f .env.example ]] || { echo "!! .env.example not found — run this from the lumnik repo (the template is at the root)" >&2; exit 1; }
umask 077 # any file created below (.env.tmp holding the real secrets) is owner-only from the start
cp .env.example .env
for key in POSTGRES_PASSWORD KEYCLOAK_ADMIN_PASSWORD LUMNIK_SECRET_MASTER_KEY LUMNIK_CRYPTO_SECRET_KEY HUBADMIN_PASSWORD INTEGRATOR_PASSWORD LUMNIK_APP_DB_PASSWORD LUMNIK_ADMIN_DB_PASSWORD LUMNIK_READONLY_DB_PASSWORD; do
secret="$(openssl rand -base64 32)"
# fill the empty `KEY=` line; awk prints the secret literally (no shell/sed metachar issues)
awk -v k="$key" -v v="$secret" 'BEGIN{FS=OFS="="} $1==k && $2=="" {print k"="v; next} {print}' .env > .env.tmp
mv .env.tmp .env
done
# Stamp the bootstrap superuser explicitly: the volume freezes this name at first
# init, and a .env that SAYS which name it froze never needs the probe below.
printf 'POSTGRES_USER=lumnik\n' >> .env
chmod 600 .env # owner-only — these are secrets (cp + redirect would otherwise leave it world-readable)
echo " wrote .env (gitignored, mode 600). BACK IT UP — losing LUMNIK_SECRET_MASTER_KEY makes stored secrets unrecoverable."
else
echo "==> using existing .env"
# Older .env files predate the per-install hubadmin/integrator passwords (C1, extended
# 2026-08-11 to integrator) — grow them in place.
for key in HUBADMIN_PASSWORD INTEGRATOR_PASSWORD LUMNIK_APP_DB_PASSWORD LUMNIK_ADMIN_DB_PASSWORD LUMNIK_READONLY_DB_PASSWORD; do
if ! grep -q "^${key}=..*" .env; then
umask 077
grep -q "^${key}=" .env || printf '%s=\n' "$key" >> .env
secret="$(openssl rand -base64 32)"
awk -v k="$key" -v v="$secret" 'BEGIN{FS=OFS="="} $1==k && $2=="" {print k"="v; next} {print}' .env > .env.tmp
mv .env.tmp .env && chmod 600 .env
echo " added $key to .env"
fi
done
fi
# 2) start the stack
if [[ "$BUILD" -eq 1 ]]; then
echo "==> building the hub image from source and starting the stack"
"${COMPOSE[@]}" up -d --build
else
echo "==> pulling images and starting the stack"
"${COMPOSE[@]}" pull || { echo "!! image pull failed — if the ghcr image is private run 'docker login ghcr.io', or use --build to compile from source" >&2; exit 1; }
"${COMPOSE[@]}" up -d
fi
# Align the live DB roles to .env on every run (P8): first boot's init script already
# used these values (no-op re-set); an older install rotates from the historic
# defaults the moment the keys land in .env. Idempotent — safe every run. Rotation
# is the same gesture: new value in .env, re-run up.sh. SQL rides stdin (never argv);
# values are openssl base64 (no quotes to escape).
echo "==> aligning database role passwords to .env"
pg_cid="$("${COMPOSE[@]}" ps -q postgres)"
env_val() { grep "^$1=" .env | cut -d= -f2-; }
# Superuser name: the VOLUME outranks the claim. The name is frozen into PGDATA at first
# init; .env can be silent (older installs) or WRONG (a regenerated .env stamps 'lumnik'
# over a pre-rename volume — deleting .env on a living stack did exactly that). So the
# claimed name is verified against the running database, and discovery runs whenever the
# claim is absent or false. pg_isready ignores auth, so it waits under any name.
pg_super="$(env_val POSTGRES_USER)" || [ $? -eq 1 ]
for _ in $(seq 1 30); do
docker exec "$pg_cid" pg_isready -U "${pg_super:-lumnik}" -d lumnik >/dev/null 2>&1 && break
sleep 2
done
super_works() { docker exec "$pg_cid" psql -U "$1" -d lumnik -tAc 'SELECT 1' >/dev/null 2>&1; }
if [[ -z "$pg_super" ]] || ! super_works "$pg_super"; then
if super_works lumnik; then
pg_super=lumnik
else
pg_super=adempiere # pre-rename volume — the only other name ever bootstrapped
fi
# Persist the discovery so compose interpolation and every later run agree with the volume.
if grep -q '^POSTGRES_USER=' .env; then
awk -v v="$pg_super" 'BEGIN{FS=OFS="="} $1=="POSTGRES_USER" {print "POSTGRES_USER="v; next} {print}' .env > .env.tmp
mv .env.tmp .env
else
printf 'POSTGRES_USER=%s\n' "$pg_super" >> .env
fi
chmod 600 .env
echo " discovered bootstrap superuser '$pg_super' from the volume; recorded in .env"
fi
{
printf "ALTER ROLE lumnik_app PASSWORD '%s';\n" "$(env_val LUMNIK_APP_DB_PASSWORD)"
printf "ALTER ROLE lumnik_admin PASSWORD '%s';\n" "$(env_val LUMNIK_ADMIN_DB_PASSWORD)"
printf "ALTER ROLE lumnik_readonly PASSWORD '%s';\n" "$(env_val LUMNIK_READONLY_DB_PASSWORD)"
} | docker exec -i "$pg_cid" psql -q -v ON_ERROR_STOP=1 -U "$pg_super" -d lumnik \
&& echo " roles aligned (lumnik_app / lumnik_admin / lumnik_readonly)" \
|| echo " !! role-password alignment failed — the hub may not reach the DB; inspect: docker exec -it $pg_cid psql -U $pg_super lumnik" >&2
# Grants, schemas and extensions ride every run too (the whole file is idempotent):
# init scripts only run at FIRST volume init, so a volume older than 02-extensions.sql
# never received them — the first one-shot CSV ingest then fails its CREATE SCHEMA with
# "permission denied for database lumnik".
docker exec -i "$pg_cid" psql -q -v ON_ERROR_STOP=1 -U "$pg_super" -d lumnik \
< infra/postgres/init/02-extensions.sql \
&& echo " grants/schemas/extensions aligned (02-extensions.sql, idempotent)" \
|| echo " !! grants alignment failed — ingestion may hit 'permission denied'; inspect: docker exec -it $pg_cid psql -U $pg_super lumnik" >&2
# Neither hubadmin nor integrator ships in the realm WITH a credential — C1 posture
# (2026-07-25), extended 2026-08-11 to integrator (RealmAudienceContractTest): a known
# password must never survive an install, and infra/keycloak/*.json is on the
# open-edition allowlist so a static one would become world-readable at the flip. Set
# each one per install from .env via the Keycloak admin API. Failure is fail-closed
# (the user simply cannot log in) — warn, don't abort a healthy stack.
# $1 = space-separated candidate usernames (aliases for the same logical account —
# e.g. the pre-rename 'gardenadmin' — the loop heals older realm imports)
# $2 = .env key holding the password to set
set_realm_user_password() {
local candidates="$1" pw_key="$2"
local admin_user admin_pw pw tok uid name
# `|| [ $? -eq 1 ]` on every .env lookup: KEYCLOAK_ADMIN is a documented-optional key
# that a fresh .env (copied from .env.example) legitimately lacks — under
# `set -euo pipefail` an unguarded no-match grep kills the whole script before the
# :-default or the warn below can run (bit on the first real cloud install,
# 2026-08-18). Only grep's "no match" (rc 1) is tolerated; a real error (rc 2,
# e.g. unreadable .env) still fails fast instead of masquerading as "missing".
admin_user="$(grep '^KEYCLOAK_ADMIN=' .env | cut -d= -f2-)" || [ $? -eq 1 ]; admin_user="${admin_user:-admin}"
admin_pw="$(grep '^KEYCLOAK_ADMIN_PASSWORD=' .env | cut -d= -f2-)" || [ $? -eq 1 ]
pw="$(grep "^${pw_key}=" .env | cut -d= -f2-)" || [ $? -eq 1 ]
if [[ -z "$pw" ]]; then
echo " !! $pw_key missing in .env — $candidates stays locked (no password)"; return 0
fi
# Password rides stdin (--data-urlencode password@-), never argv — no `ps` leak.
# Retried: the hub can be healthy while Keycloak is still finishing its first
# realm import (seen on slow first boots) — give it up to ~25s before warning.
local attempt
for attempt in 1 2 3 4 5; do
# --max-time bounds each attempt: a hung connect must not turn the ~25s retry
# budget into an unbounded stall.
tok="$(printf '%s' "$admin_pw" | curl -fsS --connect-timeout 3 --max-time 10 -X POST "http://localhost:8180/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
--data-urlencode "username=$admin_user" --data-urlencode password@- 2>/dev/null \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null)" || true
[[ -n "${tok:-}" ]] && break
[[ "$attempt" -lt 5 ]] && sleep 5
done
if [[ -z "${tok:-}" ]]; then
echo " !! could not obtain a Keycloak admin token (check KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD in .env) — $candidates stays locked (no password)"; return 0
fi
# Realms imported before the rename still carry the legacy 'gardenadmin' user (a realm
# import is one-shot) — heal it under whichever name this install has.
for name in $candidates; do
# Non-fatal like the token call above — a lookup failure must keep the
# warn-don't-abort contract (set -e would otherwise kill a healthy stack).
uid="$(curl -fsS --connect-timeout 3 --max-time 10 -H "Authorization: Bearer $tok" \
"http://localhost:8180/admin/realms/lumnik/users?username=$name&exact=true" 2>/dev/null \
| python3 -c 'import sys,json;u=json.load(sys.stdin);print(u[0]["id"] if u else "")' 2>/dev/null)" || true
[[ -n "$uid" ]] && break
done
if [[ -z "$uid" ]]; then
echo " !! $candidates not found in the lumnik realm — skipped"; return 0
fi
PW="$pw" python3 -c 'import json,os;print(json.dumps({"type":"password","temporary":False,"value":os.environ["PW"]}))' \
| curl -fsS --connect-timeout 3 --max-time 10 -X PUT "http://localhost:8180/admin/realms/lumnik/users/$uid/reset-password" \
-H "Authorization: Bearer $tok" -H "Content-Type: application/json" -d @- \
&& echo " $name: per-install password set ($pw_key in .env)" \
|| echo " !! $name password set failed — stays locked (no password)"
}
# 3) wait for the hub to be healthy (Flyway runs at boot)
echo "==> waiting for the hub to become healthy (this can take a minute on first boot)"
hub_cid="$("${COMPOSE[@]}" ps -q hub)"
for _ in $(seq 1 60); do
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$hub_cid" 2>/dev/null || echo none)"
if [[ "$status" == "healthy" ]]; then
echo "==> hub is healthy"
set_realm_user_password "hubadmin gardenadmin" HUBADMIN_PASSWORD
set_realm_user_password "integrator" INTEGRATOR_PASSWORD
echo ""
echo " Hub: http://localhost:8080 (health: /q/health)"
echo " Keycloak: http://localhost:8180/realms/lumnik"
echo " Next: point lm at the hub — server http://localhost:8080, OIDC http://localhost:8180/realms/lumnik"
exit 0
fi
sleep 3
done
echo "!! hub did not become healthy in time — inspect with: ${COMPOSE[*]} logs hub" >&2
exit 1
The full self-host README
# Self-host lumnik
Stand up the whole lumnik stack — hub + PostgreSQL (pgvector) + Keycloak (real OIDC) — on a single
host with Docker. It runs anywhere Docker runs: a laptop, or a VM on any cloud (GCP, AWS, Azure, Scaleway).
> "Deploy anywhere" needs a portable image on a VM, not per-cloud infrastructure-as-code. That's this.
> Managed-service / Terraform paths come later, driven by a real deployment's needs.
## Prerequisites
- Docker + Docker Compose v2 (`docker compose version`).
- The published hub image `ghcr.io/icreated/lumnik-hub` is **private pre-launch** — `docker login ghcr.io`
authenticates you but grants **no access** unless your GitHub account was added to the package.
The path that works for everyone: `--build` (compiles from source inside Docker — no image access
and no local Maven/JDK needed, only Docker; the first build takes a few minutes).
- **For the natural-language layer (RAG chat, semantic search, analytic "ask"):** an
[Ollama](https://ollama.com) running on the host, with the three models pulled. Without it,
*structured* ingestion, canonical fusion, and the REST API still work — but the embedding model
is unreachable, so RAG indexing **skips its chunks** (semantic search/chat return nothing) and
"ask" has no model to call. The hub reaches the host Ollama via `host.docker.internal`
(wired in the compose file); override with `OLLAMA_BASE_URL` in `.env` for a remote Ollama.
```bash
ollama serve # if not already running
ollama pull nomic-embed-text # embeddings (RAG indexing)
ollama pull llama3.2 # conversational RAG chat
ollama pull qwen2.5-coder:7b # text-to-SQL "ask" (4.7GB — the analytic model)
# optional: ollama pull gemma4 # the offline faithfulness @Judge eval
```
> **Linux host:** `host.docker.internal` resolves to the **docker bridge IP**, never to
> the host's loopback — and the Ollama systemd service binds `127.0.0.1` by default, so
> the hub cannot reach it as installed (Docker Desktop on macOS/Windows routes to the
> host loopback and masks this). Bind Ollama on the bridge, container-only:
>
> ```bash
> mkdir -p /etc/systemd/system/ollama.service.d
> printf '[Service]\nEnvironment="OLLAMA_HOST=172.17.0.1:11434"\n' \
> > /etc/systemd/system/ollama.service.d/override.conf
> systemctl daemon-reload && systemctl restart ollama
> ```
>
> `172.17.0.1` is Docker's default bridge gateway — if you customized the bridge
> (daemon.json `bip`, rootless), use what
> `docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}'` prints.
>
> Nothing is exposed publicly (172.17.0.1 is only reachable from containers) — but if a
> host firewall is active, allow the container range in:
> `ufw allow from 172.16.0.0/12 to any port 11434 proto tcp`. Source-range matching is
> deliberate: compose networks get their own `br-*` bridges (not `docker0`), so an
> interface-scoped rule would miss them. The /12 covers Docker's default address
> pools — narrow it if your host also carries VPN/corporate subnets in that range.
> The same rule applies to
> **any** host service the hub must reach through `host.docker.internal`. On a
> CPU-only box, `Environment="OLLAMA_KEEP_ALIVE=-1"` in the same override keeps the
> model loaded — first-question cold loads take tens of seconds otherwise.
## Quick start
```bash
./deploy/selfhost/up.sh --build # build from source — works for everyone (the image is private pre-launch)
# ...or, if your GitHub account has access to the published image:
./deploy/selfhost/up.sh # pull the image, generate secrets, start, wait for healthy
```
On the first run, `up.sh` creates `.env` with strong random secrets (`openssl rand`). When the hub is
healthy it prints the URLs:
- Hub — `http://localhost:8080` (health: `/q/health` — unauthenticated; the `/api/platform/*` API needs a token)
- Keycloak — `http://localhost:8180/realms/lumnik`
Then point `lm` at the hub (server `http://localhost:8080`, OIDC `http://localhost:8180/realms/lumnik`).
```bash
./deploy/selfhost/logs.sh hub # tail logs (omit the service name for all)
./deploy/selfhost/down.sh # stop the stack
./deploy/selfhost/down.sh --volumes # stop AND delete all data (full reset)
```
## Secrets
`up.sh` writes `.env` (gitignored, mode `600` — owner-only) with six secrets: the Postgres and Keycloak
admin passwords, the two hub encryption keys (`LUMNIK_SECRET_MASTER_KEY`, `LUMNIK_CRYPTO_SECRET_KEY`),
and the per-install passwords for the demo realm users (`HUBADMIN_PASSWORD`, `INTEGRATOR_PASSWORD` —
no known password ever survives an install).
**Back `.env` up** —
losing `LUMNIK_SECRET_MASTER_KEY` makes secrets stored by the hub unrecoverable. To pin a specific image
version instead of the latest `main` build, set `LUMNIK_VERSION` in `.env` (e.g. `LUMNIK_VERSION=1.0.0`
once a `hub-v*` release is tagged).
## Ports
By default the hub (`8080`) and Keycloak (`8180`) bind to `127.0.0.1` — reachable from the host only.
For a remote host, access them over an SSH tunnel, or see "Going public" below.
## Going public — the TLS façade (shipped)
To expose lumnik beyond localhost with one HTTPS origin, use the **TLS façade overlay**
(`deploy/tls/`): a single Caddy origin fronting the hub and path-proxying Keycloak under
`/realms` — one issuer for every device. A LAN install gets an internal CA certificate;
setting a public `LUMNIK_TLS_HOST` gets Let's Encrypt automatically.
Runbook: [deploy/tls/README.md](../tls/README.md) — also summarized on the
[deploy page](../../docs/deploy.md).
## Postgres bootstrap user
Fresh installs bootstrap Postgres as superuser **`lumnik`**, and `up.sh` records that
name in `.env`. The data volume is the authority: on every run the script verifies the
recorded name against the running database, and when `.env` is silent (older installs)
or wrong (a regenerated `.env` over an existing volume), it asks the database which name
the volume was initialized with — `lumnik`, or the legacy `adempiere` on pre-rename
volumes — and corrects `.env`. No action needed either way.
## Backup & restore
Nightly-able backup (hot pg_dump + Keycloak store) and restore, including onto a
fresh machine: `backup.sh` / `restore.sh` here — full runbook at
[docs/operations/backup-restore.md](../../docs/operations/backup-restore.md).
Back up `.env` separately: it holds the master key.
## Residuals
The hub's internal DB-role passwords (`lumnik_app` / `admin` / `readonly`) use built-in defaults; Postgres
is not published outside the compose network, so it is internal-only. Making those configurable is a
tracked follow-up.
## Email delivery (optional)
Rules (`lm notify add`) route matching events to email. Delivery is **mocked until both
of these knobs are set** in `.env` (set only one and the hub WARNs at boot):
```bash
LUMNIK_SMTP_HOST=smtp.example.com
LUMNIK_SMTP_MOCK=false
# optional: LUMNIK_SMTP_PORT=587 LUMNIK_SMTP_USERNAME=… LUMNIK_SMTP_PASSWORD=… LUMNIK_SMTP_FROM=…
```
The delivery drill, end to end, with the bundled dev mailer:
```bash
docker compose --profile mailpit -f docker-compose.selfhost.yml up -d
# .env: LUMNIK_SMTP_HOST=mailpit LUMNIK_SMTP_PORT=1025 LUMNIK_SMTP_MOCK=false
docker compose -f docker-compose.selfhost.yml up -d hub
lm notify add --events 'decision.*' --to you@example.com
# trigger a decision (or lm integration test on a rule-matched type), then open:
# browse to http://127.0.0.1:8025 — the sent mail, visible
```
Stop / tail logs: deploy/selfhost/down.sh, deploy/selfhost/logs.sh.
What the bundled Keycloak is — and is not
"Real OIDC" means real: JWTs are verified, dev-bypass is off. But the Keycloak this stack
ships runs start-dev on a file-H2 store — made for a single-host install you operate
yourself, not an enterprise identity deployment. For your own directory (Keycloak, Okta,
Entra ID), see Bring your own IdP — JIT provisioning for an external
issuer is not in the open edition. The H2 store's backup quirks are covered in
Backup & restore.
One HTTPS origin — the TLS façade
The base stack is loopback-only http. An optional overlay puts one Caddy origin in front of
everything — the hub (API + the /app/ PWA) at /, Keycloak path-proxied under /realms/* —
so every device sees one origin and one token issuer, which is what installing the PWA on
a phone requires. One command layers it on:
LUMNIK_TLS_HOST=erp.example.com \
docker compose -f docker-compose.selfhost.yml -f docker-compose.tls.yml up -d
- A public
LUMNIK_TLS_HOSTgets a Let's Encrypt certificate automatically. - A LAN IP or
localhostgets Caddy's internal CA for local testing. - Deliberate posture change: unlike the base compose, the overlay binds 443/80 on all
interfaces by default — serving phones on the LAN is its reason to exist — and
LUMNIK_TLS_BINDrestricts it. - Re-running
up.shlater composes the base file alone and un-stacks the overlay (the hub reverts to the localhost issuer) — on a façade install, always follow anup.shrun with the overlay command above.
Certificates, trusting the internal CA on a phone, and the change-of-issuer caveat are covered in the façade's own README — reproduced here, so the answer never depends on the repository being reachable:
The full TLS façade README
# TLS façade — one https origin for the self-host stack
Caddy in front of everything: the hub (API + the `/app/` PWA) at `/`, Keycloak
path-proxied under `/realms/*` + `/resources/*`. One origin means no mixed content,
no CORS, and **one token issuer for every device** — the per-origin issuer override the
mobile experiments had to carry is no longer needed.
> **Exposure posture (deliberate — C2, 2026-07-25):** unlike the base compose
> (loopback-only), this overlay binds 443/80 on **all interfaces** by default —
> serving the magasinier's phone on the LAN is the façade's whole reason to exist.
> Restrict it with `LUMNIK_TLS_BIND` (e.g. `LUMNIK_TLS_BIND=127.0.0.1` behind your
> own reverse proxy, or a specific LAN address). Adding "local TLS" is a network
> posture change — make it a choice, not a surprise.
```bash
LUMNIK_TLS_HOST=erp.example.com \
docker compose -f docker-compose.selfhost.yml -f docker-compose.tls.yml up -d
# → https://erp.example.com (hub + PWA)
# → https://erp.example.com/realms/lumnik (Keycloak)
```
Layer it LAST when combining overlays — its `QUARKUS_OIDC_TOKEN_ISSUER` must win.
## Certificates, automatically
| `LUMNIK_TLS_HOST` | Certificate |
|---|---|
| a public domain | Let's Encrypt, obtained and renewed by Caddy (ports 80+443 reachable) |
| a LAN IP / localhost | Caddy's internal CA — for local testing |
## Trusting the internal CA (LAN testing)
Browsers warn on the internal CA until you trust its root. Extract it from the
running container and install it on the device (iOS: AirDrop the file, then
*Settings → General → VPN & Device Management*, and enable full trust under
*Certificate Trust Settings*):
```bash
docker compose -f docker-compose.selfhost.yml -f docker-compose.tls.yml \
cp caddy:/data/caddy/pki/authorities/local/root.crt ./lumnik-local-ca.crt
```
With the root trusted, the PWA gets its full powers on the phone: the real install
prompt, `crypto.subtle`, service workers. (Without it you can still browse past the
warning, but installability is degraded — that's the browser's rule, not ours.)
## What the overlay changes
- **Keycloak** honors `X-Forwarded-*` (`KC_PROXY_HEADERS`) and announces the façade
origin in every URL it mints (`KC_HOSTNAME`); the hub keeps its internal
back-channel (`keycloak:8180`, `KC_HOSTNAME_BACKCHANNEL_DYNAMIC`).
- **The hub** validates tokens against the façade issuer and honors forwarded headers.
- Nothing else moves: the plain-http workflow (`http://localhost:8080`) keeps working
for a same-machine operator without this overlay.
## Lessons the live runs taught
- **Bare-IP clients send no SNI** (RFC 6066 forbids it), and without a `default_sni`
global option Caddy matches no certificate and aborts every handshake with TLS
alert 80. The Caddyfile carries the fix.
- **Re-running `up.sh` silently un-stacks the overlays.** `up.sh` composes the base file
alone, so the hub is recreated with the localhost issuer and every externally-minted
token is refused until the overlay command runs again. On a façade install, bringing
the stack up is a two-step gesture — `up.sh`, then
`LUMNIK_TLS_HOST=erp.example.com docker compose -f docker-compose.selfhost.yml -f docker-compose.tls.yml up -d`
(plus any other overlays you run; TLS last, its issuer must win).
- **Changing the public origin changes the token issuer — and orphans JIT-provisioned
identities.** They are keyed `(idp_issuer, idp_subject)`; after a move to https the
same human is a NEW identity whose first login collides with the old row's login
(`uq_user_login`, surfaced honestly as an error). Migrating an install to TLS means
migrating identities too: update `idp_issuer` on existing rows, or accept
re-provisioning by clearing the old-issuer users. Pick the origin once, early.
Point lm at the hub
Once the stack is healthy, wire the CLI to it. A context needs only the server and the
OIDC issuer — lm login discovers the device/token endpoints from the issuer's
.well-known document, so nothing else is hand-copied:
cd lm && go build -o lm ./cmd/lm # build the CLI (Go 1.26+ — the floor is lm/go.mod)
./lm config set-context local \
--server http://localhost:8080 \
--oidc-issuer http://localhost:8180/realms/lumnik
./lm config use-context local
./lm login # prints a URL + device code — open it, sign in as integrator (password: INTEGRATOR_PASSWORD in .env)
The bundled realm ships two demo users, both with no password baked in: up.sh mints a
per-install password for each through the Keycloak admin API once the stack is healthy.
Re-running up.sh heals older installs that still carry a historical default.
integrator— tenant 1, password in.envasINTEGRATOR_PASSWORD; change it before anything real.hubadmin— superadmin, tenant 11 (the tenant the bundled realm has always given it), password in.envasHUBADMIN_PASSWORD.
Both accounts and both tenants are seeded by the hub at install.
(--oidc-client-id defaults to lm-cli, the bundled realm's client — set it only against
your own IdP.) After lm login, every lm call is Bearer-authenticated; verify with the
first authenticated smoke test:
./lm connector list
How to…
Pin the hub image instead of tracking main
Set LUMNIK_VERSION in .env. The generated file ships the line commented out
(#LUMNIK_VERSION=main) — uncomment it, give it the tag you want (once a hub-v* release is
tagged), and re-run the launcher:
./deploy/selfhost/up.sh
Result: the stack runs ghcr.io/icreated/lumnik-hub:$LUMNIK_VERSION instead of the
rolling main build. up.sh never rewrites an existing .env, so the line survives every
re-run.
Point the hub at an Ollama that is not on this machine
Uncomment the OLLAMA_BASE_URL=… line in .env (the template ships it commented) and re-run
up.sh. Left unset, the hub looks for Ollama on the machine running Docker
(http://host.docker.internal:11434) — the default that makes chat and the analytic ask
work on a laptop. On the open edition this knob has nothing to reach: the extensions
that call Ollama are not in that build (see the edition note above), so an open-edition
install needs no Ollama at all.
Turn on email delivery
Notification rules (lm notify add) route matching events to email. Delivery is mocked
until both of these knobs are set in .env:
LUMNIK_SMTP_HOST=smtp.example.com
LUMNIK_SMTP_MOCK=false
# optional: LUMNIK_SMTP_PORT=587 LUMNIK_SMTP_USERNAME=… LUMNIK_SMTP_PASSWORD=…
# LUMNIK_SMTP_FROM=lumnik@example.com LUMNIK_SMTP_STARTTLS=REQUIRED
Re-run up.sh and the boot log answers with the posture (email delivery live via …).
Set only one of the two and the boot log WARNs instead of going half-live — a host
without LUMNIK_SMTP_MOCK=false delivers nothing; MOCK=false without a host loops
against localhost.
To rehearse without a provider, the stack bundles a dev mailer behind a compose profile:
docker compose --profile mailpit -f docker-compose.selfhost.yml up -d
# .env: LUMNIK_SMTP_HOST=mailpit LUMNIK_SMTP_PORT=1025 LUMNIK_SMTP_MOCK=false
Result: rule-matched events land as mail — visible at http://127.0.0.1:8025 on the
Mailpit drill, or in the recipient's real inbox once pointed at your provider. On the Helm
path the same knobs live under values.smtp.* (up.sh derives them from the same
LUMNIK_SMTP_* variables). A failed send is logged and skipped — best-effort by design;
the webhook channel is the one with retries and a delivery journal.
Reach the hub from another machine
Not by changing a port: the hub (8080) and Keycloak (8180) are pinned to 127.0.0.1 in
docker-compose.selfhost.yml, deliberately — so that a remote host never exposes the
Keycloak admin console on all interfaces. Two ways out, both supported: an SSH tunnel, or
the TLS façade above — which is also the one that lets a
phone install the PWA.
Run against a managed or external PostgreSQL
Not a knob on this path — the self-host stack wires the hub to its own Postgres container
(DB_URL is fixed in docker-compose.selfhost.yml). An external database is the
Kubernetes / Helm path's job — the next section.
Kubernetes / Helm
The cloud edition (commercial) ships a Kubernetes/Helm path — hub + Postgres + Keycloak with real OIDC, sealed secrets, and a managed-PostgreSQL mode. It is not part of this repository.