Kubernetes Helm chart for the Forail automation platform.
Deploys the full stack: PostgreSQL, Redis, OPA, OpenTelemetry Collector,
Forail backend (web + task + init Job), Forail frontend, and an Ingress
that routes / to the SPA and /api, /admin, /static, /sso,
/websocket to the backend.
Images are published to public ghcr.io/forail-platform/* — no pull secret needed.
# (Optional) Pre-create a TLS secret for the Ingress:
kubectl create ns forail
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout tls.key -out tls.crt -subj '/CN=forail.local' \
-addext 'subjectAltName=DNS:forail.local,DNS:*.forail.local'
kubectl -n forail create secret tls forail-tls --cert=tls.crt --key=tls.key
# Install (admin password is required — install fails without it):
helm install forail . -n forail --create-namespace -f values.yaml \
--set secrets.forailAdminPassword="$(openssl rand -base64 24)"If you mirror the images to a private registry, override images.*.repository and set
imagePullSecrets in your values file.
This chart ships no working secret defaults:
-
secrets.postgresPassword,secrets.forailSecretKeyandsecrets.forailBroadcastWebsocketSecretare auto-generated on first install and reused across upgrades — leave them empty unless you want to pin explicit values. -
secrets.forailAdminPasswordis required;helm installfails if unset. -
forail-taskruns non-privileged by default. The podman-in-pod job execution path needs privileges — enable it explicitly and, ideally, isolate such workers onto dedicated tainted nodes:--set task.privileged=true --set task.hostCgroup=true
-
Session cookies are
Secureby default (forail.cookieSecure: "true") andforail.allowedHostsdefaults to the ingress host plus127.0.0.1,localhost(not"*"). Keep the loopback entries — the liveness/readiness probes call the API on127.0.0.1, and Django answers400 DisallowedHostwithout them, which crash-loopsforail-web. -
networkPolicy.enabled(default false) adds a default-deny ingress policy with scoped allows so Postgres/Redis aren't reachable cluster-wide. Enable it on a policy-enforcing CNI (Calico/Cilium) — k3s' default flannel does not enforce it. -
podSecurityContextand per-workloadsecurityContext.{web,frontend,assistant}are available for pod hardening (empty by default; validate per image — the frontend binds:80and needsNET_BIND_SERVICEor a non-root port). -
Job execution defaults to Kubernetes container groups (
forail.node.typeis nowcontrol, washybrid). Jobs run as separate pods rather than through podman inside the task pod, so no workload needs privileges. An install that wants the podman path must now say so and enable it:hybridwithouttask.privileged=truefails the render instead of producing an install where every job staysPending. -
Redis now requires a password. Generated on first install, reused on upgrade. An existing install picks it up on the next
helm upgrade; nothing outside the chart should be talking to that Service, but anything that is will need the credential fromforail-secrets. -
forail.tenancyEnabled=truenow requiresforail.tenancy.rls=true. The previous single switch turned on the tenancy features with no row-level security behind them. -
Every workload now drops all capabilities and refuses privilege escalation by default, with the frontend keeping
NET_BIND_SERVICE. Override per workload undersecurityContext.*; set a key to{}to opt out. -
assistant.storage.sizedropped 20Gi → 5Gi when the model server moved to its own claim. PVCs cannot shrink, so an existing install with the assistant enabled keeps its 20Gi claim and the upgrade fails on the immutable field — deleteforail-assistant-data(the vector index rebuilds itself) or pin--set assistant.storage.size=20Gi. Fresh installs are unaffected.
Automation jobs run as pods in a Kubernetes container group: forail-task
asks receptor to create, watch and delete one pod per job. Three pieces must be
in place, all shipped by the chart:
- Pod RBAC —
serviceAccount.createandrbac.create(both default true) render aforailServiceAccount and a namespacedforail-job-runnerRole/RoleBindingcoveringpodsandpods/log|attach|exec. Web, task and the init Job run under that account. Turn them off only when you run jobs on an external execution node and want no in-cluster job pods; otherwise every launch fails withpods is forbidden ... cannot list resource "pods"and the job stays pending. - Namespace — the
MY_POD_NAMESPACEdownward-API env makes job pods land in the release namespace, which is exactly where the Role grants access. - Receptor worktype —
files/receptor/receptor.confregisterskubernetes-incluster-auth(authmethod: incluster). Without it launches fail at 0s withunknown work type kubernetes-incluster-auth.
This is the default (forail.node.type=control) and needs no privileges
anywhere.
The alternative, forail.node.type=hybrid, runs jobs through podman inside the
task pod and requires --set task.privileged=true --set task.hostCgroup=true.
Without both, podman fails on the overlay mount and every job stays Pending.
Those were previously the defaults in the wrong combination — hybrid with
privileged: false — which is the one pairing that cannot run a job at all. The
render now refuses it and says which of the two configurations to pick, rather
than installing something whose main function is broken.
Off by default (assistant.enabled=false). When enabled it renders two
Deployments, not one:
| Workload | Contains | Claim |
|---|---|---|
forail-assistant |
FastAPI + embedded ChromaDB | forail-assistant-data, 5Gi |
forail-assistant-ollama |
the model server, images.assistantOllama |
forail-assistant-ollama-models, 20Gi |
They are split so that only the model server needs a GPU and a large volume; the API stays schedulable on any node. Ollama has no authentication, so its Service is ClusterIP and only the API talks to it.
# CPU (default)
helm upgrade forail . -n forail --set assistant.enabled=true
# GPU — requires a node advertising nvidia.com/gpu and the NVIDIA device plugin
helm upgrade forail . -n forail \
--set assistant.enabled=true \
--set assistant.ollama.gpu.enabled=trueassistant.ollama.gpu.enabled requests nvidia.com/gpu, which pins that pod
to a node advertising the device — leave it off until the cluster has one, or
the pod stays Pending. images.assistantOllama.tag is pinned deliberately:
the assistant image used to carry a binary copied out of ollama/ollama:latest,
and an upstream layout change broke inference without a line of our code
changing. Bump it on purpose.
forail-helm/
├── Chart.yaml
├── values.yaml
├── Makefile # lint / template / package / sync-from-deploy
├── templates/ # 16 templates (postgres, redis, opa, otel,
│ forail-web/task/frontend/init, ingress, rbac, ...)
└── files/ # static configs consumed via Helm Files.Get
├── settings/ # Forail backend settings.py modules
├── scripts/ # init.sh, healthcheck-*.sh, backup/restore
├── nginx/ # internal nginx for forail-web
├── otel/ # OpenTelemetry Collector config
└── receptor/ # Receptor mesh config
The static config files under files/ are duplicated from
forail-deploy (the docker-compose deployment artifact repo). When the
upstream files change, sync them:
make sync-from-deploy # copies from ../forail-deploy/{settings,scripts,...}
git diff # review
git add files/ && git commit -m "files: sync from forail-deploy"The chart and forail-deploy must therefore be cloned side-by-side at the
same parent directory for make sync-from-deploy to work.
- forail-deploy — docker-compose deployment + single-VM Vagrantfile
- forail-operator — k8s operator that reconciles JobTemplate / Inventory / Credential / Schedule CRDs with the Forail REST API
- forail-dev-cluster — 4-VM Vagrant test cluster for chart + operator integration testing