Skip to main content
This page prepares the cluster: everything here is kubectl and plain manifests, no Helm. The application itself is installed afterwards, either by the Helm chart or with kubectl alone (Install without Helm), once the cluster passes the checks below. The files cited below live in the hosting repository. Run the commands from a clone of it:
Every step was run on a real cluster (k0s v1.36.3, three nodes) before being written down. The commands are the ones that worked; the Traps section at the end lists what went wrong on the way, with the symptom that gives each one away.

What you need before starting

1. Reach the cluster

Every node must be Ready. kubectl version should show a client at most one minor version away from the server; further apart, some commands fail in puzzling ways.

2. Storage

Check first — this is the single most common reason nothing starts.
No resources found means no volume can ever be created: PostgreSQL and the data volume would stay Pending forever, and the pods with them. Managed clusters differ wildly here; some ship a class, some only offer object storage (which does not suit PostgreSQL). If the list is empty, local-path-provisioner gives you node-local volumes in one manifest:
Make it the default, otherwise claims that name no class — the chart’s — stay unbound:
You want to read local-path (default).
A node-local volume lives on one node’s disk. It is enough for a single PostgreSQL replica and for ReadWriteOnce data, and it is not highly available: losing the node loses the volume. Back the database up, or use your provider’s block CSI driver when it has one.
Prove it works before going further — and mind that the check needs a pod, not just a claim (Trap 1):
Bound, and ok in the logs. Anything else, stop here: the rest cannot work.

3. Ingress controller

Needed only for a public address; skip it if you will use port-forward. Install Traefik or ingress-nginx — most managed Kubernetes offerings have both as a one-click add-on, which is the simplest path since the provider then also wires the load balancer. Then:
Note the class name (traefik, nginx…) and the EXTERNAL-IP of the controller’s service: that address is what your DNS record will point to.

4. cert-manager and an issuer

Installing cert-manager is not enough — it issues nothing until an issuer exists.
Then create the issuers. Two of them: production, and staging — which you use first, because Let’s Encrypt blocks an account for a week after five failures in an hour, and the first attempt rarely succeeds.
The production issuer is the same with https://acme-v02.api.letsencrypt.org/directory and its own key secret name. Both are ready-made in the hosting repository under k8s/cert-manager/cluster-issuer-letsencrypt.yaml. Replace the email: of both issuers with a mailbox you read before applying it: the file ships with a maintainer’s address.
READY: True on both means cert-manager registered with the ACME servers.
Use ingressClassName, not class. The latter goes through the legacy kubernetes.io/ingress.class annotation and is only recommended for ingress-gce. With it, the challenge Ingress is created without a recognised class, the controller ignores it, and the certificate sits at Ready: False while nothing reports an error.

5. HTTPS redirect (Traefik)

A certificate does not close the clear-text entrance: once the site is public, http:// keeps serving it unless something redirects. With Traefik, that something is a Middleware, cluster-side, ready-made in the hosting repository:
The application’s Ingress opts into it with one annotation — see Going public. With ingress-nginx there is nothing to apply: ssl-redirect acts as soon as the Ingress has a TLS section.

The manifests in k8s/

The hosting repository’s k8s/ folder holds four sets of manifests. Those under k8s/apowerb/ are rendered from the chart, so both install paths deploy the same stack.

Install without Helm

Expected: six pods Running and apowerb-th2etl-seed Completed. Back up k8s/secret.yaml and never regenerate it: a new ENCRYPT_KEY makes every connected integration unreadable. Reach the interface with kubectl -n apowerb port-forward svc/apowerb-frontend 13000:3000. Public address. Copy apowerb-public/, put your domain in, apply the copy:
It uses the staging issuer. Once the certificate is READY, switch the cert-manager.io/cluster-issuer annotation to letsencrypt-prod and delete the apowerb-tls secret. Upgrade. Keep the same secret.yaml. The seed Job is immutable: delete it first.
With a public address, skip k8s/apowerb/backend-deployment.yaml and apply my-public/ instead: the plain backend has no public URL, and CORS and OAuth callbacks break until the public copy is back. Remove. kubectl delete -f k8s/apowerb/ deletes the namespace and both volumes, the database included. Nothing protects them, unlike the chart.

Next: install with Helm

Helm chart

Secrets, install, checks, public address, first administrator, upgrades.

Traps

Trap 1: a PVC that stays Pending on purpose

Symptom. You create a claim to test the storage; it stays Pending. It looks like the storage is broken. Cause. Storage classes with volumeBindingMode: WaitForFirstConsumer — local-path among them — only create the volume when a pod needs it. This is by design: the volume is placed on the node the pod is scheduled to. Remedy. Test with a claim and a pod, as in step 2. kubectl get storageclass shows the mode.

Trap 2: no storage class at all

Symptom. Same Pending, but with a pod waiting too, and kubectl describe pvc says: no persistent volumes available for this claim and no storage class is set. Cause. The cluster has no default storage class — common on lean managed clusters. Remedy. Step 2. Check kubectl get storageclass before installing, not after wondering why nothing starts.

Trap 3: a certificate stuck at Ready: False

Symptom. kubectl get certificate shows READY: False and stays there, without an obvious error. Causes, in the order worth checking.
  1. The DNS name does not resolve to the ingress load balancer yet, or port 80 is closed — the HTTP-01 challenge needs both.
  2. The issuer uses class instead of ingressClassName (see step 4).
  3. The Let’s Encrypt rate limit has been hit — five failures in an hour blocks the account for a week. This is what the staging issuer is for.
The Challenge object names the real reason.