Skip to content
Lumaft documentation contents
Lumaft documentation

Set up on Kubernetes

Run Lumaft as a single-replica Deployment with a block PersistentVolumeClaim, an init container for secret files, and an Ingress for TLS.

Set up on Kubernetes

Lumaft on Kubernetes is one Deployment with one replica, a ReadWriteOnce block volume for SQLite, and an Ingress that terminates TLS. Two Kubernetes habits need adjusting: Secrets must not be mounted directly (they arrive as symlinks, which Lumaft rejects), and the Deployment must use the Recreate strategy so two pods never share the volume.

Architecture

   Internet ──443──▶ Ingress (TLS) ──▶ Service :8080 ──▶ Pod
                                                          ├─ init: writes secret files → emptyDir
                                                          └─ lumaft
                                                               /data ← PVC (RWO, block)
                                                               /run/lumaft ← emptyDir (ro)
                                                               → S3 via node egress / NAT

Before you begin

  • A cluster with a StorageClass that provisions block volumes (EBS gp3 on EKS, Azure Disk on AKS, Persistent Disk on GKE). Not EFS, not Azure Files, not NFS — SQLite's WAL mode is unsupported on network filesystems.
  • An Ingress controller and a way to issue certificates (cert-manager or a cloud-managed certificate).
  • The verified image digest. See Deployment and setup.
  • Your backend details. See Find your backend details.
  • On EKS, an IAM role for the read-only policy that the pod's service account can assume (IRSA or EKS Pod Identity). The policy is in IAM setup on AWS.

Manifests

Apply these in order. Replace the digest, the hostname, and the backend values.

Namespace and secrets

apiVersion: v1
kind: Namespace
metadata:
  name: lumaft
---
apiVersion: v1
kind: Secret
metadata:
  name: lumaft-bootstrap
  namespace: lumaft
type: Opaque
stringData:
  admin-password: 'REPLACE_WITH_12_TO_4096_CHARACTERS'
  backends.json: |
    {
      "schemaVersion": 1,
      "backends": [
        {
          "kind": "s3",
          "id": "production",
          "displayName": "Production infrastructure",
          "bucket": "BUCKET_NAME",
          "prefix": "OPTIONAL_PREFIX",
          "layout": "project-scoped",
          "region": "us-east-1",
          "forcePathStyle": false,
          "enabled": true
        }
      ]
    }

The Secret is read by the init container as environment variables and written to files with the ownership Lumaft requires. It is never mounted into the Lumaft container.

Service account

