Consolidate Docker deployment into a single PaperJet container
Some checks failed
CI / Backend (Python) (pull_request) Successful in 23s
CI / Frontend (TypeScript) (pull_request) Successful in 9m32s
CI / Container (Docker) (pull_request) Failing after 54s

This commit is contained in:
Elijah 2026-08-15 11:07:59 -07:00
parent db9e2ca51b
commit 65abb5154e
13 changed files with 290 additions and 151 deletions

View file

@ -1,10 +1,10 @@
# Self-Hosted PDF Editor — Implementation Plan
**Project codename:** `paperjet` (rename freely)
**Document version:** 1.1
**Document version:** 1.2
**Status:** Foundational specification — this document is the single source of truth. Every AI agent working on this project reads this file first, in full, before writing code.
> **v1.1 changes:** resolved the four open questions — upload ceiling set to 200 MB; versioning reframed around accidental-loss recovery with a document-level **trash/restore** added as the primary safety net; cookie/proxy behavior pinned (direct HTTP for dev only, everything through the HTTPS proxy in production); signature capture specified as **draw + type-to-signature with live multi-font preview**.
> **v1.2 changes:** the runtime deployment is consolidated into one Docker image containing nginx, the compiled SPA, and FastAPI/uvicorn. The existing v1.1 decisions remain unchanged.
---
@ -12,7 +12,7 @@
A self-hosted, single-user, browser-based PDF editor with a Sejda-style annotation workflow. The user adds text boxes, freehand drawing, signatures, images, highlights, and shapes onto a PDF, moves and resizes them freely, and exports a flattened PDF. The application persists work continuously (autosave), keeps a per-file version history, and supports undo/redo.
It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on **no external programs, no external containers, and no third-party APIs**. Libraries and packages bundled into the application's own containers are permitted.
It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on **no external programs, no external containers, and no third-party APIs**. Libraries and packages bundled into the application image are permitted.
### Non-goals (v1)
- Multi-user collaboration / real-time co-editing
@ -78,8 +78,8 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
| ORM | SQLAlchemy 2.x | With Alembic for migrations |
| Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container |
| Password hashing | Argon2id (`argon2-cffi`) | |
| Web server (frontend) | nginx | Serves the static React build; also the app-internal entry |
| ASGI server (backend) | uvicorn | Behind nginx |
| Web server | nginx | Serves the static React build and proxies /api to loopback uvicorn |
| ASGI server | uvicorn | Runs on loopback inside the same application image |
| Orchestration | Docker Compose | |
| External programs / containers / APIs | **None** | Hard requirement |
@ -98,43 +98,44 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
┌─────────────▼─────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ frontend │ │ backend │ │
│ │ nginx │─────▶│ FastAPI │ │
│ │ serves SPA │ /api │ uvicorn │ │
│ │ proxies /api│ │ PyMuPDF │ │
│ └──────────────┘ │ SQLAlchemy │ │
│ └───────┬────────┘ │
│ │ │
│ ┌──────────▼────────┐ │
│ │ volumes │ │
│ │ pdf_storage/ │ │
│ │ thumbnails/ │ │
│ │ db/app.sqlite │ │
│ └───────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ paperjet image │ │
│ │ nginx :80 ── /api ──▶ uvicorn :8000 │ │
│ │ React SPA FastAPI + PyMuPDF │ │
│ └───────────────────┬──────────────────┘ │
│ │ │
│ ┌──────────▼────────┐ │
│ │ volumes │ │
│ │ pdf_storage/ │ │
│ │ thumbnails/ │ │
│ │ db/app.sqlite │ │
│ └───────────────────┘ │
└────────────────────────────────────────────┘
```
- The `frontend` nginx serves the SPA and reverse-proxies `/api/*` to the `backend`. This means the browser sees a single origin — no CORS complexity, cookies "just work."
- The user's outer reverse proxy points at the `frontend` container's published port.
- The single `paperjet` container serves the SPA and reverse-proxies `/api/*` to its loopback-only FastAPI process. The browser sees a single origin — no CORS complexity, cookies "just work."
- The user's outer reverse proxy points at the published port of the `paperjet` container.
---
## 5. Repository Structure
A single monorepo. Two deployable images.
A single monorepo with one deployable runtime image. The Dockerfile uses a
Node build stage and a Python/nginx runtime stage.
```
paperjet/
├── README.md
├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first
├── docker-compose.yml
├── Dockerfile
├── docker-entrypoint.sh
├── .dockerignore
├── .env.example
├── .github/workflows/ci.yml # lint + typecheck + test on push
├── .forgejo/workflows/ci.yml # lint, tests, image build, and publish
├── frontend/
│ ├── Dockerfile # multi-stage: build SPA, serve via nginx
│ ├── nginx.conf # SPA fallback + /api proxy
│ ├── nginx.conf # SPA fallback + loopback /api proxy
│ ├── package.json
│ ├── tsconfig.json # strict: true
│ ├── vite.config.ts
@ -163,7 +164,6 @@ paperjet/
│ └── types/ # generated from backend OpenAPI
├── backend/
│ ├── Dockerfile
│ ├── pyproject.toml
│ ├── alembic/ # migrations
│ └── app/
@ -342,7 +342,7 @@ In-document accidental edits during a single session are also covered by the in-
- The registry is a dict `{type: handler}`; adding a type adds a handler, nothing else.
- Missing handler → log + skip, never crash (§7.3).
- Export accepts an optional `versionId` to export a historical version instead of the working state.
- Fonts: bundle inside the backend image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for `text` annotations; and (b) a small curated set of **signature/script fonts** for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability.
- Fonts: bundle inside the application image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for `text` annotations; and (b) a small curated set of **signature/script fonts** for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability.
### 9.4 Assets (binary annotation payloads)
- `POST /documents/{id}/assets` (multipart) stores a signature PNG or placed image on the `pdf_storage` volume, returns `{ ref }`.
@ -499,29 +499,29 @@ Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrain
## 13. Docker Compose & Deployment
```yaml
# docker-compose.yml (illustrative; agents finalize)
# docker-compose.yml
services:
backend:
build: ./backend
paperjet:
image: ${PAPERJET_IMAGE:-paperjet:local}
build:
context: .
dockerfile: Dockerfile
environment:
- SECRET_KEY=${SECRET_KEY}
- MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200}
- TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30}
- AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30}
- COOKIE_SECURE=${COOKIE_SECURE:-false} # false for direct-HTTP dev; set true in production (behind the HTTPS proxy)
PAPERJET_SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting PaperJet}
PAPERJET_MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200}
PAPERJET_TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
PAPERJET_AUTO_VERSION_RETENTION_DAYS: ${AUTO_VERSION_RETENTION_DAYS:-30}
PAPERJET_COOKIE_SECURE: ${COOKIE_SECURE:-false}
PAPERJET_DATABASE_PATH: /data/db/app.sqlite
PAPERJET_PDF_STORAGE_PATH: /data/pdfs
PAPERJET_THUMBNAILS_PATH: /data/thumbnails
PAPERJET_DEBUG: ${DEBUG:-false}
volumes:
- pdf_storage:/data/pdfs
- thumbnails:/data/thumbnails
- db:/data/db
expose:
- "8000"
restart: unless-stopped
frontend:
build: ./frontend
depends_on: [backend]
ports:
- "${HTTP_PORT:-8080}:80" # user's outer reverse proxy targets this
- "${HTTP_PORT:-4982}:80"
restart: unless-stopped
volumes:
@ -530,10 +530,10 @@ volumes:
db:
```
- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `backend:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_UPLOAD_MB` (≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their **outer** reverse proxy, since that's the other place a large upload can be silently truncated.
- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `127.0.0.1:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_UPLOAD_MB` (≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their **outer** reverse proxy, since that's the other place a large upload can be silently truncated.
- A 200 MB ceiling comfortably covers 100200 page documents with embedded images; PDF.js page virtualization (§12.2) keeps the editor responsive on documents that large.
- Backend Dockerfile installs Python deps and the bundled fonts; PyMuPDF comes from its wheel (no system packages, no external programs).
- Frontend Dockerfile is multi-stage: Node builds the SPA, the result is copied into an nginx image.
- The root Dockerfile builds the SPA with Node, installs production Python dependencies and bundled fonts, and copies both into one nginx/Python runtime image.
- docker-entrypoint.sh supervises nginx and loopback uvicorn so a failed child process stops the container cleanly.
- All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups.
---
@ -563,7 +563,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the
3. **One coordinate-transform module.** All space conversions go through `lib/coords.ts`. Never compute transforms inline in a component or duplicate the logic. Canonical space is always PDF points, top-left origin (§8).
4. **Store coordinates only in canonical space.** Never persist screen pixels.
5. **Types are generated, not hand-written.** After any backend schema change, regenerate the OpenAPI types for the frontend. Keep the TS annotation union and the Pydantic models in sync with `shared/annotation-schema.json`.
6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the existing two images are fine. If a task seems to need an external dependency, stop and flag it; do not add one.
6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the application image are fine. If a task seems to need an external dependency, stop and flag it; do not add one.
7. **Extend via registries.** New annotation types/tools register themselves; never scatter `switch(type)` logic across the codebase. Unknown types must round-trip without crashing.
8. **Security code is review-gated.** Do not modify auth, session, hashing, upload validation, or path handling without explicitly calling it out for review. Never log secrets or password material.
9. **Tests are required for the risk areas:** the coordinate module (round-trip + cross-engine), the export renderer per handler, auth flows, and upload validation. A feature touching these isn't done until its tests pass.
@ -580,7 +580,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the
Sequenced so each phase is independently testable and the riskiest correctness work (coordinates, export) is validated early.
**Phase 0 — Scaffolding**
Monorepo, both Dockerfiles, compose, `.env.example`, CI (lint/typecheck/test), nginx config, empty FastAPI app with `/health`, SPA shell with routing. *Done when:* `docker compose up` serves a blank authenticated-shell app and `/api/v1/health` responds.
Monorepo, the root Dockerfile and entrypoint, single-service Compose, `.env.example`, CI (lint/typecheck/test/container smoke), nginx config, empty FastAPI app with `/health`, SPA shell with routing. *Done when:* `docker compose up` serves a blank authenticated-shell app and `/api/v1/health` responds.
**Phase 1 — Auth & Library core**
First-run setup, login/logout, session cookie, rate limiting. Upload (button + drag-drop), document list, thumbnails, soft delete + **trash/restore** (single + bulk, empty trash, retention purge sweep), home page (recent + library + trash). *Done when:* a user can log in, upload, see, delete, and restore PDFs.
@ -616,4 +616,4 @@ All four open questions are now settled:
---
*End of plan v1.1. Amendments are made to this document first, then to code.*
*End of plan v1.2. Amendments are made to this document first, then to code.*