feat: Docker deploy serving SPA and API on :8080
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.worktrees
|
||||
.venv
|
||||
venv
|
||||
data
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
.pytest_cache
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/.vscode
|
||||
*.db
|
||||
.env
|
||||
.DS_Store
|
||||
**/*.md
|
||||
!README.md
|
||||
docs
|
||||
backend/tests
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Stage 1 — build Vue SPA
|
||||
FROM node:20 AS frontend-build
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2 — FastAPI + built SPA
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
ENV DATA_DIR=/data \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/app ./app
|
||||
COPY --from=frontend-build /frontend/dist ./frontend/dist
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,60 @@
|
||||
# MincedPad
|
||||
|
||||
Self-hosted anonymous paste / file share: public wall, TTL, burn-after-read, and a password-gated admin panel. One Docker container serves the Vue SPA and FastAPI API on port **8080**.
|
||||
|
||||
## Deploy (Docker Compose)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open http://127.0.0.1:8080/
|
||||
Admin: http://127.0.0.1:8080/admin
|
||||
|
||||
Data (SQLite + uploads) persists in `./data` on the host (`DATA_DIR=/data` in the container).
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `ADMIN_PASSWORD` | `changeme` | Admin login password |
|
||||
| `SECRET_KEY` | (dev default) | JWT signing secret — change in production |
|
||||
| `MAX_UPLOAD_MB` | `200` | Max upload size in megabytes |
|
||||
| `RATE_LIMIT_PER_SECOND` | `2` | Create/upload rate limit per IP |
|
||||
| `DATA_DIR` | `/data` | SQLite + uploads directory |
|
||||
|
||||
Set these under `environment:` in `docker-compose.yml` (or via your orchestrator).
|
||||
|
||||
## Mobile
|
||||
|
||||
The UI is mobile-first (Element Plus responsive grid, full-width controls under 768px). Use a real phone or browser device mode for smoke checks after deploy.
|
||||
|
||||
## Local development
|
||||
|
||||
**API**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv ../.venv && source ../.venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
DATA_DIR=../data PYTHONPATH=. uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload
|
||||
```
|
||||
|
||||
**Frontend** (proxies `/api` to `:8080`)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Production-style (API serves `frontend/dist`):
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
cd ../backend && DATA_DIR=../data PYTHONPATH=. uvicorn app.main:app --port 8080
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Use as you like for self-hosting.
|
||||
+34
-1
@@ -1,7 +1,10 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.public import router as public_router
|
||||
@@ -11,6 +14,20 @@ from app import models # noqa: F401 — register models with Base.metadata
|
||||
from app.services import cleaner as cleaner_service
|
||||
|
||||
|
||||
def _resolve_frontend_dist() -> Path | None:
|
||||
"""Locate frontend/dist in Docker (/app/frontend/dist) or monorepo checkout."""
|
||||
here = Path(__file__).resolve()
|
||||
candidates = (
|
||||
here.parents[2] / "frontend" / "dist", # .../backend/app/main.py → repo root
|
||||
here.parents[1] / "frontend" / "dist", # /app/app/main.py → /app/frontend/dist
|
||||
Path("/app/frontend/dist"),
|
||||
)
|
||||
for path in candidates:
|
||||
if (path / "index.html").is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
async def _cleaner_loop() -> None:
|
||||
while True:
|
||||
db = SessionLocal()
|
||||
@@ -47,3 +64,19 @@ app.include_router(admin_router)
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"ok": True, "name": "MincedPad"}
|
||||
|
||||
|
||||
_dist = _resolve_frontend_dist()
|
||||
if _dist is not None:
|
||||
assets_dir = _dist / "assets"
|
||||
if assets_dir.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
|
||||
@app.api_route("/{full_path:path}", methods=["GET", "HEAD"])
|
||||
async def spa(full_path: str):
|
||||
if full_path == "api" or full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
file_path = _dist / full_path
|
||||
if full_path and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(_dist / "index.html")
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
mincedpad:
|
||||
build: .
|
||||
ports: ["8080:8080"]
|
||||
environment:
|
||||
ADMIN_PASSWORD: "changeme"
|
||||
SECRET_KEY: "change-me-in-production"
|
||||
MAX_UPLOAD_MB: "200"
|
||||
RATE_LIMIT_PER_SECOND: "2"
|
||||
DATA_DIR: "/data"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
Reference in New Issue
Block a user