Helm Chart Architecture
Overview
Section titled “Overview”dbportal’s Helm chart provides a production-grade Kubernetes deployment with security hardening, pluggable storage and autoscaling.
Distribution
Section titled “Distribution”The chart is not published to a registry yet. Install it from this repository
(charts/dbportal/ — the directory keeps the upstream name until the rebrand’s
chart layer, see CONTEXT.md §5):
helm dependency build charts/dbportalhelm install dbportal charts/dbportalThe image it points at is ghcr.io/klinux/dbportal (main on every push, semver on v* tags).
Chart Structure
Section titled “Chart Structure”charts/dbportal/├── Chart.yaml # Metadata, appVersion, Bitnami PostgreSQL dependency├── values.yaml # All configurable defaults├── values.schema.json # JSON Schema validation (helm lint --strict)├── .helmignore # Package exclusion patterns├── README.md # Chart-level documentation└── templates/ ├── _helpers.tpl # Named templates (labels, names, image, storage logic) ├── deployment.yaml # App Deployment (checksum restart, emptyDir, probes) ├── service.yaml # ClusterIP / NodePort / LoadBalancer ├── ingress.yaml # Optional Ingress (nginx/traefik) ├── route.yaml # Optional Gateway API HTTPRoute (default off) ├── configmap.yaml # Non-sensitive env vars (PORT, storage, LLM, OIDC) ├── seed-configmap.yaml # Optional seed-connections config (rendered when enabled) ├── secret.yaml # Sensitive env vars (JWT, passwords, API keys) ├── serviceaccount.yaml # SA with IRSA/Workload Identity annotations ├── hpa.yaml # HorizontalPodAutoscaler (CPU + memory) ├── pdb.yaml # PodDisruptionBudget ├── pvc.yaml # PersistentVolumeClaim (SQLite mode) ├── networkpolicy.yaml # Ingress/egress rules (DB ports, DNS, HTTPS) └── NOTES.txt # Post-install usage instructionsArchitecture Decisions
Section titled “Architecture Decisions”1. Security Hardening
Section titled “1. Security Hardening”The chart enforces a restrictive security posture by default:
podSecurityContext: runAsNonRoot: true runAsUser: 1001 # Matches Dockerfile (adduser --uid 1001 nextjs) runAsGroup: 1001 fsGroup: 1001 seccompProfile: type: RuntimeDefault
securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: [ALL]readOnlyRootFilesystem + writable volumes: Next.js writes to .next/cache at runtime, and the app writes generated first-run credentials and the sample database into its data directory. Three writable mounts solve this without relaxing security:
/app/.next/cache— emptyDir; Next.js ISR/build cache (ephemeral, per-pod)/tmp— emptyDir; temporary files/app/data— data directory (auth-bootstrap.json, sample database): emptyDir by default, the PVC when persistence is enabled
OpenShift adaptation: OpenShift’s restricted-v2 SCC assigns
runAsUser/fsGroup from a per-namespace range and rejects pods that
hard-code IDs outside it. global.compatibility.openshift.adaptSecurityContext
(default auto; same contract as the Bitnami subchart, so one value covers
both) makes the dbportal.podSecurityContext helper omit
runAsUser/runAsGroup/fsGroup when the API server exposes
security.openshift.io/v1, keeping runAsNonRoot and the seccomp profile.
Arbitrary UIDs are safe because every writable path is a volume mount and the
entrypoint execs directly when not running as root.
2. Dockerfile Alignment
Section titled “2. Dockerfile Alignment”The chart is tightly coupled to the Dockerfile:
| Dockerfile | Chart |
|---|---|
EXPOSE 3000/tcp |
service.targetPort: 3000 |
adduser --uid 1001 nextjs |
podSecurityContext.runAsUser: 1001 |
WORKDIR /app |
Volume mounts under /app/ |
mkdir -p data |
/app/data volume: PVC when persistence is enabled, emptyDir otherwise |
GET /api/db/health |
Startup/readiness/liveness probes |
3. Storage Modes
Section titled “3. Storage Modes” ┌─────────────┐ │ values.yaml │ │ storageProvider │ └──────┬──────┘ │ ┌────────────┼────────────┐ ▼ ▼ ▼ ┌───────┐ ┌────────┐ ┌──────────┐ │ local │ │ sqlite │ │ postgres │ │ │ │ │ │ │ │ No PVC│ │ Auto │ │ External │ │ No DB │ │ PVC │ │ URL or │ │ │ │ create │ │ Subchart │ └───────┘ └────────┘ └──────────┘- local (default): Browser localStorage only. No server-side persistence.
- sqlite: Auto-creates PVC. Single-writer — do not use with multiple replicas.
- postgres: Two options:
postgresql.enabled=true→ Deploys Bitnami subchart, auto-wiresSTORAGE_POSTGRES_URLsecrets.storagePostgresUrl→ External PostgreSQL connection
Auto-wiring logic (_helpers.tpl):
if postgresql.enabled AND storageProvider == "local": effective storageProvider = "postgres" # auto-switch4. PostgreSQL Subchart Integration
Section titled “4. PostgreSQL Subchart Integration”When postgresql.enabled=true:
┌──────────────────────┐ ┌───────────────────────┐│ dbportal Pod │ │ PostgreSQL Pod ││ │ │ (Bitnami subchart) ││ STORAGE_POSTGRES_URL├─────►│ :5432 ││ = postgresql:// │ │ ││ libredb:$PASS@ │ │ Secret: ││ <release>-pg:5432 │ │ <release>-postgresql ││ /libredb_storage │ │ │└──────────────────────┘ └───────────────────────┘Subchart secret name follows Bitnami convention: <release-name>-postgresql (not <release>-<chart>-postgresql).
5. Secret Management
Section titled “5. Secret Management”┌─────────────────────────────────────────────┐│ secrets.existingSecret ││ ││ Set? ──Yes──► Use external secret ││ │ (Vault/Sealed Secrets/ESO) ││ No Skip secret.yaml rendering ││ │ ││ ▼ ││ secret.yaml rendered with: ││ - strict (authBootstrap=off): required ││ jwtSecret, adminPassword ││ - zero-config (default): only provided ││ values are written, missing ones are ││ generated by the app on first start ││ - optional: llmApiKey, oidcClientId/Secret, ││ storagePostgresUrl │└─────────────────────────────────────────────┘existingSecretKeys allows custom key name mapping for external secrets.
extraSecretEnv (docs/CONTEXT.md §4.45) covers what the block above has no key for - METRICS_TOKEN, STORAGE_ENCRYPTION_KEY, VAULT_TOKEN, the ${DB_PASSWORD} a seed file refers to: a map of environment variables rendered into <release>-extra-env (templates/secret-extra-env.yaml), attached with envFrom to every role Deployment after the ConfigMap, and hashed into the pod template so a rotated value rolls the pods. extraEnv would put the same values in the pod spec, which anyone who may read a Deployment reads. tests/unit/helm-chart-extra-secret-env.test.ts holds it, and that a default render is unchanged.
6. ConfigMap / Environment Variables
Section titled “6. ConfigMap / Environment Variables”Most non-sensitive configuration flows through a ConfigMap (the seed-connection vars are the exception — see the note below the table):
| Variable | Source | Conditional |
|---|---|---|
NODE_ENV |
Fixed production |
Always |
PORT |
service.targetPort |
Always |
HOSTNAME |
config.bindAddress — empty by default, which is the “let the image resolve it” sentinel; extraEnv overrides it |
Always |
NEXT_TELEMETRY_DISABLED |
Fixed 1 |
Always |
NODE_OPTIONS |
Fixed --max-old-space-size=384 |
Always |
NEXT_PUBLIC_AUTH_PROVIDER |
authProvider |
Always |
LOG_LEVEL |
config.logLevel |
Always |
AUTH_BOOTSTRAP |
config.authBootstrap |
When non-empty (chart default: "" — omitted) |
STORAGE_PROVIDER |
Auto-wired (see above) | Always |
STORAGE_SQLITE_PATH |
config.storageSqlitePath |
When sqlite |
SEED_CONFIG_PATH / SEED_CACHE_TTL_MS |
seedConnections.* — set directly on the Deployment (not via the ConfigMap) |
When seedConnections.enabled |
LLM_PROVIDER/MODEL/API_URL |
config.llm* |
When set |
OIDC_* |
config.oidc* |
When authProvider=oidc |
“Fixed” above describes what the ConfigMap writes, not what the container ends up with: the ConfigMap arrives through envFrom, extraEnv renders into the container’s env: list, and an explicit env entry always wins over an envFrom key of the same name. Any row above can therefore be overridden through extraEnv, though only some are safe to: PORT is rendered from service.targetPort and also drives the container’s named http port and all three probes, so overriding it through extraEnv alone moves the app off the port the probes still target and the pod never becomes Ready. HOSTNAME is the one row whose ConfigMap value is deliberately empty. Empty is a sentinel, not an omission: it means “nobody chose”, and the container’s entrypoint then resolves its own bind address, preferring :: — which it proves is dual-stack by connecting an IPv4 client to a throwaway :: listener, rather than inferring it from a sysctl. It falls back to 0.0.0.0 when the namespace has no IPv6, or when :: really is IPv6-only while a non-loopback IPv4 address exists. The key is written rather than omitted so that a pod name injected as HOSTNAME by the runtime can never reach the server; config.bindAddress overrules the resolver:
config: bindAddress: "0.0.0.0" # or "::" to force dual-stack; "" (default) resolves itDual-stack is one setting now, not two.
service.ipFamilyPolicy(PreferDualStack/RequireDualStack) andservice.ipFamiliesgive the Service an IPv6 address, and that is all an operator has to set — the pod listens on both families by default. What an operator must not do is the inverse pairing:config.bindAddress(or anextraEnvHOSTNAME) pinned to an IPv4 literal alongside a dual-stack Service. Kubernetes never inspects what the container bound, so such a Service populates an IPv6 EndpointSlice from the pod’s IPv6 address and kube-proxy routes IPv6 traffic to a socket that is not there — the client getsconnection refused. It never self-heals and it is invisible to the probes, because the kubelet probes the pod’s primary IP (IPv4 on a typical IPv4-primary cluster), so the pod reports Ready while its IPv6 path is dead.NOTES.txtwarns on exactly that combination at install time. Values reference: the chartREADME.md.
The chart follows the application’s zero-config default (config.authBootstrap: "" omits AUTH_BOOTSTRAP, so missing JWT_SECRET/ADMIN_PASSWORD are generated at first boot): a default-values install is fully working, which certified catalogs such as the Rancher partner-charts repository require. The architectural consequence for this document is that the deployment may only reference Secret keys that actually exist — the env entries for JWT_SECRET, ADMIN_PASSWORD, USER_EMAIL, USER_PASSWORD render only when their value is set or an existingSecret is used, and a mandatory secretKeyRef is reserved for the one combination where a missing key really is an error (strict mode with authProvider=local).
The behaviour itself — what gets generated, how to retrieve the credentials, what strict mode requires per auth provider, and the single-replica constraint — is documented once, in the chart’s README.md. Treat that section as canonical and do not restate it here.
For the complete, authoritative list of configurable values and defaults, see the chart’s own
README.md. This document covers architecture and rationale; the chart README is the values reference.
7. Pod Restart on Config Change
Section titled “7. Pod Restart on Config Change”Deployment annotations include checksums of ConfigMap and Secret:
annotations: checksum/config: {{ sha256sum configmap.yaml }} checksum/secret: {{ sha256sum secret.yaml }}Any change to configuration values triggers a rolling restart automatically.
8. Seed Connections
Section titled “8. Seed Connections”When seedConnections.enabled=true, the chart provisions a set of pre-defined database connections at startup:
- You must supply the definitions via either inline
seedConnections.config(rendered intoseed-configmap.yaml) or anexistingConfigMap. Enabling the feature with neither fails the render with an explicit message (a template guard indeployment.yaml) — previously the Deployment shipped mounting a ConfigMap that was never rendered, and the pods failed at startup instead. - The deployment mounts the ConfigMap at
/app/config/<key>, where<key>isseedConnections.configMapKey(defaultseed-connections.yaml), and setsSEED_CONFIG_PATHto that path (plusSEED_CACHE_TTL_MSfromseedConnections.cacheTTL). These two env vars are set on the Deployment directly, not through the app ConfigMap. - Credentials referenced by the seed config resolve from environment/secret at runtime, so secrets stay out of the ConfigMap.
9. Agent Runtime
Section titled “9. Agent Runtime”The agent has no on-switch: the app derives whether it can run from a configured model plus a writable ledger (docs/AGENT.md), so the chart’s job is to write less, not more.
agent.enabledunset — the default — writes noDBPORTAL_AGENT_ENABLEDat all, leaving the derivation intact;falsewrites the off-switch (quoted, becauseEnvVar.valueis a string);trueis accepted and explicit.- The chart does write one
WORKFLOW_*variable:WORKFLOW_LOCAL_DATA_DIR=/app/data/workflow, inside the volume mounted on/app/datain every render. The reason is release topology, not preference.image.tagdefaults to.Chart.AppVersion, which now names an image whose Dockerfile carries the same default, but that default landed in the Dockerfile only after the0.11.0tag, so an install pinned to an olderimage.tagstill resolves an unset variable to.workflow-dataunderWORKDIR /app, wherereadOnlyRootFilesystem: truemakes it unwritable andresolveAgentLedgerDirectory’s probe answersLEDGER_UNAVAILABLE. The chart therefore keeps writing the path itself. Both copies name the same path andtests/unit/helm-chart-agent.test.tsasserts they stay equal, so the duplication cannot drift; it is written beforeextraEnv, so an operator can still move the ledger. - A release where an agent could run and more than one replica is asked for fails the render (a template guard in
deployment.yaml, same shape as the seed guard above). The zero-config ledger takes file locks and each pod mounts its own/app/data, so a run started on one pod is invisible to the next request; the only backend that lifts the constraint (@workflow/world-postgres) is not loadable in the published image (B16indocs/BACKLOG.md), which the message says rather than selling an opt-in that does not exist yet. “Could run” is read conservatively from these values: an inlinesecrets.llmApiKey,config.llmProviderset to one of the key-optional providers (ollama,custom— the app’svalidateConfigdemands a key only forgeminiandopenai), or an explicitagent.enabled=true. So an existing multi-replica install that configures no AI keeps rendering. The guard’s blind spots aresecrets.existingSecret,extraEnvFromandextraEnv, none of which a template can read a model out of; all three are listed in the chart README and in the helper’s own comment. - Run history lives in that ledger, so
persistence.enabled=falsemeans anemptyDirand a history that goes with the pod.values.yaml, the chart README andNOTES.txtall say so.
10. Roles: the agent surface run apart
Section titled “10. Roles: the agent surface run apart”role: agent (docs/CONTEXT.md §4.30) writes DBPORTAL_ROLE=agent, and the same image then serves only the service API (/api/v1), the MCP endpoint (/api/mcp), the probes and the metrics scrape; every page, session route and admin route is a 404. role: worker (§4.40) writes DBPORTAL_ROLE=worker: the instance serves only the probes and the scrape and runs the job queue. keda.enabled on that release renders a KEDA ScaledObject whose Prometheus trigger reads dbportal_jobs_queued from /api/metrics and keeps keda.queuedPerReplica queued jobs per worker (templates/scaledobject.yaml; refused on another role, together with the HPA, or with the queue outside PostgreSQL; the Deployment then renders no replicas line, so an upgrade does not reset what the scaler chose). config.jobsWorker: "off" on the studio release stops its own loop once workers run apart. Files a worker writes (EXPORT_DIR, BACKUP_DIR) live on a volume both releases mount, or in the bucket. tests/unit/helm-chart-keda.test.ts holds the render and the refusals.
The roles in one release (workers.enabled, agentRole.enabled; since chart 0.1.2): the same release renders a Deployment per role from one pod template (templates/_pod.tpl), so a mount, a probe or a secret reference changed once reaches every role; they share the release’s ConfigMap, Secret, seed and datasource secrets. Each role Deployment carries its own name label (app.kubernetes.io/name: dbportal-worker, dbportal-agent, plus app.kubernetes.io/component), which is what keeps its pods out of the studio’s selectors - a Deployment selector is immutable and overlapping selectors fight - and gives it a Service (<release>-agent), a NetworkPolicy and a scaler of its own: keda.enabled then targets <release>-workers and the HPA keeps the studio. The workers mount the release’s data volume (ReadWriteMany when persistent, refused otherwise); the agent gets an emptyDir, it keeps nothing. The studio writes JOBS_WORKER=off for itself when workers render, unless config.jobsWorker says otherwise. tests/unit/helm-chart-roles.test.ts holds it, and that a default render is byte for byte what it was. A default render writes nothing, so a studio release is byte-for-byte what it was. Run the agent role as its own release - its own Deployment, Service and NetworkPolicy - pointed at the same storage backend (STORAGE_PROVIDER, the seed ConfigMap, the same secrets for the datasources its tokens name), and give only that release’s Service to the agents. tests/unit/helm-chart-role.test.ts holds the env variable to the value and the schema to the two words.
Release Pipeline
Section titled “Release Pipeline”The chart never releases before the image it deploys exists (#161), and the
whole flow is compatible with the repository’s immutable-releases policy
(draft-first, #154/#155/#158). For a product release, docker-build-push.yml
dispatches helm-release.yml on the release tag ref after the image
publish succeeds - the chart is deliberately published from the tag’s
snapshot (the same commit as the image), not from main HEAD; a chart-only
fix merged in between publishes separately via its own charts/** push run.
Push to main (charts/** changed) OR dispatched by docker-build-push │ after a successful image publish ▼┌─────────────────────────────────────────┐│ helm-release.yml ││ ││ Job 0: preflight gates ││ ghcr image <appVersion> published? ││ ├── yes → proceed ││ ├── no + push event → green no-op ││ │ (the release chain re-dispatches ││ │ after the image is published) ││ └── no + dispatch → fail fast ││ ││ chart <version> already released ││ with its .tgz? → skip jobs 2 and 3 ││ ││ Job 1: lint-test ││ ├── ct lint (chart-testing) ││ ├── Kind cluster create ││ └── ct install (real cluster test) ││ ││ Job 2: release-github-pages ││ ├── helm dependency build ││ ├── draft release + .tgz upload, ││ │ publish last (immutable-safe) ││ └── helm repo index --merge → push ││ gh-pages index.yaml ││ ││ Job 3: release-oci ││ ├── helm dependency build ││ ├── helm package ││ └── helm push → ghcr.io/libredb/charts│└─────────────────────────────────────────┘ │ ▼ArtifactHub auto-scan (~30 min)Do not manually dispatch helm-release.yml for a new appVersion before its
image is on GHCR - the gate fails fast by design. A failed image build
dispatches nothing, so the chart correctly stays unpublished. The gate
queries GHCR anonymously and relies on the image package being public.
The second preflight gate protects the published surfaces from re-publication
(#167). Immutable releases freeze the release asset, but the gh-pages index and
the OCI tag are mutable: both publish jobs re-package whatever charts/**
currently holds, so a charts/** merge that changed chart content without
bumping the chart version used to rewrite the released version’s index digest
and its OCI copy while the asset kept the original bytes. When the release for
dbportal-<version> already exists, is published, and carries its
.tgz, jobs 2 and 3 are skipped and the run is a full no-op after lint-test.
A missing release, a leftover draft, or a published release without its asset
all still publish - the last one so the release job’s loud immutability error
(#154) is what the run reports. Publishing chart changes therefore always means
bumping version:; bun run chart:check now refuses the un-bumped state at PR
time, so this gate is the backstop rather than the first line of defence.
The gate has one escape hatch, the force_republish dispatch input, for the
single case a version bump cannot fix: a run whose asset upload succeeded but
whose index or OCI push failed, leaving a released version missing from the
index (this happened to chart 0.1.5 and was hand-patched at the time). Dispatch
helm-release.yml with force_republish=true on the released version’s ref
and only while charts/** is byte-identical to what that version shipped -
otherwise it does exactly the damage the gate exists to prevent. The release
chain’s automated dispatch passes no inputs, so it can never take this path.
Version Management
Section titled “Version Management”Chart.yaml version(e.g.,0.1.4): the chart’s own SemVer. Chart-only fixes bump it alone;bun run chart:bumpbumps its patch when tracking an app release.Chart.yaml appVersion: the app image version this chart deploys. Always equalspackage.jsonversion — enforced in CI.- Guard:
scripts/sync-chart-version.mjsruns asbun run chart:checkinside the requiredLint, Typecheck and Buildcheck (ci.yml), so a PR that bumpspackage.jsoncannot merge untilbun run chart:bumpis run and committed (issue #138). The guard also fails whenappVersionchanges without a chartversionbump (chart-releaser’sskip_existingwould silently publish nothing) or when the new chart version was already released, and it keeps theartifacthub.io/imagestag and the README--versionexample in step. Theartifacthub.io/changesline is written bychart:bumpbut deliberately not checked, so hand-written changelog entries for chart-only releases never trip the guard. - Guard (#167): a PR that changes any packaged file under
charts/dbportal/while leaving the chartversionat an already-released value fails the same check - re-publishing that version would mutate its gh-pages/OCI digest. Fix it by bumpingversion:(and the README--versionexamples) by hand:chart:bumponly moves the chart version whenappVersionis out of sync, so a chart-only content change needs the manual bump. Paths matched by the chart’s.helmignore(ci/) are excluded because they never reach the packaged.tgz, and a version that has not been released yet stays freely editable. - Base comparisons read main’s
Chart.yamlat the merge-base ofHEADandorigin/main, so a release merged tomainafter your branch point cannot false-positive the already-released check on a stale branch (issue #151); in shallow checkouts with no computable merge-base (CI’s depth-1 fetch), theorigin/maintip is used as before, which is effectively exact on PR merge refs. CI setsCHART_SYNC_STRICT=1(ci.yml), which turns the guard’s skip-and-warn paths (origin/mainnot resolvable, origin tags unreachable) into hard failures; unset locally, they stay warnings so offline runs still work.
Versioning policy: version and appVersion stay independent (researched 2026-07)
Section titled “Versioning policy: version and appVersion stay independent (researched 2026-07)”Lockstep (version == appVersion, the cert-manager model) was considered and rejected:
it only works when chart-only releases never happen. This chart still has an active
chart-only backlog, and under lockstep each such fix would either wait for the next
product release or force an artificial one through the full distribution pipeline (npm,
Docker, brew, deb/rpm, snap) — irreversible now that immutable releases are enabled.
SemVer prerelease suffixes are not an escape hatch (they sort below the version they
suffix and Helm hides prereleases by default), and kubernetes-sigs/kueue abandoned
lockstep for the same reasons (kueue#3971). Consumers still see the app version
everywhere it matters: the APP VERSION column in helm search repo, ArtifactHub, and
the artifacthub.io/images annotation. Revisit lockstep if the chart reaches 1.0 and
chart-only churn drops.
Deployment Examples
Section titled “Deployment Examples”Minimal (port-forward)
Section titled “Minimal (port-forward)”helm dependency build charts/dbportalhelm install dbportal charts/dbportal \ --set secrets.jwtSecret=$(openssl rand -base64 32) \ --set secrets.adminPassword=MyAdmin123kubectl port-forward svc/dbportal-dbportal 3000:80Production (Ingress + PostgreSQL + HPA)
Section titled “Production (Ingress + PostgreSQL + HPA)”helm install libredb libredb/dbportal \ --set secrets.jwtSecret=$(openssl rand -base64 32) \ --set secrets.adminPassword=StrongPass123 \ --set postgresql.enabled=true \ --set postgresql.auth.password=pg-secret \ --set ingress.enabled=true \ --set ingress.className=nginx \ --set "ingress.hosts[0].host=libredb.example.com" \ --set "ingress.hosts[0].paths[0].path=/" \ --set "ingress.hosts[0].paths[0].pathType=Prefix" \ --set "ingress.tls[0].secretName=libredb-tls" \ --set "ingress.tls[0].hosts[0]=libredb.example.com" \ --set autoscaling.enabled=true \ --set podDisruptionBudget.enabled=trueGateway API instead of an Ingress
Section titled “Gateway API instead of an Ingress”helm install libredb libredb/dbportal \ --set secrets.jwtSecret=$(openssl rand -base64 32) \ --set secrets.adminPassword="$ADMIN_PASSWORD" \ --set route.main.enabled=true \ --set "route.main.parentRefs[0].name=traefik-gateway" \ --set "route.main.parentRefs[0].namespace=traefik" \ --set "route.main.parentRefs[0].sectionName=websecure" \ --set "route.main.hostnames[0]=libredb.example.com"route is a map of route names, so several routes can attach the same Service to different
Gateways or listeners; main renders as <fullname>, any other name as <fullname>-<name>.
Two constraints are enforced rather than documented:
- An enabled route must set
parentRefsor the render fails, naming the route at fault. The chart cannot default it - the Gateway to attach to is specific to each cluster’s Gateway API install - and an HTTPRoute attached to no Gateway is accepted by the API server while routing nothing, so the failure replaces an install that succeeds with the app unreachable. kindaccepts onlyHTTPRoute(schema-enforced): only an HTTPRoute-shaped body is rendered, so any other kind would emit a manifest the API server rejects.
route.labels and route.annotations are shared across every enabled route, which makes
labels and annotations reserved key names directly under route rather than route names. A
per-route entry wins on a key collision; the chart’s own labels win over both.
External Secrets (Vault / ESO)
Section titled “External Secrets (Vault / ESO)”helm install libredb libredb/dbportal \ --set secrets.existingSecret=my-vault-secretKnown Limitations
Section titled “Known Limitations”- SQLite + Multi-Replica: SQLite is single-writer.
storageProvider=sqlitewithreplicaCount > 1will cause write conflicts. Usepostgresfor multi-replica. With SQLite storageautoscaling.enabledis ignored: the HPA is not rendered (NOTES.txt warns) and the deployment falls back toreplicaCount. - ISR Cache: Next.js ISR cache is per-pod (emptyDir). Session-based app, so no impact.
- Chart appVersion: Not auto-bumped - a version-bump PR must run
bun run chart:bump; the CI sync guard blocks the merge untilappVersionequalspackage.json(see Version Management above).
Rate limiting, the Origin check, and the CSP escape hatch
Section titled “Rate limiting, the Origin check, and the CSP escape hatch”Three Phase 1 security controls change what a multi-replica or reverse-proxied deployment has to
configure, and all three are documented with copy-paste values in
charts/dbportal/README.md.
The short version:
- The built-in rate limiter is per process. The chart’s default
replicaCount: 1makes that the whole deployment; anything above one replica must enforce the budgets at the ingress instead. This is a deliberate design decision, not a gap: the existing storage abstraction is a per-user blob store whose read-modify-write cycle cannot hold counters, and an ingress already has a rate limiter that works across replicas. - dbportal refuses state-changing requests whose
Originhost does not match its own. SetALLOWED_ORIGINS(via the chart’sextraEnv) to your public origin whenever the ingress rewritesHostwithout settingx-forwarded-host, or every action including login returns 403. - dbportal’s
Content-Security-Policyis enforced, not report-only. If an upgrade breaks a resource served from a non-default origin, setCSP_REPORT_ONLY=true(also viaextraEnv) to downgrade it without rebuilding the image while you identify the violated directive.
Probes
Section titled “Probes”The chart’s startupProbe and livenessProbe call /api/health/live (the process answers)
and its readinessProbe calls /api/health/ready, which is 503 while the server store or
Vault - each only when configured - does not answer (docs/CONTEXT.md §4.13).
Metrics
Section titled “Metrics”Set METRICS_TOKEN in the chart’s secret and point Prometheus at GET /api/metrics with
authorization: Bearer <token> (a bearer_token_file in the scrape config, or a
ServiceMonitor with authorization.credentials). podAnnotations in values.yaml takes
the usual prometheus.io/scrape: "true", prometheus.io/path: /api/metrics and
prometheus.io/port when the cluster discovers targets by annotation. Series and labels:
docs/API_DOCS.md.