CloudPDF
DocsPricing
Start building

Run on Kubernetes with Helm

For production — from a single startup deployment to enterprise scale — the Helm chart gives you the things that matter when downtime is expensive: multiple replicas, autoscaling, a pod disruption budget, managed schema migrations, and a clean split between configuration and secrets.

The chart is published as an OCI artifact to the same registry as the image:

oci://ghcr.io/embedpdf/charts/cloudpdf-server

Prerequisites#

  • A Kubernetes cluster and kubectl access.
  • Helm 3.
  • A Postgres database and an object store (S3 / GCS / Azure Blob) you manage. The chart does not run a database for you — production should use a managed or operator-run Postgres and a real bucket.

Install#

Create the secrets the server needs, then install:

kubectl create secret generic cloudpdf-secrets \
  --from-literal=CLOUDPDF_JWT_SECRET="$(openssl rand -hex 32)" \
  --from-literal=CLOUDPDF_PASSWORD_VERIFICATION_HMAC_SECRET="$(openssl rand -hex 32)" \
  --from-literal=CLOUDPDF_PASSWORD_SESSION_SERVER_SECRET="$(openssl rand -hex 32)" \
  --from-literal=CLOUDPDF_DB_URL="postgres://user:pass@db:5432/cloudpdf" \
  --from-literal=AWS_ACCESS_KEY_ID="..." \
  --from-literal=AWS_SECRET_ACCESS_KEY="..." \
  --from-literal=CLOUDPDF_LICENSE_KEY="..."
 
helm install cloudpdf \
  oci://ghcr.io/embedpdf/charts/cloudpdf-server \
  --version <version> \
  -f values.yaml

The server is fail-closed on licensing: without a CLOUDPDF_LICENSE_KEY (or an installed air-gapped certificate) pods exit at boot. An expired license degrades to read-only instead — it never restart-loops the fleet.

A values.yaml for the scalable profile (Postgres + S3, multiple replicas):

# values.yaml
# image.tag defaults to the chart's own appVersion — every chart release
# pulls the image it shipped with. For production, pin the digest you
# tested instead:
# image:
#   digest: sha256:...
 
replicaCount: 2
 
# Reference the secret you created above instead of putting secrets in values.
existingSecret: cloudpdf-secrets
 
# Non-secret configuration becomes a ConfigMap.
config:
  CLOUDPDF_DB_DRIVER: postgres
  CLOUDPDF_STORAGE_KIND: s3
  CLOUDPDF_STORAGE_S3_BUCKET: my-cloudpdf-bucket
  CLOUDPDF_STORAGE_S3_REGION: eu-west-1
  CLOUDPDF_WORKER_POOL_SIZE: 'max'
 
# Schema migrations run as a pre-upgrade Helm hook Job, so app pods never race.
migrations:
  enabled: true
 
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
 
resources:
  requests:
    cpu: '1'
    memory: 1Gi
  limits:
    cpu: '2'
    memory: 2Gi
 
ingress:
  enabled: true
  className: nginx
  hosts:
    - host: pdf.your-app.com
      paths:
        - path: /
          pathType: Prefix
helm install cloudpdf oci://ghcr.io/embedpdf/charts/cloudpdf-server \
  --version <version> -f values.yaml

The chart refuses unsafe combinations at install time: more than one replica requires Postgres and a shared object store; the SQLite/PVC profile is pinned to a single pod; the migrate hook cannot combine with SQLite or a PVC. A values.schema.json also rejects unknown keys, so typos fail fast instead of deploying half-configured.

How migrations are handled#

When migrations.enabled is set, the chart runs cloudpdf-server migrate up as a pre-install / pre-upgrade hook Job before any app pods roll. The app pods themselves run with CLOUDPDF_AUTO_MIGRATE=0 and CLOUDPDF_FAIL_ON_PENDING=1, which means:

  • Replicas never race to migrate the database.
  • A deploy that somehow skips migrations fails fast instead of serving on a stale schema.

See Migrations for the full model.

Health, scaling & resilience#

The chart wires the probes and resilience primitives for you:

  • Liveness/healthz, readiness/readyz.
  • HorizontalPodAutoscaler on CPU (and optionally memory) when autoscaling.enabled is set.
  • PodDisruptionBudget so rolling nodes never take all replicas at once.
  • Non-root securityContext by default.

Set CLOUDPDF_ENGINE_ISOLATION=host (via extraEnv) so a native PDF crash costs a sub-second engine respawn inside the pod instead of a pod restart. Still run at least two replicas, keep CLOUDPDF_WORKER_POOL_SIZE in step with the CPU you give each pod, and size memory for per-thread font and CMap duplication. Details in Health & scaling.

Document affinity (multi-replica)#

With several replicas, docAffinity.enabled: true renders a second Ingress that consistent-hashes /v1/docs traffic so one document’s requests land on one warm replica. Two modes:

  • key: header (preferred, portable): hashes the X-CloudPDF-Doc header, which SDKs from this release send automatically (the server’s CORS allowlist includes it from the same release; docAffinityHeader: false exists as an escape hatch for stale servers or proxies that reject unknown headers).
  • key: uri (nginx-only): extracts the document id from the path via a configuration-snippet — note that many managed controllers ship allow-snippet-annotations=false, which silently disables it.

Any replica serves any document correctly either way — affinity is purely a warm-cache optimization. Leave it off until your replica count or per-document memory makes duplication visible.

Sandboxed runtimes#

runtimeClassName (empty by default) runs the pod under a sandboxed container runtime such as gVisor or Kata — kernel-level containment for the native engine, at syscall-emulation cost. Measure before committing.

The small-footprint profile (SQLite)#

For a low-traffic internal tool, you can run a single replica backed by a PersistentVolume instead of Postgres + a bucket:

# values-sqlite.yaml
replicaCount: 1
 
strategy:
  type: Recreate # SQLite has a single writer — never run two pods at once
 
persistence:
  enabled: true
  size: 20Gi
 
config:
  CLOUDPDF_DB_DRIVER: sqlite
  CLOUDPDF_STORAGE_KIND: fs
  CLOUDPDF_AUTO_MIGRATE: '1'
 
autoscaling:
  enabled: false
 
existingSecret: cloudpdf-secrets # just CLOUDPDF_JWT_SECRET here

The SQLite profile is deliberately pinned to one pod with a Recreate strategy because SQLite allows a single writer. For anything that needs more than one replica, use the Postgres + object-storage profile.

Secrets the enterprise way#

If you run a secrets operator (External Secrets, Vault, sealed-secrets, your cloud’s CSI driver), create the Kubernetes Secret with that tool and point the chart at it via existingSecret. The chart never requires you to put secret values in values.yaml.

Upgrading#

helm upgrade cloudpdf oci://ghcr.io/embedpdf/charts/cloudpdf-server \
  --version <new-version> -f values.yaml

The pre-upgrade migration Job runs first; app pods roll only after it succeeds.

Verify#

helm test cloudpdf          # optional smoke tests, if enabled
kubectl get pods            # app pods Running, migration Job Completed
kubectl get hpa             # autoscaler present

Next steps#