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-serverPrerequisites#
- A Kubernetes cluster and
kubectlaccess. - 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.yamlThe 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: Prefixhelm install cloudpdf oci://ghcr.io/embedpdf/charts/cloudpdf-server \
--version <version> -f values.yamlThe 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.enabledis set. - PodDisruptionBudget so rolling nodes never take all replicas at once.
- Non-root
securityContextby 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 theX-CloudPDF-Docheader, which SDKs from this release send automatically (the server’s CORS allowlist includes it from the same release;docAffinityHeader: falseexists 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 aconfiguration-snippet— note that many managed controllers shipallow-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 hereThe 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.yamlThe 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