Ironscan
Scan, persist, browse: Ironman race data as a queryable archive.
Ironscan is a first-party project: a read-only Go client for Ironman race data plus the small web stack that runs it. The iron-web binary scrapes ironman.com, RTRT.me and Open-Meteo, persists calendars, participants, results, splits and course assets to Postgres, and serves an embedded SvelteKit UI over Connect-RPC from one port. A companion CLI (ironctl) runs the same scrapes from a terminal and can write into the same database. The bulk of the data is the full-field split tables, which is what sizes the deployment.
Installation
Architecture
- Deployment: Single
ironscandeployment in theironscannamespace, one replica,Recreatestrategy - Image:
gitea.web.kueber.eu/johnny/ironscan, a static Go binary with the SvelteKit UI embedded; the repo's own Gitea workflow builds and pushes it to the private registry, and thesecrets.docker-giteanamespace label delivers theregcred-docker-giteapull secret - Database: CNPG PostgreSQL cluster from the cnpg component, two instances on
longhorn-db-single-encrypted, volume patched up to 30Gi because the split imports outgrew the component default; the DSN reaches the pod asDATABASE_URLfrom thecnpg-appsecret - Storage: No PVC of its own; the only writable path is an
emptyDiron/tmp - Networking: ClusterIP service on port 8080, HTTPRoute on the public gateway's
httpslistener forironscan.web.kueber.eu;CORS_ORIGINnames the same URL - Probes: Goose migrations run before the listener opens, so the startup probe on
/healthzallows ten minutes (the app's ownMIGRATION_TIMEOUT) before readiness (/readyz) and liveness take over - Poll-lease guard:
LIVE_INSTANCE_IDis set from the pod name, so a second instance (a debug run, an accidental scale-up) never double-polls the same live RTRT event - Priority:
tier-2for the app and its database, with requests of 25m/64Mi (app) and 50m/256Mi (Postgres) and memory limits at roughly twice that
Security
- Runs as uid 65532,
runAsNonRoot: true, read-only root filesystem, all capabilities dropped,RuntimeDefaultseccomp, no service account token - Login happens at the gateway, not in the app: an Envoy Gateway
SecurityPolicy(oidc-ironscan) fronts the HTTPRoute with Keycloak, clientironscanin thekueber.eurealm, callback/oauth2/callback,Laxcookies scoped to the hostname. It gates every path,/healthzincluded; kubelet probes hit the pod directly and are unaffected. The app's own bearer-token guard stays off because the SvelteKit frontend never attaches anAuthorizationheader - Envoy reaches the issuer through a dedicated
Backend(keycloak-backend) validated against the proxy image's system CA bundle, because the in-cluster DNS view ofkeycloak.web.kueber.eupoints back at the gateway. The authorization, token and end-session endpoints are declared explicitly so the controller skips OIDC discovery, which fails with a system-trust Backend on the Envoy Gateway versions in use; the end-session endpoint is what makes RP-initiated logout work with discovery off. The pattern is written up in identity end-to-end - Network contract from the app-network-policy component: ingress only from the public gateway's Envoy pods; the baseline egress (DNS, the namespace, the gateway) is widened by
egress-ironscanto 443/80 on the internet so the scanners can reach ironman.com, RTRT.me and Open-Meteo - Database volumes encrypted at rest with the SOPS-managed
cnpg-longhornkey; first-party images from the Gitea registry fall under the audit-mode cosign policy described in image supply chain
Updates
The deployment pins latest@sha256:… and pulls with imagePullPolicy: Always; Renovate moves the digest whenever the Gitea workflow pushes a new build. Schema changes ship as goose migrations inside the binary and run on the next start, within the startup-probe window.
Data Management
- Database: CNPG cluster
cnpg, 30Gi, dumped by thebackups-cnpgPreBackupPod (pg_isreadywait, thenpg_dump --cleanofapp) - Backups: k8up
Schedules to Hetzner and Backblaze (workload-talos-ironscan-*) via restic from the backups component: daily backup, weekly check and prune, retention of 5 last, 7 daily, 4 weekly, 12 monthly and 5 yearly snapshots - Provisioning: the workspace component creates the restic buckets and the DNS record for the hostname through a
TofuWorkspace - Restoring means replaying the dump into a fresh cluster; see Postgres restore
User Management
Whoever Keycloak lets through the ironscan client is in; the app has no accounts, roles or per-user state. The confidential client is created by the app's workspace (spec.oidc, not gated) and the policy reads the minted secret from workspace-outputs; without that Secret the route answers with errors before the UI is ever reached.
Configuration Management
- Runtime settings as plain env vars in the talos patch (
CORS_ORIGIN,LIVE_INSTANCE_ID); the database DSN from the CNPG-generatedcnpg-appsecret - The OIDC client secret in
workspace-outputs(minted by the workspace), the Longhorn volume key incnpg-longhorn, the restic password ink8up-restic-password, the tofu state key intofu-encryption
Usage
Sign in through Keycloak and browse races by calendar, then drill into participants, results and splits; a running-scans popup shows scan progress when the backend is scanning. New data comes from the in-pod scanners; bulk history can still be loaded through the database (ironctl --db, or a dump restore).
Cluster-specific deviations from the above live in the per-cluster README — see k8s/apps/ironscan/talos/README.md.
Cluster Deployment
Ironscan — Talos cluster
Deviations from defaults
- First-party image from the private Gitea registry —
gitea.web.kueber.eu/johnny/ironscan:latest, pulled viaregcred-docker-gitea(namespace labelsecrets.docker-gitea). The ironscan repo builds and pushes the image itself (docker-buildworkflow). - OIDC at the gateway, not in the app — iron-web's own
AUTH_TOKENbearer guard stays off because the SvelteKit frontend never attaches anAuthorizationheader. Instead an Envoy GatewaySecurityPolicy(security-policy-oidc.yaml) fronts the HTTPRoute with Keycloak (clientironscan, realmkueber.eu, redirecthttps://ironscan.web.kueber.eu/oauth2/callback). Envoy reaches the issuer through a dedicatedBackendvalidated against the proxy image's system CA bundle (wellKnownCACertificates: System), since the in-cluster DNS view ofkeycloak.web.kueber.eupoints back at the gateway — no manifest updates when Let's Encrypt rotates chains. The Keycloak client is created by the TofuWorkspace (workspace/patch-oidc.yaml, not gated) and the policy reads its secret fromworkspace-outputs; nothing is copied by hand. Note this gates every path — including/healthzfrom outside; kubelet probes hit the pod directly and are unaffected. - 30Gi CNPG volume — full-field split imports dominate: the June 2026 dump was 1.3 GiB gzipped, roughly 10 GiB live with indexes, so the component default of 2Gi is patched up in
cnpg/patch-cluster.yaml. - 10-minute startup probe — goose migrations run before the listener opens (
MIGRATION_TIMEOUTdefaults to 10m, sized for one-shot data migrations over the splits table), so the startup probe covers the same window before liveness takes over. - Single replica with the poll-lease guard armed —
LIVE_INSTANCE_IDis set from the pod name anyway; it costs nothing and prevents double-polling of live RTRT events if a second instance ever runs alongside (debug run, accidental scale-up).
- Image:
gitea.web.kueber.eu/johnny/ironscan:latest@sha256:c34460fffc7726abad97f61a4c6fdd43ef0c0a2ecf060c9efb00e386e84dce10
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app.kubernetes.io/name: ironscan
name: ironscan
namespace: ironscan
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: ironscan
strategy:
rollingUpdate: null
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: ironscan
spec:
automountServiceAccountToken: false
containers:
- env:
- name: CORS_ORIGIN
value: https://ironscan.web.kueber.eu
- name: LIVE_INSTANCE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: DATABASE_URL
valueFrom:
secretKeyRef:
key: uri
name: cnpg-app
image: gitea.web.kueber.eu/johnny/ironscan:latest@sha256:c34460fffc7726abad97f61a4c6fdd43ef0c0a2ecf060c9efb00e386e84dce10
imagePullPolicy: Always
livenessProbe:
failureThreshold: 5
httpGet:
path: /healthz
port: web
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 3
name: ironscan
ports:
- containerPort: 8080
name: web
protocol: TCP
readinessProbe:
failureThreshold: 3
httpGet:
path: /readyz
port: web
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 3
resources:
limits:
memory: 512Mi
requests:
cpu: 25m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
readOnlyRootFilesystem: true
startupProbe:
failureThreshold: 60
httpGet:
path: /healthz
port: web
periodSeconds: 10
timeoutSeconds: 3
volumeMounts:
- mountPath: /tmp
name: tmp
imagePullSecrets:
- name: regcred-docker-gitea
priorityClassName: tier-2
securityContext:
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
runAsGroup: 65532
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
volumes:
- emptyDir: {}
name: tmp