---
title: "Helm / Kubernetes"
description: "Deploy the CloudPDF server to Kubernetes with the official Helm chart — replicas, autoscaling, managed migrations, and clean secret handling."
source: "https://www.cloudpdf.com/docs/server/deployment/helm"
---

# 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](https://helm.sh).
- 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:

```sh
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):

```yaml
# 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
```

```sh
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](https://www.cloudpdf.com/docs/server/operations/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](https://www.cloudpdf.com/docs/server/operations/health-and-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:

```yaml
# 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

```sh
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

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

## Next steps

- [Health & scaling](https://www.cloudpdf.com/docs/server/operations/health-and-scaling) — Worker pool, probes, replicas, and memory sizing.
- [Configuration reference](https://www.cloudpdf.com/docs/server/configuration) — Every CLOUDPDF\_\* setting and its Helm values key.