On EKS with IRSA, annotate the service account with the role from IAM setup on AWS (trust policy for the cluster's OIDC provider instead of ecs-tasks.amazonaws.com). The AWS SDK inside Lumaft picks up the projected web-identity token automatically.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: lumaft
  namespace: lumaft
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/LumaftTask

On AKS or GKE reading Amazon S3, supply credentials as described in Backend credentials from Azure; the same options apply.

Storage

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: lumaft-data
  namespace: lumaft
spec:
  accessModes: ['ReadWriteOnce']
  storageClassName: gp3 # a block StorageClass on your cluster
  resources:
    requests:
      storage: 50Gi

Set the StorageClass's reclaimPolicy to Retain (or use a class that has it) so deleting the claim does not delete the database.

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: lumaft
  namespace: lumaft
spec:
  replicas: 1
  strategy:
    type: Recreate # never two pods on one SQLite volume
  selector:
    matchLabels: { app: lumaft }
  template:
    metadata:
      labels: { app: lumaft }
    spec:
      serviceAccountName: lumaft
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile: { type: RuntimeDefault }
      initContainers:
        - name: lumaft-init
          image: ghcr.io/dekglas/lumaft@sha256:REPLACE_WITH_THE_RELEASE_DIGEST
          command: ['/bin/sh', '-ec']
          args:
            - |
              umask 077
              install -d -m 0700 /data/db
              install -d -m 0700 /run/lumaft/secrets
              printf '%s\n' "$ADMIN_PASSWORD" > /run/lumaft/secrets/admin-password
              printf '%s\n' "$BACKENDS_JSON" > /run/lumaft/secrets/backends.json
              chmod 0600 /run/lumaft/secrets/*
          env:
            - name: ADMIN_PASSWORD
              valueFrom: { secretKeyRef: { name: lumaft-bootstrap, key: admin-password } }
            - name: BACKENDS_JSON
              valueFrom: { secretKeyRef: { name: lumaft-bootstrap, key: backends.json } }
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ['ALL'] }
          volumeMounts:
            - { name: data, mountPath: /data }
            - { name: runtime, mountPath: /run/lumaft }
      containers:
        - name: lumaft
          image: ghcr.io/dekglas/lumaft@sha256:REPLACE_WITH_THE_RELEASE_DIGEST
          ports:
            - { name: http, containerPort: 8080 }
          env:
            - { name: LUMAFT_SQLITE_PATH, value: /data/db/lumaft.db }
            - { name: LUMAFT_LOCAL_ADMIN_PASSWORD_FILE, value: /run/lumaft/secrets/admin-password }
            - { name: LUMAFT_BACKENDS_FILE, value: /run/lumaft/secrets/backends.json }
            - { name: LUMAFT_PUBLIC_ORIGIN, value: https://lumaft.example.com }
          resources:
            requests: { cpu: '1', memory: 2Gi }
            limits: { memory: 2Gi }
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ['ALL'] }
          startupProbe:
            httpGet: { path: /api/v1/readiness, port: http }
            periodSeconds: 2
            failureThreshold: 30
          readinessProbe:
            httpGet: { path: /api/v1/readiness, port: http }
            periodSeconds: 30
          livenessProbe:
            httpGet: { path: /api/v1/health, port: http }
            periodSeconds: 30
          volumeMounts:
            - { name: data, mountPath: /data }
            - { name: runtime, mountPath: /run/lumaft, readOnly: true }
            - { name: tmp, mountPath: /tmp }
      terminationGracePeriodSeconds: 45
      volumes:
        - name: data
          persistentVolumeClaim: { claimName: lumaft-data }
        - name: runtime
          emptyDir: { medium: Memory, sizeLimit: 1Mi }
        - name: tmp
          emptyDir: { sizeLimit: 64Mi }

Three details are load-bearing:

  • LUMAFT_SQLITE_PATH=/data/db/lumaft.db. Kubernetes sets the volume root's group to fsGroup and makes it group-writable, which Lumaft's parent-directory rule refuses. The init container creates /data/db with mode 0700 owned by UID 1000, and the database lives there.
  • The init container runs as UID 1000, so everything it writes has the right owner without a chown, and the pod passes the restricted Pod Security Standard.
  • emptyDir for /run/lumaft, written by the init container, not a Secret volume. Secret volumes materialize files as symlinks, which the file rules reject.

Service and Ingress

apiVersion: v1
kind: Service
metadata:
  name: lumaft
  namespace: lumaft
spec:
  selector: { app: lumaft }
  ports:
    - { name: http, port: 8080, targetPort: http }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: lumaft
  namespace: lumaft
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt # or your issuer
spec:
  ingressClassName: nginx
  tls:
    - hosts: [lumaft.example.com]
      secretName: lumaft-tls
  rules:
    - host: lumaft.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service: { name: lumaft, port: { name: http } }

The Ingress provides the HTTPS origin the Secure session cookie needs. Leave LUMAFT_ALLOW_HTTP unset. Optionally add a NetworkPolicy that admits ingress to the pod only from the Ingress controller's namespace.

Verify

kubectl -n lumaft rollout status deployment/lumaft
kubectl -n lumaft logs deployment/lumaft -c lumaft-init
kubectl -n lumaft logs deployment/lumaft -c lumaft | tail -n 5
curl -fsS https://lumaft.example.com/api/v1/readiness

Sign in, open Administration → Backends, and confirm the diagnostic. Then remove the LUMAFT_LOCAL_ADMIN_PASSWORD_FILE variable and the admin-password key from the Secret and roll the Deployment.

Screenshotkubectl get pods output showing one lumaft pod Running with 1/1 ready, beside the Backends page showing a successful diagnostic

Operate on Kubernetes

  • Backups are cold. Scale to zero, copy the file out through a short-lived pod that mounts the claim, then scale back to one:

    # backup-pod.yaml
    apiVersion: v1
    kind: Pod
    metadata: { name: lumaft-backup, namespace: lumaft }
    spec:
      restartPolicy: Never
      securityContext: { runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000 }
      containers:
        - name: hold
          image: ghcr.io/dekglas/lumaft@sha256:REPLACE_WITH_THE_RELEASE_DIGEST
          command: ['sleep', '3600']
          volumeMounts: [{ name: data, mountPath: /data }]
      volumes:
        - { name: data, persistentVolumeClaim: { claimName: lumaft-data } }
    
    kubectl -n lumaft scale deployment/lumaft --replicas=0
    kubectl -n lumaft wait --for=delete pod -l app=lumaft --timeout=90s
    kubectl apply -f backup-pod.yaml && kubectl -n lumaft wait --for=condition=Ready pod/lumaft-backup
    kubectl -n lumaft cp lumaft-backup:/data/db/lumaft.db "./lumaft-$(date -u +%Y%m%dT%H%M%SZ).db"
    kubectl -n lumaft delete pod lumaft-backup
    kubectl -n lumaft scale deployment/lumaft --replicas=1
    

    Verify the copy per Backup and disaster recovery. A CSI VolumeSnapshot of a running pod is crash-consistent, not a verified backup.

  • Upgrades. Take a backup, change the digest in the Deployment, apply. Recreate stops the old pod before the new one starts. A refused start naming a gated migration is expected; set LUMAFT_SCHEMA_UPGRADE_APPLY once per Database integration.

  • Never scale above one on SQLite. The second pod cannot attach the RWO volume on another node, and on the same node it refuses with ownership-conflict.

  • PostgreSQL. With a Business license and a PostgreSQL service in the cluster or managed by your cloud, drop the PVC and the LUMAFT_SQLITE_PATH variable, have the init container write /run/lumaft/secrets/postgres-url, and set LUMAFT_POSTGRES_URL_FILE to it. Enterprise replicas then follow Database integration.