Skip to content

Run Prelum on Kubernetes

The image is the same one Docker and releases describes. What changes is where the memory boundary sits: the pod's limits.memory is the outer bound, and Prelum's own per-render limit is the inner one. Both are needed, and they do different jobs.

When a pod exceeds limits.memory, the kernel's OOM killer chooses a victim from inside that pod by oom_score. Prelum and every Typst process it forked are candidates. If it picks Prelum itself, the container restarts: every render in flight dies, the pod drops out of its Service, and the caller whose template caused the pressure receives the same failure as everyone else. PRELUM_MAX_RENDER_MEMORY_BYTES stops a runaway render before the pod's budget is touched, so the failure stays a 422 for the one caller responsible while the other renders finish.

The two are distinguishable in the responses, which is the point of setting both. A render that exceeds its own limit aborts and answers 422 template_compile_failed: the caller wrote a template that wanted too much, and nothing is wrong with the deployment. A render killed by the pod's limit is killed from outside, answers 500 render_failed, and is logged at error so it reaches Sentry — because that one usually means the pod is under-provisioned for its configured concurrency, and the caller who received it may have done nothing unusual at all.

Usually, not always. The per-render bound is RLIMIT_DATA, which covers the heap and anonymous mappings: fonts a caller sends in files are memory-mapped by Typst outside it, as is scratch space if /tmp is a medium: Memory volume, and on kernels older than 4.7 anonymous mappings fall outside it too. Enough concurrent requests of that shape can push the pod over its limit without any single render exceeding its own. Nothing in the response can separate that from genuine under-provisioning, so it is reported as the infrastructure fault it resembles — treat a cluster of render_failed alerts as a sizing question first, and check the payloads second.

A starting manifest

apiVersion: apps/v1
kind: Deployment
metadata:
  name: prelum
spec:
  replicas: 3
  selector:
    matchLabels:
      app: prelum
  template:
    metadata:
      labels:
        app: prelum
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
      containers:
        - name: prelum
          image: ghcr.io/vrtfinland/prelum:1.1.0
          ports:
            - containerPort: 9870
          env:
            - name: PRELUM_API_TOKEN
              valueFrom:
                secretKeyRef:
                  name: prelum
                  key: api-token
            - name: PRELUM_ENVIRONMENT
              value: production
          resources:
            requests:
              cpu: 500m
              memory: 1Gi
            limits:
              cpu: "2"
              memory: 2Gi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: render-scratch
              mountPath: /tmp
          livenessProbe:
            httpGet:
              path: /health
              port: 9870
          readinessProbe:
            httpGet:
              path: /health
              port: 9870
      volumes:
        - name: render-scratch
          emptyDir:
            sizeLimit: 1Gi

Sizing the pod

The image ships PRELUM_MAX_RENDER_MEMORY_BYTES=536870912 and PRELUM_MAX_CONCURRENT_RENDERS=2. With the request and output buffers Prelum holds on its own side, that comes to roughly 1.2 GiB of ceiling, so limits.memory: 2Gi leaves room for the interpreter. Configuration sets out the arithmetic in full.

Scale throughput with replicas, not with PRELUM_MAX_CONCURRENT_RENDERS. Typst is CPU-bound, so a pod with two cores gains little from more concurrent renders, while every extra one multiplies the pod's memory ceiling. More replicas also spread the blast radius of a restart.

If you do raise concurrency, raise limits.memory in the same commit. Raising one without the other is the most direct way to reintroduce the pod-wide OOM kill that the per-render limit exists to prevent.

Scratch space and the read-only root filesystem

readOnlyRootFilesystem: true leaves Prelum nowhere to build a render's temporary project, so the manifest above mounts an emptyDir at /tmp, which is where PRELUM_TEMP_ROOT points by default.

Leave that volume disk-backed. An emptyDir with medium: Memory is a tmpfs, and tmpfs pages are charged to the pod's memory limit — where RLIMIT_DATA does not reach them, because it bounds the Typst process's heap rather than the filesystem. A render writing large inline files to a memory-backed /tmp can therefore OOM the pod with its own memory limit working exactly as intended. sizeLimit bounds the volume independently and is worth setting either way.

If you point PRELUM_TEMP_ROOT somewhere other than /tmp, keep the path short: the published files key length bound is derived from how much of the operating system's path limit the render root leaves free, and Prelum refuses to start if a configured root would void it.

Startup failures are deliberate

Prelum probes the memory-limit wrapper before it serves its first request and exits if the wrapper cannot honour the configured value. A CrashLoopBackOff with the render memory limit probe exited ... in the log means the deployment is misconfigured, not that the service is unhealthy.

This is on purpose. Every way the wrapper can fail ends in the same exit status a failed compilation produces, so without the probe a broken configuration would answer 422 to every caller and page nobody. Fix the configuration rather than removing the limit.