feat: Docker deploy serving SPA and API on :8080

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
肉末
2026-07-14 13:03:51 +08:00
parent 600c536367
commit 7c2489ea5c
5 changed files with 149 additions and 1 deletions
+18
View File
@@ -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
View File
@@ -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"]
+60
View File
@@ -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
View File
@@ -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")
+13
View File
@@ -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