CloudPDF
DocsPricing
Start building

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:latest

That’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’s curl calls (any long random string).
  • CLOUDPDF_AUTO_PROVISION_TENANT=1 — creates tenants on first use. Dev convenience; leave it off in production.
  • -v cloudpdf-data:/data keeps your documents across restarts; --init gives 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 Unauthorized

A 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.webp

That 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: