Consolidate Docker deployment into a single PaperJet container
This commit is contained in:
parent
db9e2ca51b
commit
65abb5154e
13 changed files with 290 additions and 151 deletions
20
.dockerignore
Normal file
20
.dockerignore
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
.git
|
||||
.forgejo
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
|
||||
**/__pycache__
|
||||
**/*.py[cod]
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
**/*.egg-info
|
||||
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.log
|
||||
|
|
@ -40,9 +40,13 @@ COOKIE_SECURE=false
|
|||
# --------------------------------------------
|
||||
# Networking
|
||||
# --------------------------------------------
|
||||
# Port the frontend container publishes. Your outer reverse proxy targets this.
|
||||
# Port the PaperJet container publishes. Your outer reverse proxy targets this.
|
||||
HTTP_PORT=4982
|
||||
|
||||
# Optional image reference for a pulled Forgejo build.
|
||||
# Leave unset for local builds; set this before docker compose pull in production.
|
||||
# PAPERJET_IMAGE=git.elijahkuntz.com/your-user/paperjet:latest
|
||||
|
||||
# --------------------------------------------
|
||||
# Debug
|
||||
# --------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
name: Automated Container Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log into Local Registry
|
||||
run: |
|
||||
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
|
||||
|
||||
- name: Build and Push Image
|
||||
run: |
|
||||
# Force the entire image path string to lowercase dynamically
|
||||
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
docker build -t "$IMAGE_PATH" .
|
||||
docker push "$IMAGE_PATH"
|
||||
|
|
@ -69,3 +69,73 @@ jobs:
|
|||
- name: Test (vitest)
|
||||
working-directory: frontend
|
||||
run: npx vitest run
|
||||
|
||||
container:
|
||||
name: Container (Docker)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate Compose configuration
|
||||
run: docker compose --env-file .env.example config --quiet
|
||||
|
||||
- name: Build container image
|
||||
run: |
|
||||
IMAGE_PATH=$(printf 'git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')
|
||||
docker build \
|
||||
-t paperjet:ci \
|
||||
-t "$IMAGE_PATH:latest" \
|
||||
-t "$IMAGE_PATH:${{ github.sha }}" \
|
||||
.
|
||||
|
||||
- name: Run container smoke test
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
|
||||
docker run \
|
||||
--detach \
|
||||
--name paperjet-ci \
|
||||
--publish 18080:80 \
|
||||
--env PAPERJET_SECRET_KEY=ci-only-secret \
|
||||
--env PAPERJET_COOKIE_SECURE=false \
|
||||
--env PAPERJET_DEBUG=false \
|
||||
paperjet:ci
|
||||
|
||||
cleanup() {
|
||||
docker rm --force paperjet-ci >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
curl --fail --retry 30 --retry-delay 1 --retry-connrefused \
|
||||
http://127.0.0.1:18080/
|
||||
curl --fail --retry 30 --retry-delay 1 --retry-connrefused \
|
||||
http://127.0.0.1:18080/api/v1/health
|
||||
|
||||
for attempt in $(seq 1 30); do
|
||||
status=$(docker inspect --format '{{.State.Health.Status}}' paperjet-ci)
|
||||
if [ "$status" = "healthy" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$status" = "unhealthy" ]; then
|
||||
docker logs paperjet-ci
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker logs paperjet-ci
|
||||
exit 1
|
||||
|
||||
- name: Log into Local Registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
|
||||
|
||||
- name: Push main image tags
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
IMAGE_PATH=$(printf 'git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')
|
||||
docker push "$IMAGE_PATH:latest"
|
||||
docker push "$IMAGE_PATH:${{ github.sha }}"
|
||||
|
|
|
|||
55
Dockerfile
Normal file
55
Dockerfile
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Build the React/Vite application first so the runtime image contains only
|
||||
# the compiled frontend and the Python/nginx runtime.
|
||||
FROM node:22-alpine AS frontend-build
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# The runtime image contains both public-facing nginx and the loopback-only
|
||||
# FastAPI/uvicorn process. This keeps deployment to one container while
|
||||
# preserving the existing nginx -> API boundary.
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
fontconfig \
|
||||
fonts-liberation \
|
||||
nginx \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& fc-cache -fv \
|
||||
&& rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the source tree into /app before installing so the runtime can resolve
|
||||
# the local package (including bundled signature fonts) from PYTHONPATH.
|
||||
COPY backend/pyproject.toml ./
|
||||
COPY backend/app ./app
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
COPY --from=frontend-build /frontend/dist /usr/share/nginx/html
|
||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker-entrypoint.sh /usr/local/bin/paperjet-entrypoint
|
||||
|
||||
RUN chmod +x /usr/local/bin/paperjet-entrypoint \
|
||||
&& nginx -t \
|
||||
&& mkdir -p /data/pdfs /data/thumbnails /data/db
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1/api/v1/health', timeout=3)"]
|
||||
|
||||
ENTRYPOINT ["paperjet-entrypoint"]
|
||||
|
|
@ -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,13 +98,11 @@ 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 │ │
|
||||
│ └───────┬────────┘ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ paperjet image │ │
|
||||
│ │ nginx :80 ── /api ──▶ uvicorn :8000 │ │
|
||||
│ │ React SPA FastAPI + PyMuPDF │ │
|
||||
│ └───────────────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼────────┐ │
|
||||
│ │ volumes │ │
|
||||
|
|
@ -115,26 +113,29 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
|
|||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 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 100–200 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.*
|
||||
|
|
|
|||
38
README.md
38
README.md
|
|
@ -6,15 +6,34 @@ PaperJet is a self-hosted, single-user PDF annotation editor. It keeps the uploa
|
|||
|
||||
1. Copy `.env.example` to `.env`.
|
||||
2. Replace `SECRET_KEY` with a long random value. Compose refuses to start without it.
|
||||
3. Start the stack:
|
||||
3. Build and start the single-container application:
|
||||
|
||||
```sh
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Open `http://localhost:4982` (or the value of `HTTP_PORT`). The first visit asks you to create the single master password.
|
||||
The container serves the React application through nginx and proxies `/api`
|
||||
requests to the loopback-only FastAPI process. Open `http://localhost:4982`
|
||||
(or the value of `HTTP_PORT`). The first visit asks you to create the single
|
||||
master password.
|
||||
|
||||
The `db`, `pdf_storage`, and `thumbnails` volumes contain application data. Back them up before maintenance or upgrades. Put the frontend behind HTTPS when exposing PaperJet beyond localhost and set `COOKIE_SECURE=true`.
|
||||
The `db`, `pdf_storage`, and `thumbnails` volumes contain application data.
|
||||
Back them up before maintenance or upgrades. Put the frontend behind HTTPS
|
||||
when exposing PaperJet beyond localhost and set `COOKIE_SECURE=true`.
|
||||
|
||||
### Run a Forgejo-published image
|
||||
|
||||
Set `PAPERJET_IMAGE` in `.env` to the desired registry tag, for example the
|
||||
`latest` or commit-SHA tag published by Forgejo. Then pull and start without
|
||||
building locally:
|
||||
|
||||
```sh
|
||||
docker compose pull
|
||||
docker compose up -d --no-build
|
||||
```
|
||||
|
||||
The published image exposes only port 80. Your outer reverse proxy should
|
||||
target the Compose port configured by `HTTP_PORT`.
|
||||
|
||||
## Development
|
||||
|
||||
|
|
@ -39,6 +58,12 @@ The Vite server proxies `/api` to `http://localhost:8000`.
|
|||
## Validation
|
||||
|
||||
```sh
|
||||
# container
|
||||
docker compose --env-file .env.example config --quiet
|
||||
docker compose up --build -d
|
||||
curl http://localhost:4982/api/v1/health
|
||||
docker compose ps
|
||||
|
||||
# frontend
|
||||
cd frontend
|
||||
npm test -- --run
|
||||
|
|
@ -53,4 +78,9 @@ mypy app/
|
|||
ruff check .
|
||||
```
|
||||
|
||||
The editor uses PDF.js for display and PyMuPDF for export. Stored annotation coordinates are PDF points with a top-left origin relative to the unrotated CropBox; this is the contract shared by the editor and export renderer.
|
||||
Stop the local stack with `docker compose down`. Do not use `-v` unless you
|
||||
intend to remove the local application volumes.
|
||||
|
||||
The editor uses PDF.js for display and PyMuPDF for export. Stored annotation
|
||||
coordinates are PDF points with a top-left origin relative to the unrotated
|
||||
CropBox; this is the contract shared by the editor and export renderer.
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
FROM python:3.12-slim AS backend
|
||||
|
||||
# System dependencies: fonts for PDF export + fontconfig
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
fonts-liberation \
|
||||
fontconfig && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
fc-cache -fv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir -e ".[dev]"
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create data directories (will be overridden by volume mounts)
|
||||
RUN mkdir -p /data/pdfs /data/thumbnails /data/db
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
from app.db import Base
|
||||
from app.models import * # noqa: F401, F403 — ensure all models are imported
|
||||
from app.models import * # noqa: F403 — ensure all models are imported
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
|
|
|
|||
|
|
@ -1,27 +1,23 @@
|
|||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
paperjet:
|
||||
image: ${PAPERJET_IMAGE:-paperjet:local}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- 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}
|
||||
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
|
||||
ports:
|
||||
- "8000:8000"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
depends_on: [backend]
|
||||
ports:
|
||||
- "${HTTP_PORT:-4982}:80"
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
36
docker-entrypoint.sh
Normal file
36
docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
uvicorn --app-dir /app app.main:app \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000 \
|
||||
--workers 1 &
|
||||
backend_pid=$!
|
||||
|
||||
nginx -g "daemon off;" &
|
||||
nginx_pid=$!
|
||||
|
||||
stop_children() {
|
||||
kill -TERM "$backend_pid" "$nginx_pid" 2>/dev/null || true
|
||||
wait "$backend_pid" 2>/dev/null || true
|
||||
wait "$nginx_pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
trap - TERM INT
|
||||
stop_children
|
||||
exit 143
|
||||
}
|
||||
|
||||
trap shutdown TERM INT
|
||||
|
||||
# If either service exits, stop the other one and let Docker restart/report the
|
||||
# container rather than leaving a partially working instance running.
|
||||
set +e
|
||||
wait -n "$backend_pid" "$nginx_pid"
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
stop_children
|
||||
exit "$exit_code"
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# Stage 1: Build the SPA
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve via nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy our nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built SPA from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
@ -2,10 +2,12 @@ server {
|
|||
listen 80;
|
||||
server_name _;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
types {
|
||||
application/javascript mjs;
|
||||
application/wasm wasm;
|
||||
}
|
||||
|
||||
# Match MAX_UPLOAD_MB — raise in both this and the outer reverse proxy
|
||||
|
|
@ -25,9 +27,9 @@ server {
|
|||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy /api/ to the backend container
|
||||
# Proxy /api/ to the loopback-only FastAPI process in this container
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue