Quick start
This gets a server running on your machine with zero external services — SQLite, the local disk, and a local cache, all under one volume — then uploads a real PDF and renders its first page. It’s the fastest way to see the server work end to end.
Prefer the npm package or a production-grade install? Jump to Deployment — the steps below use Docker because it needs nothing installed but Docker itself.
1. Get a development license key#
Every self-hosted server runs on a license key — the server refuses to boot without one. A development key is made for exactly this page: with it, the server boots with zero further configuration (dev fallbacks, loud warnings, development-scale limits). How the model works — connected vs air-gapped, what an expired license does — is on Licensing.
Get a development key
Free for local development and evaluation — one click, and it works for your whole team.
2. Run the server#
docker run --rm --init -p 3000:3000 \
-v cloudpdf-data:/data \
-e CLOUDPDF_LICENSE_KEY="key/..." \
-e CLOUDPDF_API_AUTH_TOKENS=dev-api-token-00000000000000000000 \
-e CLOUDPDF_AUTO_PROVISION_TENANT=1 \
ghcr.io/embedpdf/cloudpdf-server:latestThat’s the whole install. The image bakes in everything the server needs — the Node runtime, native PDFium, image processing, and fonts — so there is no toolchain to set up.
CLOUDPDF_LICENSE_KEY— your development key from step 1. Required.CLOUDPDF_API_AUTH_TOKENS— a static admin credential for this walkthrough’scurlcalls (any long random string).CLOUDPDF_AUTO_PROVISION_TENANT=1— creates tenants on first use. Dev convenience; leave it off in production.-v cloudpdf-data:/datakeeps your documents across restarts;--initgives the container clean signal handling.
A development key deliberately permits insecure dev fallbacks (like the
built-in JWT secret) so this one command works. Production keys refuse
them: you’ll set a real CLOUDPDF_JWT_SECRET and secrets of at
least 32 bytes — see
Authentication.
3. Confirm it’s healthy#
curl localhost:3000/healthz
# {"status":"ok"}
curl localhost:3000/readyz
# {"license":{...},"status":"ok"}/healthz means the process is alive; /readyz means it can serve (the
database answers, not draining). Both are public — everything else requires a
credential:
curl -i localhost:3000/v1/tenants/default/documents
# HTTP/1.1 401 UnauthorizedA 401 here is the server working correctly: it refuses unauthenticated
requests.
4. Upload a PDF and render it#
Uploads are a three-step protocol — announce, send bytes, commit — because in
production the bytes go straight to your object store on a presigned URL
and never pass through the API. Locally the server proxies them, and the SDKs
wrap all three steps in one call; here it’s three curls:
API_TOKEN=dev-api-token-00000000000000000000
PDF=./my-document.pdf
SHA=$(shasum -a 256 "$PDF" | cut -d' ' -f1)
# 1. announce the upload
DOC=$(curl -s -X POST localhost:3000/v1/tenants/default/documents/init \
-H "Authorization: Bearer $API_TOKEN" -H 'content-type: application/json' \
-d "{\"contentLength\":$(wc -c < "$PDF"),\"contentSha256\":\"$SHA\",\"uploadPreference\":\"proxy\"}" \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
# 2. send the bytes
curl -s -X POST "localhost:3000/v1/tenants/default/documents/$DOC/upload-proxy" \
-H "Authorization: Bearer $API_TOKEN" \
-F "file=@$PDF;type=application/pdf" > /dev/null
# 3. commit (the server verifies the SHA-256)
curl -s -X POST "localhost:3000/v1/tenants/default/documents/$DOC/commit" \
-H "Authorization: Bearer $API_TOKEN" -H 'content-type: application/json' \
-d "{\"sha256\":\"$SHA\"}"Now render its first page:
curl -s "localhost:3000/v1/tenants/default/documents/$DOC/thumbnail" \
-H "Authorization: Bearer $API_TOKEN" -o first-page.webp && open first-page.webpThat WebP came out of the same render pipeline your users will hit — native PDFium, rendered on demand and cached as an immutable artifact.
5. Talk to it from your app#
Your frontend uses @cloudpdf/engine, pointed at the server
you just started:
import { createCloudEngine } from '@cloudpdf/engine';
const engine = createCloudEngine({
baseUrl: 'http://localhost:3000',
token: () => getDocToken(), // doc-scoped JWT minted by your backend
});
const doc = await engine.open({ kind: 'id', id: docId });
const page = doc.page(1);
const image = await page.render.image({ viewport: { kind: 'width', width: 1200 } });Your backend mints the getDocToken() JWT — see
Authentication for the token
shape (and the signDevToken helper for local experiments), and the
engine docs for rendering a page
into the DOM.
6. Stop it#
docker stop $(docker ps -q --filter ancestor=ghcr.io/embedpdf/cloudpdf-server:latest)Your data stays in the cloudpdf-data volume, ready for the next run.
Where to go next#
You ran the simplest possible configuration. To take it further: