Before you start
The cluster must be ready first — a default storage class, an ingress controller and a certificate issuer. The Kubernetes page prepares it withkubectl and plain manifests; come back here once its checks
pass. You also need helm 3.8 or later, for OCI registry support.
1. Secrets
Five values. Four of them are mandatory — the chart refuses to install withoutpostgres.password and backend.env.encryptKey (no default, on
purpose) and the two th2pulse tokens, because the backend and th2pulse do not
start without them.
2. Install
Start without an Ingress: fewer moving parts, and a public name is not needed to know whether the stack runs.--version: Helm installs the newest chart published on Docker Hub, so this
page never lags behind a release. helm -n apowerb list shows the version you
got; pass it as --version to any later command that must stay on it.
Pulling the images takes a few minutes on a fresh cluster — the backend image
alone is around 250 MB.
From 0.4.22 the published chart is signed with cosign, keyless, by the
publishing workflow. To check that a chart comes from apowerb/apowerb-hosting:
The chart moved to its own Docker Hub repository. It used to be pushed to
apowerb/apowerb, alongside the backend images — a repository cannot be
subdivided there, and helm push names it after the chart. Versions 0.2.0
and 0.4.0 stay where they were published; nothing was deleted. Anythingfrom 0.4.1 on lives at apowerb/apowerb-chart.First real install: OVH Managed Kubernetes (3 nodes, Cinder ext4 volumes) on
29/09/26. It found two blockers — PostgreSQL failing on the volume’s
lost+found, and upgrades stuck on Multi-Attach — both fixed in 0.4.21.3. Check what actually runs
- six pods
Running: backend, frontend, postgres, th2etl, th2pulse, otel-collector; apowerb-th2etl-seedCompleted— it is aJob, so0/1 Completedis the success state, not a failure;- two PVCs
Bound: the data volume and the database’s.
th2etl and th2pulse at first start are expected —
Trap 1.
Then reach the services:
401 on /api/config/setup is the right answer: authentication comes before
the checklist. A 404 there would mean the backend image predates it.
These checks go through a port-forward on purpose. The backend is never
published directly: once the site is public, the frontend proxies /api/* to
it, and nothing else reaches it — /health included
(Trap 8).
4. Going public
Point an A record at theEXTERNAL-IP of the ingress controller
(Kubernetes, step 3), wait for it to
resolve, then upgrade with the Ingress — with the staging issuer first
(Kubernetes, step 4):
tlsEnabled writes the Ingress tls section; the secret name is derived
(apowerb-tls) unless ingress.tlsSecretName names one, which is what
cert-manager fills from the annotation above.
Nothing else to set for the public address: with an Ingress enabled,
publicUrls.appPublicUrl and publicUrls.publicBaseUrl are derived from
ingress.host and ingress.tlsEnabled. That one value feeds nine settings in
the core — the CORS origins and the GitHub, Google and Outlook Mail callbacks —
so setting them by hand is how they end up disagreeing.
Watch the certificate being issued:
READY: False? See
Kubernetes, Trap 3.
Once READY: True with staging, swap the annotation to letsencrypt-prod,
delete the staging secret so a real certificate is requested, and check the
site in a browser:
Close port 80
The certificate does not close the clear-text entrance — Trap 7. With Traefik, apply the Middleware (Kubernetes, step 5), then add one annotation:ssl-redirect applies as soon as a
TLS section exists.
Without an Ingress, nothing is exposed outside the cluster: since 0.4.0 the
frontend Service is a ClusterIP — no provider load balancer, and no public IP
billed for it. Set service.frontend.type=LoadBalancer, or reach it with
kubectl port-forward. The install notes say which case you are in.
5. The first administrator
A fresh database has no administrator, and no route creates one. Two settings do:superadmin.email alone: the person signs up, and the next backend start
promotes them. Then sign in and open
Admin → Configuration: the screen lists every capability this installation
has, and the sidebar badge counts what is still missing. That is the shortest
path to knowing what is left to configure — see
What needs configuring.
6. Upgrading and removing
--version this moves to the newest published chart; add
--version <x.y.z> to choose one.
Upgrading an existing install to 0.4.21: check where the database lives
first. Since 0.4.21, PostgreSQL writes to /var/lib/postgresql/data/pgdata,
a subdirectory of its volume, instead of the volume’s root: on ext4 volumes
(OVH Cinder and most cloud block disks) the root holds a lost+found that
made initdb fail. An install that already ran — so on a volume without
lost+found (k3s local-path, kind, NFS) — keeps its database at the root.
After the upgrade PostgreSQL would find pgdata/ empty and create a fresh
database: the old data stays on the volume, but out of sight.
PG_VERSION shows at the root, back up with pg_dumpall before upgrading,
then restore into the new database. A fresh install is not affected.
From 0.4.21 the backend also uses strategy: Recreate (its volume is
ReadWriteOnce): each upgrade stops the old pod before starting the new one,
so expect a few seconds of downtime.
Uninstalling leaves the data volume behind, on purpose — it carries
helm.sh/resource-policy: keep:
Storage
persistence.enabled claims one volume, mounts it on persistence.mountPath
(/data) and points RUNTIME_ROOT at it. Everything the backend writes lives
under it: bi_store, uploads, artifacts_store, agents_pool.
- Without it, an imported CSV is gone at the next restart of the backend pod,
and the dashboard reading it dies with it.
agents_poolis the exception — it is regenerated from the database. - The claim is
ReadWriteOnce, so the backend stays at one replica unless the storage class offersReadWriteMany. - It carries
helm.sh/resource-policy: keep:helm uninstallleaves the files behind rather than deleting them with the release.
The seed is a Job
th2etl-seed posts the default pipelines and exits. It runs as a
post-install,post-upgrade hook and retries while th2etl migrates the
database. As a Deployment, Kubernetes would restart a container that exited 0 —
a crash loop on a success.
A smaller stack
Nothing beyond the product itself is mandatory. The./helm/apowerb-chart
paths below are relative to a clone of
apowerb-hosting:
From a checkout
Installing from a clone of the hosting repository takes the same five values. The chart refuses to install without four of them:postgres.password
and backend.env.encryptKey, which have no default on purpose — a secret
shipped in a public repository is not one — and the two th2pulse tokens. The
backend and th2pulse do not start without them. Failing at install time is the
honest moment to say it.
Checks that need no cluster
Traps
Trap 1: th2etl and th2pulse restart twice
Symptom. Right after install, both showRESTARTS 2, with
Connection refused on port 5432 in the previous container’s logs.
Cause. They open the database before PostgreSQL accepts connections, exit,
and are restarted. Up to chart 0.4.20, nothing waited on the database.
Remedy. Chart 0.4.21 adds a wait-for-postgres init container to the
backend, th2etl and th2pulse, which removes these restarts. On an older chart,
none needed: Kubernetes recovers, and both reach Running within
a couple of minutes. Worry only if the count keeps climbing — then read
kubectl -n apowerb logs <pod> --previous, which will name a different cause.
Trap 2: an upgrade that empties the Secret
Symptom. After ahelm upgrade, integrations no longer connect and agents
lose their stored credentials.
Cause. helm upgrade without --values values-secrets.yaml re-renders the
Secret from the chart’s defaults — empty strings. ENCRYPT_KEY is what the
integrations’ OAuth tokens were encrypted with; change it and they become
unreadable.
Remedy. Always pass the same values file. Back it up with your other
secrets — it is not recoverable from the cluster once overwritten.
Trap 3: a Service stuck in Pending
Symptom.kubectl get svc shows EXTERNAL-IP: <pending> forever.
Cause. service.frontend.type=LoadBalancer on a cluster whose provider
gives no load balancer.
Remedy. The chart defaults to ClusterIP for exactly this reason. Reach
the interface through the Ingress, or kubectl port-forward.
Trap 4: pulling the chart from the old address
Symptom.helm install succeeds and the stack comes up — without th2etl,
th2pulse, the collector, or the data volume.
Cause. The chart used to be published to apowerb/apowerb, next to the
backend images, and those old versions are still pullable. A stale command
line therefore works, which is what makes it dangerous: it silently installs
the stack as it was several versions ago.
Remedy. Pull from apowerb/apowerb-chart, and check what you got:
name must read apowerb-chart.
Trap 5: removing a secret from the values does not remove it from the cluster
Symptom. You delete a credential fromvalues-secrets.yaml, run helm upgrade, and the
Secret still holds it.
Cause. The chart writes a credential only when it is non-empty
({{- if .Values.defaultLlm.apiKey }}). Dropping it from the values makes the template stop
rendering the key — and Helm does not delete a key that is simply absent from the new render.
Measured on 09/09/26: the shared-model API key was still in apowerb-secrets, 53 characters
long, after an upgrade that no longer declared it.
This is the mirror image of Trap 2, and the more
dangerous of the two: there you lose a secret you meant to keep; here you keep a secret you
meant to destroy.
Remedy. Delete the key explicitly, then restart so the pods stop carrying it:
optional: true, so nothing breaks once it is
gone — but check that before deleting a key from any chart that does not.
Trap 6: the model provider refuses the cluster’s outbound address
Symptom. The interface reports the shared model as configured, and every chat fails. The backend log names the provider, not the network:AS214504 Hostman LTD versus AS210976 Timeweb), both
geolocated in Germany.
Diagnosis. Ask the pod, not your laptop — only its address matters:
401 means reachable and merely unauthenticated, a 403
or a 400 mentioning location means blocked.
Do not conclude “the key is invalid” from a 400 on Google’s API before forcing IPv4: the same
key can answer 200 over IPv4 and 400 over IPv6, and the message is identical to the geo-block.
Check whether the pod even has IPv6 (
Network is unreachable settles it).Trap 7: HTTPS served, but HTTP still answers
Symptom.https:// works with a valid certificate, and http:// returns
200 with the same page instead of a redirect.
Cause. ingress.tlsEnabled adds the TLS section; it does not close port
80. The controller keeps serving the site in clear text.
Remedy. The Middleware above, or nginx’s ssl-redirect. Check with
curl -sI http://<host>/ — you want 308, not 200. This matters more than
it looks: a visitor typing the address without https:// submits their
credentials unencrypted.
Trap 8: /health answers 200 in the cluster and 404 on the domain
Symptom. An external uptime monitor pointed athttps://<host>/health
reports the service down, while every pod is Running and the site loads in a
browser.
Cause. The Ingress carries a single rule — / to the frontend. The
backend is not published; the frontend proxies /api/* to it server-side.
So /api/config reaches the backend through the domain, while /health sits
outside /api and is answered by the SPA, which returns 404 for a route it
does not know.
Remedy. Nothing to repair — /health is the kubelet’s liveness probe, and
it answers where the probe runs. Verify it as in step 3,
through a port-forward. For external monitoring, watch the site root (200) or
/api/config, which is public and served by the backend:
