Skip to content

Kubernetes Deployment

Kamerplanter is deployed via a single Helm chart that includes all components: backend, frontend, ArangoDB, and Valkey. The container images and the Helm chart are hosted on the GitHub Container Registry (ghcr.io).


Prerequisites

What Minimum
Kubernetes cluster Version 1.28+
Helm Version 3.12+
kubectl Configured and connected to the cluster
Ingress controller Traefik, nginx-ingress, or similar
Storage StorageClass with ReadWriteOnce support (for ArangoDB + Valkey)

Overview: What gets deployed?

flowchart TB
    subgraph "Kubernetes Cluster"
        direction TB
        ING["Ingress<br/>(Traefik / nginx)"]

        subgraph "Kamerplanter Namespace"
            FE["Frontend<br/>(Deployment, 2 Replicas)"]
            BE["Backend<br/>(Deployment, 2 Replicas)"]
            DB["ArangoDB<br/>(StatefulSet, 1 Replica)"]
            VK["Valkey<br/>(Deployment, 1 Replica)"]
        end
    end

    ING -->|"/ and /api/*"| FE
    BE --> DB
    BE --> VK
    FE -->|"proxy /api"| BE

    style ING fill:#FF9800,color:#fff
    style FE fill:#66BB6A,color:#fff
    style BE fill:#43A047,color:#fff
    style DB fill:#2E7D32,color:#fff
    style VK fill:#1B5E20,color:#fff
Component Type Replicas Description
Backend Deployment 2 FastAPI application (API + Celery worker)
Frontend Deployment 2 React app behind nginx, proxies /api to the backend
ArangoDB StatefulSet 1 Document/graph database with Persistent Volume (5 Gi)
Valkey Deployment (Recreate) 1 Redis-compatible cache + Celery broker, own volume (1 Gi, append-only file)

Installation

1. Add the Helm repository

The Kamerplanter Helm chart is published as an OCI artifact on the GitHub Container Registry:

# OCI registries don't need helm repo add —
# pulling works directly via the OCI URL
helm pull oci://ghcr.io/nolte/charts/kamerplanter --version 0.2.0

--version must name a published version

0.2.0 is the most recently published chart version. The same OCI address also carries a development channel: the develop tree holds a pre-release with the -dev suffix (0.2.1-dev at the moment), and that tag is overwritten by every merge into develop that touches helm/. Only the dev identifier is enforced, not the number in front of it: the -dev version is not guaranteed to lead the published line. Do not pull anything from it that is meant to run — the bytes change underneath you while the version string stays the same. Details: CI/CD — Two channels.

The 0.2.0 chart tag was overwritten from develop

charts/kamerplanter:0.2.0 no longer carries what release v0.2.0 published. Measured on the OCI manifest: it is annotated org.opencontainers.image.created: 2026-08-18T14:09:14Z, while v0.2.0 was published on 2026-08-13 at 18:09:49 UTC — a helm/ merge into develop re-pushed the tag five days later. That is exactly what the two channel checks have prevented since. The tag is deliberately not repaired: overwriting it would be a second push under the same version reference.

For the commands on this page that means: --version 0.2.0 still resolves, but delivers a develop build rather than the release build. 0.1.0 is the newest chart tag whose manifest still carries its release timestamp (created on 2026-08-06 at 13:38:04 UTC, 15 seconds after v0.1.0 was published), and v0.2.1 — still a draft as this is written — is the first release created under the protection of both checks. Whichever you pick, a deployment only becomes reproducible once you pin the manifest digest you audited.

Authentication to the GitHub registry

If the registry is private, you need to log in first:

echo $GITHUB_TOKEN | helm registry login ghcr.io --username $GITHUB_USER --password-stdin

2. Create the mandatory secrets

No backend pod starts without this secret

Before installing the chart, the Kubernetes secret kamerplanter-secrets must exist. The backend container reads ARANGODB_PASSWORD, ARANGO_ROOT_PASSWORD, JWT_SECRET_KEY, FERNET_KEY, ERASURE_TOMBSTONE_SALT and LOG_PSEUDONYM_SALT exclusively via envFrom from this secret — not from values.yaml. If any of the last four values is missing (or ARANGODB_PASSWORD is left at the literal rootpassword), the backend start-up aborts with SystemExit as soon as DEBUG=false is set (fail-fast gate, src/backend/app/main.py). The Celery worker pod draws from the same secret and checks LOG_PSEUDONYM_SALT at start-up just as strictly; it aborts with SystemExit if the value is missing or shorter than 32 characters.

kubectl create namespace kamerplanter

kubectl create secret generic kamerplanter-secrets \
  --namespace kamerplanter \
  --from-literal=ARANGODB_PASSWORD="your-secure-password" \
  --from-literal=ARANGO_ROOT_PASSWORD="your-secure-password" \
  --from-literal=JWT_SECRET_KEY="$(openssl rand -hex 32)" \
  --from-literal=FERNET_KEY="$(python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')" \
  --from-literal=ERASURE_TOMBSTONE_SALT="$(openssl rand -hex 32)" \
  --from-literal=LOG_PSEUDONYM_SALT="$(openssl rand -hex 32)"

If you switch TimescaleDB on (controllers.timescaledb.enabled: true), the Secret also needs TIMESCALEDB_PASSWORD — see TimescaleDB.

Database credentials

The application does not connect to ArangoDB as root but with the account kamerplanter (database.arangodb.appUsername). A helper container app-user in the ArangoDB pod creates or updates it on every start: password from ARANGODB_PASSWORD, read and write access to the application database only, no access to _system. The ArangoDB container itself receives only ARANGO_ROOT_PASSWORD from the Secret.

  • ARANGODB_PASSWORD is the application account's password. Choose a value of its own, not the root password.
  • ARANGO_ROOT_PASSWORD is needed by the ArangoDB pod only. Put it into a Secret of its own; then the backend no longer sees the root password (it reads all of kamerplanter-secrets via envFrom):
kubectl create secret generic kamerplanter-arangodb-root \
  --namespace kamerplanter \
  --from-literal=ARANGO_ROOT_PASSWORD="$(openssl rand -hex 32)"
values-production.yaml (excerpt)
database:
  arangodb:
    rootPasswordSecret: kamerplanter-arangodb-root

Existing installation: separate root access in three steps

ArangoDB reads ARANGO_ROOT_PASSWORD only on the very first start with an empty volume. For an existing installation therefore:

  1. Upgrade: after the upgrade, app-user creates the account kamerplanter with the current ARANGODB_PASSWORD, and the backend, worker and beat connect with it. If your values set ARANGODB_USERNAME: root or an envFrom on the ArangoDB container (earlier version of this guide), remove both — otherwise root access stays. Back to the old behaviour: database.arangodb.appUsername: root.
  2. Own application password: set a new value for ARANGODB_PASSWORD in kamerplanter-secrets, restart the ArangoDB pod first (app-user applies the password) and then the backend, worker and beat (kubectl rollout restart).
  3. Move the root password out: create kamerplanter-arangodb-root with the current root password, set database.arangodb.rootPasswordSecret, and only then remove ARANGO_ROOT_PASSWORD from kamerplanter-secrets. To change the root password itself, change it in ArangoDB first (arangosh) and then in the Secret.

Full overview of every mandatory secret per enabled feature (e.g. INTERNAL_SERVICE_TOKEN once the AI assistant or image recognition is active): Configuration Matrix — Mandatory secrets.

3. Create a values file

Create a values-production.yaml with your customizations:

values-production.yaml
controllers:
  backend:
    replicas: 2     # (1)!
    containers:
      main:
        envFrom:
          - secret: kamerplanter-secrets    # (2)!
        env:
          ARANGODB_HOST: kamerplanter-arangodb
          ARANGODB_PORT: "8529"
          ARANGODB_DATABASE: kamerplanter
          REDIS_URL: redis://kamerplanter-valkey:6379/0
          CORS_ORIGINS: '["https://plants.example.com"]'
          DEBUG: "false"
          KAMERPLANTER_MODE: full    # (3)!

  frontend:
    replicas: 2

  arangodb:
    statefulset:
      volumeClaimTemplates:
        - name: data
          accessMode: ReadWriteOnce
          size: 10Gi    # (5)!
          advancedMounts:    # (4)!
            main:
              - path: /var/lib/arangodb3

# Attachments in S3 — mandatory with more than one backend replica
storage:
  backend: s3
  s3:
    endpointUrl: https://s3.eu-central-1.amazonaws.com
    region: eu-central-1
    bucket: kamerplanter-prod
    credentialsRef:
      secretName: storage-s3-credentials

ingress:
  main:
    enabled: true
    hosts:
      - host: plants.example.com    # (6)!
        paths:
          - path: /              # /api too: the frontend nginx proxies it to the backend
            pathType: Prefix
            service:
              identifier: frontend
  1. Two replicas for rolling updates without downtime. Only with S3 (storage.backend: s3, see below) or a ReadWriteMany volume: on the default ReadWriteOnce volume the chart refuses more than one backend replica, because a second pod on another node never starts (Storage configuration).
  2. Pulls ARANGODB_PASSWORD, JWT_SECRET_KEY, FERNET_KEY, ERASURE_TOMBSTONE_SALT and LOG_PSEUDONYM_SALT from the secret created in the previous step — no plain-text passwords in values.yaml.
  3. light = without login/tenant system, single user. full (the chart default) = with JWT auth and tenant management. Details: Deployment Profiles.
  4. Only the ArangoDB main container mounts the data volume, not the app-user helper container. The chart sets up the root password and the application account itself (see Database credentials); set no envFrom and no ARANGODB_USERNAME here.
  5. Adjust the size to your needs. The chart default for the ArangoDB PVC is 5Gi.
  6. Your desired hostname. The Ingress controller must be configured for it.

Never inline passwords in the values file

values-production.yaml references secrets exclusively via envFrom/secretKeyRef — never as a plain-text value under env:. For GitOps workflows, use a secret management tool like Sealed Secrets or External Secrets Operator instead of a manual kubectl create secret (see ArgoCD — Declarative secret management). For the External Secrets Operator the chart ships a switchable ExternalSecret: Secrets from a secret store.

4. Install the Helm chart

helm install kamerplanter \
  oci://ghcr.io/nolte/charts/kamerplanter \
  --version 0.2.0 \
  --namespace kamerplanter \
  --create-namespace \
  --values values-production.yaml

0.2.0 is the same overwritten chart version here

The tag resolves, but delivers a develop build rather than what release v0.2.0 published. Why, and what to pin instead: step 1.

5. Verify the deployment

# Check pod status
kubectl get pods -n kamerplanter

# Wait for healthy pods
kubectl wait --for=condition=ready pod \
  --all -n kamerplanter --timeout=120s

Expected output:

NAME                                      READY   STATUS    RESTARTS   AGE
kamerplanter-backend-7d8f9b6c4d-abc12     1/1     Running   0          45s
kamerplanter-backend-7d8f9b6c4d-def34     1/1     Running   0          45s
kamerplanter-frontend-5c4d8e7f3b-ghi56    1/1     Running   0          45s
kamerplanter-frontend-5c4d8e7f3b-jkl78    1/1     Running   0          45s
kamerplanter-arangodb-0                   1/1     Running   0          45s
kamerplanter-valkey-0                     1/1     Running   0          45s

Performing updates

# Upgrade to a new version
helm upgrade kamerplanter \
  oci://ghcr.io/nolte/charts/kamerplanter \
  --version 0.3.0 \
  --namespace kamerplanter \
  --values values-production.yaml

The backend and frontend deployments perform a rolling update — there is no downtime, as old pods are only terminated once the new ones are ready.

Backend start-up time

Before its first request, a new backend pod prepares the database: it creates missing collections and indexes, runs the pending migrations and loads the master-data seeds. Only then does it accept connections. This happens on purpose inside the backend process and not in a separate migration job — an upgrade needs no extra step.

On an empty database that takes about 40 seconds (measured on a workstation; under the chart's 1-CPU limit rather longer). With a long migration, or while another pod is migrating and this one waits, it takes several minutes. So that Kubernetes does not restart the pod during that time, a startupProbe checks the start first; the liveness and readiness probes only begin once it succeeded. Until then the old pod keeps serving.

Limit Value
Maximum start-up time (startupProbe: 10 s × 90 attempts) 900 s (15 minutes)
Longest wait for another pod's migration (BARRIER_TIMEOUT_SECONDS) 600 s
When a migration lock counts as orphaned (LOCK_TTL_SECONDS) 300 s

What that means for you:

  • If a pod waits longer than 600 seconds for a migration another pod holds, it stops itself with MigrationBarrierTimeoutError — the backend decides that the start failed, not the kubelet. The maximum start-up time therefore always lies above this wait.
  • If a migrating pod is killed, its lock stays for up to 300 seconds; after that another pod takes over. A restart within that window waits instead of migrating right away.
  • If a migration needs longer than 15 minutes, raise the startupProbe's failureThreshold before the upgrade (the other probe values are kept). Never lower it below 70: a pod that waited the full 600 seconds for another pod's migration still needs its own start afterward (about 40 seconds, longer under 1 CPU). 10 s × 70 = 700 seconds leave 100 seconds of headroom for that; with less, Kubernetes kills a pod that is legitimately waiting or just finishing its start.
  • When scaling up (replicas or the autoscaler), a new pod only helps after its start. Plan scaling in minutes, not seconds.
values-production.yaml (excerpt) — only for very long migrations
controllers:
  backend:
    containers:
      main:
        probes:
          startup:
            spec:
              failureThreshold: 180    # 10 s × 180 = 30 minutes

Uninstallation

helm uninstall kamerplanter --namespace kamerplanter

Persistent Volumes

helm uninstall removes deployments and services but not the Persistent Volume Claims (PVCs) for ArangoDB and Valkey. Your data is preserved. To also delete the data:

kubectl delete pvc --all -n kamerplanter

Monitoring

Check logs

# Backend logs
kubectl logs -l app.kubernetes.io/component=backend -n kamerplanter --tail=50

# Frontend logs
kubectl logs -l app.kubernetes.io/component=frontend -n kamerplanter --tail=50

# ArangoDB logs
kubectl logs -l app.kubernetes.io/component=arangodb -n kamerplanter --tail=50

Health checks

The backend provides two health endpoints:

Endpoint Checks Used by
/api/v1/health/live Backend process is running Kubernetes liveness probe
/api/v1/health/ready Backend + database reachable Kubernetes readiness probe
# Test manually (via port-forward)
kubectl port-forward svc/kamerplanter-backend 8000:8000 -n kamerplanter
curl http://localhost:8000/api/v1/health/ready

Troubleshooting

Pods stay in 'Pending' state

The cluster doesn't have enough resources. Check available capacity with kubectl describe nodes and compare with the resource requests in the values file. For smaller clusters, you can reduce the requests.

ArangoDB won't start (CrashLoopBackOff)

Most common cause: not enough memory. ArangoDB needs at least 512 Mi. Check the logs: kubectl logs kamerplanter-arangodb-0 -n kamerplanter.

Frontend shows 502 Bad Gateway

The backend isn't ready yet. Wait until the backend's readiness probe succeeds: kubectl get pods -n kamerplanter -w. If the error persists: do the service names in the nginx configuration match?

Ingress works but the page won't load

Check: (1) Is an Ingress controller installed? (2) Does the DNS entry point to the cluster? (3) Does the hostname in the values file match the DNS?

Backend pod stays in 'CreateContainerConfigError'

The kamerplanter-secrets secret doesn't exist in the target namespace, or a key referenced via envFrom/secretKeyRef is missing from it. Check: kubectl get secret kamerplanter-secrets -n kamerplanter and compare the existing keys against step 2 above.

Backend pod starts and immediately crashes again (CrashLoopBackOff with 'FATAL: Default secrets detected')

The secret exists but still contains an unchanged default value — e.g. ARANGODB_PASSWORD=rootpassword or an empty FERNET_KEY. The log line names the affected fields directly: kubectl logs -l app.kubernetes.io/component=backend -n kamerplanter. Details on the check: Configuration Matrix — Mandatory secrets.


See also