Skip to content
Marolum documentation contents
Marolum documentation

Set up on Kubernetes

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

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 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: 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 to fsGroup and makes it group-writable, which Marolum'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/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.

Screenshotkubectl get pods output showing one marolum 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: 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=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 MAROLUM_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 MAROLUM_SQLITE_PATH variable, have the init container write /run/marolum/secrets/postgres-url, and set MAROLUM_POSTGRES_URL_FILE to it. Enterprise replicas then follow Database integration.