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
gp3on 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 tofsGroupand makes it group-writable, which Lumaft's parent-directory rule refuses. The init container creates/data/dbwith mode0700owned 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 therestrictedPod Security Standard. emptyDirfor/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.
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=1Verify 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.
Recreatestops the old pod before the new one starts. A refused start naming a gated migration is expected; setLUMAFT_SCHEMA_UPGRADE_APPLYonce 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_PATHvariable, have the init container write/run/lumaft/secrets/postgres-url, and setLUMAFT_POSTGRES_URL_FILEto it. Enterprise replicas then follow Database integration.