CloudPDF
DocsPricing
Start building

Run with Docker Compose

Docker Compose is the sweet spot for self-hosting on a single machine. You can start with the server alone, then add Postgres and S3-compatible object storage when you’re ready — all in one file you check into your repo.

Minimal: the server alone#

This is the zero-config profile (SQLite + local disk) wrapped in Compose, so it survives reboots and is easy to manage.

# docker-compose.yml
services:
  cloudpdf:
    image: ghcr.io/embedpdf/cloudpdf-server:1.0.0
    init: true
    ports:
      - '3000:3000'
    environment:
      CLOUDPDF_LICENSE_KEY: ${CLOUDPDF_LICENSE_KEY:?set CLOUDPDF_LICENSE_KEY in .env}
      CLOUDPDF_JWT_SECRET: ${CLOUDPDF_JWT_SECRET:?set CLOUDPDF_JWT_SECRET in .env}
    volumes:
      - cloudpdf-data:/data
    restart: unless-stopped
 
volumes:
  cloudpdf-data:

Put your secret in a .env file next to it:

# .env
# Required — the server refuses to boot without a license key.
# A development key keeps everything else zero-config:
# /docs/server/configuration/licensing
CLOUDPDF_LICENSE_KEY=key/...
CLOUDPDF_JWT_SECRET=replace-with-a-long-random-string

Then:

docker compose up -d
curl localhost:3000/healthz   # {"status":"ok"}

The container reuses the image’s built-in healthcheck, so docker compose ps shows real health, and other services can wait on it with depends_on … condition: service_healthy.

Production: server + Postgres + object storage#

This stack swaps SQLite for Postgres and the local disk for MinIO (an S3-compatible object store you run yourself — replace it with AWS S3 or any S3-compatible service in production). It also runs schema migrations as a one-shot job that the app waits for, so migrations never race.

# docker-compose.prod.yml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: cloudpdf
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
      POSTGRES_DB: cloudpdf
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U cloudpdf']
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped
 
  minio:
    image: minio/minio
    command: server /data --console-address ':9001'
    environment:
      MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:?set S3_ACCESS_KEY_ID}
      MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:?set S3_SECRET_ACCESS_KEY}
    volumes:
      - minio-data:/data
    healthcheck:
      test: ['CMD', 'mc', 'ready', 'local']
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped
 
  # One-shot: apply schema changes, then exit. The app waits for this to finish.
  migrate:
    image: ghcr.io/embedpdf/cloudpdf-server:1.0.0
    command: ['migrate', 'up']
    environment:
      CLOUDPDF_DB_DRIVER: postgres
      CLOUDPDF_DB_URL: postgres://cloudpdf:${POSTGRES_PASSWORD}@postgres:5432/cloudpdf
      CLOUDPDF_FAIL_ON_PENDING: '1'
    depends_on:
      postgres:
        condition: service_healthy
    restart: 'no'
 
  cloudpdf:
    image: ghcr.io/embedpdf/cloudpdf-server:1.0.0
    init: true
    ports:
      - '3000:3000'
    environment:
      CLOUDPDF_LICENSE_KEY: ${CLOUDPDF_LICENSE_KEY:?set CLOUDPDF_LICENSE_KEY}
      # Auth + encrypted-PDF secrets. All three are required under a
      # production license and must stay stable across restarts:
      # /docs/server/configuration/authentication#encrypted-pdf-secrets
      CLOUDPDF_JWT_SECRET: ${CLOUDPDF_JWT_SECRET:?set CLOUDPDF_JWT_SECRET}
      CLOUDPDF_PASSWORD_VERIFICATION_HMAC_SECRET: ${CLOUDPDF_PASSWORD_VERIFICATION_HMAC_SECRET:?set it in .env}
      CLOUDPDF_PASSWORD_SESSION_SERVER_SECRET: ${CLOUDPDF_PASSWORD_SESSION_SERVER_SECRET:?set it in .env}
      # Database
      CLOUDPDF_DB_DRIVER: postgres
      CLOUDPDF_DB_URL: postgres://cloudpdf:${POSTGRES_PASSWORD}@postgres:5432/cloudpdf
      # Object storage (MinIO / S3-compatible)
      CLOUDPDF_STORAGE_KIND: s3
      CLOUDPDF_STORAGE_S3_BUCKET: cloudpdf
      CLOUDPDF_STORAGE_S3_REGION: us-east-1
      CLOUDPDF_STORAGE_S3_ENDPOINT: http://minio:9000
      AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
      AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
      # Migrations are handled by the one-shot job above
      CLOUDPDF_AUTO_MIGRATE: '0'
      CLOUDPDF_FAIL_ON_PENDING: '1'
      # Concurrency — raise as you give the host more cores
      CLOUDPDF_WORKER_POOL_SIZE: max
    volumes:
      - cloudpdf-cache:/data
    depends_on:
      postgres:
        condition: service_healthy
      minio:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    restart: unless-stopped
 
volumes:
  pg-data:
  minio-data:
  cloudpdf-cache:
# .env — generate each secret with: openssl rand -hex 32
CLOUDPDF_LICENSE_KEY=key/...
CLOUDPDF_JWT_SECRET=replace-with-a-long-random-string
CLOUDPDF_PASSWORD_VERIFICATION_HMAC_SECRET=replace-with-a-long-random-string
CLOUDPDF_PASSWORD_SESSION_SERVER_SECRET=replace-with-a-long-random-string
POSTGRES_PASSWORD=replace-with-a-strong-password
S3_ACCESS_KEY_ID=cloudpdf
S3_SECRET_ACCESS_KEY=replace-with-a-strong-password

Bring it up:

docker compose -f docker-compose.prod.yml up -d

What happens, in order: Postgres and MinIO start and become healthy → the migrate job runs migrate up once and exits → the cloudpdf app starts only after migrations succeed.

Even with Postgres and object storage external, the app still keeps a local cache under /data — that’s why it has its own volume. The cache is disposable; the server rebuilds it from the object store.

Upgrading#

Bump the image tag and re-apply. The migration job runs first and the app waits for it:

# edit the image tag to the new version, then:
docker compose -f docker-compose.prod.yml up -d

Pin a real version (or digest) for both the migrate and cloudpdf services, and keep them identical — the schema the job applies must match the code the app runs.

Running more than one app replica#

To run multiple cloudpdf replicas on one host, scale the app service — it’s safe because state lives in Postgres and object storage, not the container:

docker compose -f docker-compose.prod.yml up -d --scale cloudpdf=3

Put a load balancer (or your reverse proxy) in front and point its health checks at /readyz. For real autoscaling and high availability across machines, move to Helm / Kubernetes.

Next steps#