An open source, self-hosted alternative to ILovePDF. Structural operations run in the browser, while OCR, conversions, and native compression are processed by isolated workers.
Paperforge is permanently free and accountless. It has no subscriptions, authentication, authorization, cloud dependencies, advertisements, usage tracking, or outbound telemetry. Operational limits only protect the local instance from exhausting CPU, memory, disk, or queue capacity.
apps/web: Next.js, pdf.js, and pdf-lib in a Web Worker.services/cmd/api: Go HTTP API and Asynq producer.services/cmd/worker: Asynq consumer and native executors.redis: task queue and ephemeral job state.gotenberg: Office/HTML to PDF conversion.
The detailed architecture is available in docs/architecture.md.
Requirements: Node.js 22+, Go 1.24+, Docker, and Docker Compose.
corepack enable
pnpm install
pnpm dev:web
pnpm test:webTo start the complete stack:
docker compose up -d --buildThe first run builds the three local images and stores them in Docker as
paperforge/web:local, paperforge/api:local, and paperforge/worker:local.
After that, start the existing images with:
pnpm docker:upWhen source code or a Dockerfile changes, rebuild and restart with:
pnpm docker:up:buildUseful commands:
pnpm docker:status
pnpm docker:logs
pnpm docker:downThe stack is available at http://localhost:8080. Caddy routes /api/* to Go
and all other requests to Next.js. The API, Gotenberg, and Redis do not publish
ports to the host. By default, the gateway only listens on 127.0.0.1; configure
TLS and optional anonymous rate limiting in the reverse proxy before exposing it
to an untrusted network.
POST /api/v1/jobs: creates a heavy job withmultipart/form-data.GET /api/v1/jobs/{id}: returns its current state.GET /api/v1/jobs/{id}/result: downloads the result.DELETE /api/v1/jobs/{id}: cancels or deletes the job.GET /healthz: checks the API and Redis.
Initial server-side operations: ocr, compress, repair, and
office-to-pdf.
The web interface is available at /en and /es. Requests to / are
redirected using this priority:
- The locale previously selected by the user.
- A country code supplied by a trusted GeoIP-aware proxy or hosting provider.
Spanish-speaking countries use
/es; every other known country uses/en. - The browser's
Accept-Languagepreference when no country code is available. - English for every unknown or unsupported country and language.
Country detection reads x-vercel-ip-country, cf-ipcountry, or
x-country-code when available. A self-hosted browser does not reveal the
user's country by itself, so deployments without a GeoIP provider fall back to
the browser language. Users can always switch languages from the page header.