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-stringThen:
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-passwordBring it up:
docker compose -f docker-compose.prod.yml up -dWhat 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 -dPin 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=3Put 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.