From 65abb5154e5032b8361f799c23daef7505c36edf Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 15 Aug 2026 11:07:59 -0700 Subject: [PATCH] Consolidate Docker deployment into a single PaperJet container --- .dockerignore | 20 ++++++++ .env.example | 6 ++- .forgejo/workflows/build.yml | 25 --------- .forgejo/workflows/ci.yml | 70 ++++++++++++++++++++++++++ Dockerfile | 55 ++++++++++++++++++++ Project Architecture.md | 98 ++++++++++++++++++------------------ README.md | 38 ++++++++++++-- backend/Dockerfile | 25 --------- backend/alembic/env.py | 4 +- docker-compose.yml | 32 ++++++------ docker-entrypoint.sh | 36 +++++++++++++ frontend/Dockerfile | 24 --------- frontend/nginx.conf | 8 +-- 13 files changed, 290 insertions(+), 151 deletions(-) create mode 100644 .dockerignore delete mode 100644 .forgejo/workflows/build.yml create mode 100644 Dockerfile delete mode 100644 backend/Dockerfile create mode 100644 docker-entrypoint.sh delete mode 100644 frontend/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d0edc8 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index cb63f2f..e0cf067 100644 --- a/.env.example +++ b/.env.example @@ -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 # -------------------------------------------- diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml deleted file mode 100644 index 4ed4ca5..0000000 --- a/.forgejo/workflows/build.yml +++ /dev/null @@ -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" \ No newline at end of file diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 39f912e..994bcdc 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 }}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0282e40 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Project Architecture.md b/Project Architecture.md index e22db8e..2a934a5 100644 --- a/Project Architecture.md +++ b/Project Architecture.md @@ -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 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.* diff --git a/README.md b/README.md index e20ef30..f70815f 100644 --- a/README.md +++ b/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. diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 0879421..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -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"] diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 23ba9db..63a7402 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index c8388d4..4567c1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..9d96914 --- /dev/null +++ b/docker-entrypoint.sh @@ -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" diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index e1c8fe5..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -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;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7f1e76c..b8a0a62 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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;