Tomter Vel's own platform: Caddy, NATS, Kanidm and portal as containers
deploy / deploy (push) Canceled after 0s

One host, four containers, content fetched from tomtervel/questions.
bootstrap.sh renders configs from .env and recovers the Kanidm admin;
kanidm-setup.sh creates the portal client and the desk groups. An
optional runner profile lets the content repo's reload reach this host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bl
2026-09-22 19:06:31 +02:00
co-authored by Claude Fable 5.1
commit 29f2b9daec
11 changed files with 454 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Copy to .env and fill in. Everything rendered from this (nats.conf,
# kanidm/server.toml, portal.env) is gitignored.
# Where the site and the identity provider are served. Both need an A
# record pointing at this host before the first start (Caddy gets the
# certificates over HTTP-01). tomtervel.no itself stays where it is
# until the vel decides to point the apex here.
PORTAL_HOST=portal.tomtervel.no
ID_HOST=id.tomtervel.no
# The portal version to run: a tag of https://project.uhhm.no/uhhm/portal
PORTAL_RELEASE=v0.3.36
# The content this instance serves, and reloads live on every push.
CONTENT_REPO=https://prosjekt.klingenbergbygg.no/tomtervel/questions
CONTENT_BRANCH=main
SITE_NAME=Tomter Vel
# Generated once by bootstrap.sh if left empty.
NATS_PASSWORD=
# Filled in by bootstrap.sh after it creates the Kanidm client.
OAUTH2_CLIENT_ID=tomtervel-portal
OAUTH2_CLIENT_SECRET=
# Only for the optional runner profile: a registration token from
# prosjekt.klingenbergbygg.no -> tomtervel org -> Settings -> Actions -> Runners.
RUNNER_REGISTRATION_TOKEN=
+32
View File
@@ -0,0 +1,32 @@
# Deploy from this repo: on the vel's own host, a runner registered
# against prosjekt.klingenbergbygg.no with the label `tomtervel`
# (the `runner` profile in compose.yml) checks out this repo and
# brings the stack up. Rolling out a new portal version is a commit
# that bumps PORTAL_RELEASE in .env.example and, on the host, in .env.
#
# Until that runner exists, this workflow queues and does nothing;
# deploy by hand with `git pull && docker compose up -d --build` on
# the host.
name: deploy
on:
push:
branches: [main]
paths:
- compose.yml
- Caddyfile
- portal/**
- kanidm/server.toml.tpl
- nats/nats.conf.tpl
- .gitea/workflows/deploy.yml
workflow_dispatch:
jobs:
deploy:
runs-on: tomtervel
steps:
- name: Pull and restart
run: |
set -eu
cd /srv/tomtervel/infrastructure
git pull --ff-only
sh bootstrap.sh
+6
View File
@@ -0,0 +1,6 @@
.env
portal.env
kanidm/server.toml
nats/nats.conf
certs/
.kanidm-recovered
+23
View File
@@ -0,0 +1,23 @@
# Public TLS for both hosts, real certificates from Let's Encrypt over
# HTTP-01: the DNS A records for ${PORTAL_HOST} and ${ID_HOST} must
# point at this host before the first start.
{$ID_HOST} {
# Kanidm serves its own (internal, self-signed) TLS; verification is
# skipped on the inside hop only.
reverse_proxy kanidm:8443 {
transport http {
tls_insecure_skip_verify
}
}
log {
output file /data/id.log
}
}
{$PORTAL_HOST} {
reverse_proxy portal:3000
log {
output file /data/portal.log
}
}
+86
View File
@@ -0,0 +1,86 @@
# Tomter Vel — infrastructure
Everything the vel's own site needs on one host, as containers: Caddy
for TLS, NATS with JetStream for the records, Kanidm for who is who,
and portal, the site itself. The content (pages, forms, desks) lives
in [tomtervel/questions](https://prosjekt.klingenbergbygg.no/tomtervel/questions)
and is fetched from there; this repo owns the host.
```
Internet ──► Caddy (Let's Encrypt)
├── PORTAL_HOST ──► portal:3000 ──► NATS (records) + Kanidm (login)
└── ID_HOST ─────► kanidm:8443 (internal TLS)
```
Today the vel's draft site runs on Klingenberg Bygg's host as
vel.klingenbergbygg.no, sharing that host's Kanidm and NATS. This repo
is the same site on a host of the vel's own, with a Kanidm of its own,
so nothing about the vel's members or records depends on anyone else.
## First start
On a fresh Linux host with docker (compose plugin) and openssl:
```sh
git clone https://prosjekt.klingenbergbygg.no/tomtervel/infrastructure /srv/tomtervel/infrastructure
cd /srv/tomtervel/infrastructure
cp .env.example .env # set PORTAL_HOST and ID_HOST; DNS must point here
sh bootstrap.sh # renders configs, makes the internal cert, starts, recovers Kanidm admin
sh kanidm-setup.sh # logs in, creates the portal client and desk groups, restarts portal
```
`bootstrap.sh` prints the `admin` and `idm_admin` passwords once;
write them down. After `kanidm-setup.sh`, https://PORTAL_HOST serves the
site and https://ID_HOST is the login.
## People and desks
Each group in the content (`qualifies:` under `questions/`) is a
Kanidm group `tomtervel_<group>`, all members of `tomtervel_members`.
Someone in `tomtervel_styret` logs in and sees the board's desk.
```sh
kanidm -D idm_admin -H https://ID_HOST person create kari "Kari Lien"
kanidm -D idm_admin -H https://ID_HOST person update kari --mail kari@example.no
kanidm -D idm_admin -H https://ID_HOST person credential create-reset-token kari
kanidm -D idm_admin -H https://ID_HOST group add-members tomtervel_styret kari
```
Membership is read at login; someone added while logged in logs out
and in again.
## Content changes
A push to tomtervel/questions is linted on push and tells the portal
to reload over NATS. That reload reaches the host it runs on: today
Klingenberg Bygg's. For this host, start the runner profile once with
a registration token from the tomtervel org's Actions settings:
```sh
docker compose --profile runner up -d
```
and give the content repo's `lint-and-reload.yml` a reload job with
`runs-on: tomtervel` that runs
`nats --server nats://portal:$NATS_PASSWORD@127.0.0.1:4222 pub portal.content.reload ""`.
Until then, `docker compose restart portal` picks up new content.
## Upgrading portal
Bump `PORTAL_RELEASE` in `.env` (a tag of uhhm/portal) and
`docker compose up -d --build portal`. Keep `IRIS_RELEASE` in the
content repo's workflow matched to it.
## Backups
- Kanidm writes a nightly backup into its volume (`/data/backups`,
seven kept); copy that directory off the host.
- NATS JetStream data is the `nats_data` volume: every record ever
submitted and every state change. Snapshot the volume.
- Caddy's certificates regenerate; nothing to keep.
## What is not here
Mail (a person's reset link is a token you hand them), monitoring, and
the vel's current website at tomtervel.no, which stays where it is
until the vel points the apex at PORTAL_HOST.
Executable
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
# First start on a fresh host. Idempotent: rerunning renders configs
# again and skips what exists. Needs docker with the compose plugin,
# openssl, and DNS for PORTAL_HOST and ID_HOST already pointing here.
#
# cp .env.example .env # fill in the hosts
# sh bootstrap.sh
set -eu
cd "$(dirname "$0")"
[ -f .env ] || { echo "copy .env.example to .env and fill it in first"; exit 1; }
. ./.env
# A NATS password, once.
if [ -z "${NATS_PASSWORD:-}" ]; then
NATS_PASSWORD=$(openssl rand -base64 36 | tr -d '/+=' | cut -c1-40)
sed -i "s|^NATS_PASSWORD=.*|NATS_PASSWORD=$NATS_PASSWORD|" .env
echo "NATS_PASSWORD generated into .env"
fi
# Rendered configs (gitignored).
sed "s|\${ID_HOST}|$ID_HOST|g" kanidm/server.toml.tpl > kanidm/server.toml
sed "s|\${NATS_PASSWORD}|$NATS_PASSWORD|g" nats/nats.conf.tpl > nats/nats.conf
cat > portal.env <<EOF
NATS_URL=nats://portal:$NATS_PASSWORD@nats:4222
KANIDM_URL=https://$ID_HOST
OAUTH2_CLIENT_ID=$OAUTH2_CLIENT_ID
OAUTH2_CLIENT_SECRET=${OAUTH2_CLIENT_SECRET:-}
PUBLIC_URL=https://$PORTAL_HOST
COOKIE_SECURE=true
CONTENT_REPO=$CONTENT_REPO
CONTENT_BRANCH=$CONTENT_BRANCH
SITE_NAME=$SITE_NAME
EOF
chmod 600 portal.env
# Kanidm's internal certificate: Caddy holds the public one.
mkdir -p certs
if [ ! -f certs/kanidm-key.pem ]; then
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-subj "/CN=kanidm" -addext "subjectAltName=DNS:kanidm,DNS:$ID_HOST" \
-keyout certs/kanidm-key.pem -out certs/kanidm-chain.pem >/dev/null 2>&1
chmod 600 certs/kanidm-key.pem
echo "internal Kanidm certificate made"
fi
docker compose up -d --build
echo "containers up; Caddy is fetching certificates for $PORTAL_HOST and $ID_HOST"
# The Kanidm admin accounts exist only after the first start; their
# passwords are set by recovery. Do this once; the output is the
# password, shown once.
if [ ! -f .kanidm-recovered ]; then
echo
echo "=== Kanidm admin recovery (write these passwords down) ==="
docker compose exec kanidm kanidmd recover-account admin
docker compose exec kanidm kanidmd recover-account idm_admin
touch .kanidm-recovered
fi
echo
echo "Next: sh kanidm-setup.sh (log in as idm_admin, create the portal client and desk groups)"
+105
View File
@@ -0,0 +1,105 @@
# Tomter Vel's own platform: one host, four containers, everything
# behind Caddy. The content (pages, forms, desks) is not here: portal
# fetches it from tomtervel/questions on prosjekt.klingenbergbygg.no
# and hot-reloads it over NATS. This repo owns the host: identity,
# the bus, TLS, and which portal version runs.
#
# bootstrap.sh first time on a fresh host: renders configs from
# .env, makes Kanidm's internal cert, starts it all,
# recovers the Kanidm admin, creates the portal client
# and desk groups.
# docker compose up -d --build every time after that.
name: tomtervel
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
PORTAL_HOST: ${PORTAL_HOST}
ID_HOST: ${ID_HOST}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- portal
- kanidm
nats:
image: nats:2.14.6-alpine
restart: unless-stopped
command: ["-c", "/etc/nats/nats.conf"]
volumes:
- ./nats/nats.conf:/etc/nats/nats.conf:ro
- nats_data:/data
# Published so a runner or a person on the host can `nats pub
# portal.content.reload ""`; password-protected (see nats.conf).
ports:
- "127.0.0.1:4222:4222"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
interval: 10s
timeout: 5s
retries: 3
kanidm:
image: kanidm/server:1.11.1
restart: unless-stopped
environment:
KANIDM_CONFIG_PATH: /data/server.toml
volumes:
- kanidm_data:/data
- ./kanidm/server.toml:/data/server.toml:ro
# Internal self-signed TLS: Caddy terminates the public
# certificate and proxies here without verification.
- ./certs/kanidm-chain.pem:/data/chain.pem:ro
- ./certs/kanidm-key.pem:/data/key.pem:ro
# No published ports: only Caddy talks to it.
portal:
build:
context: ./portal
args:
PORTAL_RELEASE: ${PORTAL_RELEASE}
restart: unless-stopped
env_file: portal.env
environment:
LEPTOS_SITE_ADDR: 0.0.0.0:3000
LEPTOS_SITE_ROOT: site
LEPTOS_HASH_FILES: "true"
depends_on:
nats:
condition: service_healthy
kanidm:
condition: service_started
# Optional: a Gitea Actions runner on this host, so the content repo's
# lint-and-reload can reach this NATS. Register it once against
# prosjekt.klingenbergbygg.no with the label `tomtervel`, then give
# the content repo a reload job with `runs-on: tomtervel`.
# docker compose --profile runner up -d
runner:
profiles: ["runner"]
image: gitea/act_runner:latest
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: https://prosjekt.klingenbergbygg.no
GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_REGISTRATION_TOKEN:-}
GITEA_RUNNER_NAME: tomtervel
GITEA_RUNNER_LABELS: tomtervel:host
volumes:
- runner_data:/data
- /var/run/docker.sock:/var/run/docker.sock
volumes:
caddy_data:
caddy_config:
nats_data:
kanidm_data:
runner_data:
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# The portal's OAuth2 client and one Kanidm group per desk, mapped into
# the `groups` claim under the names the pages use in `qualifies`.
# Portal reads that claim at login (portal src/auth.rs). Rerunnable.
#
# Logs in first (interactive, idm_admin's password from bootstrap.sh),
# then writes the client secret into .env and portal.env and restarts
# the portal. Needs the kanidm CLI on this machine.
set -eu
cd "$(dirname "$0")"
. ./.env
K="kanidm -D idm_admin -H https://$ID_HOST"
C=$OAUTH2_CLIENT_ID
$K login
has_client() { $K system oauth2 get "$1" 2>/dev/null | grep -q '^name:'; }
has_group() { $K group get "$1" 2>/dev/null | grep -q '^name:'; }
has_client $C || $K system oauth2 create $C "$SITE_NAME" "https://$PORTAL_HOST"
$K system oauth2 add-redirect-url $C "https://$PORTAL_HOST/auth/callback" || true
# The desk groups: every group the content gates a directory on. Keep
# this list equal to the `qualifies` values under questions/.
has_group tomtervel_members || $K group create tomtervel_members
for g in kasserer styret trafikkomite lekeplasskomite arrangementskomite nabohjelp komiteer; do
has_group tomtervel_$g || $K group create tomtervel_$g
$K group add-members tomtervel_members tomtervel_$g
done
# Portal asks for openid, profile and email; groups arrive as a claim.
$K system oauth2 update-scope-map $C tomtervel_members openid profile email
for g in kasserer styret trafikkomite lekeplasskomite arrangementskomite nabohjelp komiteer; do
$K system oauth2 update-claim-map $C groups tomtervel_$g $g
done
$K system oauth2 update-claim-map-join $C groups array
secret=$($K system oauth2 show-basic-secret $C 2>/dev/null | tail -1)
sed -i "s|^OAUTH2_CLIENT_SECRET=.*|OAUTH2_CLIENT_SECRET=$secret|" .env portal.env
docker compose restart portal
echo "client $C configured; portal restarted with its secret"
echo
echo "Give people their desk (membership is read at login):"
echo " $K group add-members tomtervel_styret <person>"
echo "Create a person:"
echo " $K person create <name> '<Display Name>' && $K person update <name> --mail <email>"
echo " $K person credential create-reset-token <name>"
+30
View File
@@ -0,0 +1,30 @@
# Kanidm server configuration. bootstrap.sh renders this into
# server.toml from .env; edit the template, not the rendered file.
# Reference: https://kanidm.github.io/kanidm/stable/server_configuration.html
version = "2"
bindaddress = "0.0.0.0:8443"
# Internal self-signed pair made by bootstrap.sh; Caddy terminates the
# public certificate and proxies here with verification disabled.
tls_chain = "/data/chain.pem"
tls_key = "/data/key.pem"
db_path = "/data/kanidm.db"
db_fs_type = "other"
db_arc_size = 2048
log_level = "info"
# domain must equal the DNS name Kanidm is served at.
domain = "${ID_HOST}"
origin = "https://${ID_HOST}"
# Trust X-Forwarded-For from Caddy on the compose network.
[http_client_address_info]
x-forward-for = ["172.16.0.0/12"]
[online_backup]
path = "/data/backups/"
schedule = "00 22 * * *"
versions = 7
+19
View File
@@ -0,0 +1,19 @@
# NATS with JetStream: portal's records, events and projections live
# here. bootstrap.sh renders this into nats.conf from .env.
port: 4222
http_port: 8222
max_payload: 8388608
max_connections: 1000
# User and password rather than a token: URL credentials
# (nats://user:pass@host) behave the same in every client library.
authorization {
users = [
{ user: "portal", password: "${NATS_PASSWORD}" }
]
}
jetstream {
store_dir: /data
}
+18
View File
@@ -0,0 +1,18 @@
# Portal, from the release tarball uhhm/portal publishes on
# project.uhhm.no (the same artifact the bare-metal instances run).
# PORTAL_RELEASE in .env pins the version; bumping it and rebuilding is
# the whole upgrade.
FROM debian:bookworm-slim
ARG PORTAL_RELEASE
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/${PORTAL_RELEASE}/portal-${PORTAL_RELEASE}.tar.gz" \
| tar -xz -C /app \
&& test -x /app/portal
# A non-root user; the image ships nothing writable it needs.
RUN useradd --system --no-create-home portal
USER portal
EXPOSE 3000
CMD ["/app/portal"]