Set up on Kubernetes
Marolum 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 Marolum 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
└─ marolum
/data ← PVC (RWO, block)
/run/marolum ← 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: marolum
---
apiVersion: v1
kind: Secret
metadata:
name: marolum-bootstrap
namespace: marolum
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 Marolum requires. It is never mounted into the Marolum 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 Marolum
picks up the projected web-identity token automatically.
apiVersion: v1
kind: ServiceAccount
metadata:
name: marolum
namespace: marolum
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/MarolumTask
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: marolum-data
namespace: marolum
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: marolum
namespace: marolum
spec:
replicas: 1
strategy:
type: Recreate # never two pods on one SQLite volume
selector:
matchLabels: { app: marolum }
template:
metadata:
labels: { app: marolum }
spec:
serviceAccountName: marolum
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile: { type: RuntimeDefault }
initContainers:
- name: marolum-init
image: ghcr.io/dekglas/marolum@sha256:REPLACE_WITH_THE_RELEASE_DIGEST
command: ['/bin/sh', '-ec']
args:
- |
umask 077
install -d -m 0700 /data/db
install -d -m 0700 /run/marolum/secrets
printf '%s\n' "$ADMIN_PASSWORD" > /run/marolum/secrets/admin-password
printf '%s\n' "$BACKENDS_JSON" > /run/marolum/secrets/backends.json
chmod 0600 /run/marolum/secrets/*
env:
- name: ADMIN_PASSWORD
valueFrom: { secretKeyRef: { name: marolum-bootstrap, key: admin-password } }
- name: BACKENDS_JSON
valueFrom: { secretKeyRef: { name: marolum-bootstrap, key: backends.json } }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ['ALL'] }
volumeMounts:
- { name: data, mountPath: /data }
- { name: runtime, mountPath: /run/marolum }
containers:
- name: marolum
image: ghcr.io/dekglas/marolum@sha256:REPLACE_WITH_THE_RELEASE_DIGEST
ports:
- { name: http, containerPort: 8080 }
env:
- { name: MAROLUM_SQLITE_PATH, value: /data/db/marolum.db }
- {
name: MAROLUM_LOCAL_ADMIN_PASSWORD_FILE,
value: /run/marolum/secrets/admin-password,
}
- { name: MAROLUM_BACKENDS_FILE, value: /run/marolum/secrets/backends.json }
- { name: MAROLUM_PUBLIC_ORIGIN, value: https://marolum.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/marolum, readOnly: true }
- { name: tmp, mountPath: /tmp }
terminationGracePeriodSeconds: 45
volumes:
- name: data
persistentVolumeClaim: { claimName: marolum-data }
- name: runtime
emptyDir: { medium: Memory, sizeLimit: 1Mi }
- name: tmp
emptyDir: { sizeLimit: 64Mi }
Three details are load-bearing:
MAROLUM_SQLITE_PATH=/data/db/marolum.db. Kubernetes sets the volume root's group tofsGroupand makes it group-writable, which Marolum'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/marolum, 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: marolum
namespace: marolum
spec:
selector: { app: marolum }
ports:
- { name: http, port: 8080, targetPort: http }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: marolum
namespace: marolum
annotations:
cert-manager.io/cluster-issuer: letsencrypt # or your issuer
spec:
ingressClassName: nginx
tls:
- hosts: [marolum.example.com]
secretName: marolum-tls
rules:
- host: marolum.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: marolum, port: { name: http } }
The Ingress provides the HTTPS origin the Secure session cookie needs. Leave
MAROLUM_ALLOW_HTTP unset. Optionally add a NetworkPolicy that admits ingress to the pod only
from the Ingress controller's namespace.
Verify
kubectl -n marolum rollout status deployment/marolum
kubectl -n marolum logs deployment/marolum -c marolum-init
kubectl -n marolum logs deployment/marolum -c marolum | tail -n 5
curl -fsS https://marolum.example.com/api/v1/readiness
Sign in, open Administration → Backends, and confirm the diagnostic. Then remove the
MAROLUM_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: marolum-backup, namespace: marolum } spec: restartPolicy: Never securityContext: { runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000 } containers: - name: hold image: ghcr.io/dekglas/marolum@sha256:REPLACE_WITH_THE_RELEASE_DIGEST command: ['sleep', '3600'] volumeMounts: [{ name: data, mountPath: /data }] volumes: - { name: data, persistentVolumeClaim: { claimName: marolum-data } }kubectl -n marolum scale deployment/marolum --replicas=0 kubectl -n marolum wait --for=delete pod -l app=marolum --timeout=90s kubectl apply -f backup-pod.yaml && kubectl -n marolum wait --for=condition=Ready pod/marolum-backup kubectl -n marolum cp marolum-backup:/data/db/marolum.db "./marolum-$(date -u +%Y%m%dT%H%M%SZ).db" kubectl -n marolum delete pod marolum-backup kubectl -n marolum scale deployment/marolum --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; setMAROLUM_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
MAROLUM_SQLITE_PATHvariable, have the init container write/run/marolum/secrets/postgres-url, and setMAROLUM_POSTGRES_URL_FILEto it. Enterprise replicas then follow Database integration.