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

20
.dockerignore Normal file
View 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

View file

@ -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
# --------------------------------------------

View file

@ -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"

View file

@ -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
View 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"]

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.*

View file

@ -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.

View file

@ -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"]

View file

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

View file

@ -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
View 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"

View file

@ -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;"]

View file

@ -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;