From 434de9aa3e9f50221f8fb883305d393cb23d522b Mon Sep 17 00:00:00 2001 From: "Nox (OpenClaw)" Date: Tue, 17 Mar 2026 17:07:38 +0000 Subject: [PATCH] feat: production docker + documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend Dockerfile: multi-stage build with venv, gunicorn+uvicorn workers, entrypoint runs alembic then gunicorn - Frontend Dockerfile: multi-stage with npm ci, nginx:1.27-alpine runtime - nginx.conf: gzip compression, security headers (X-Frame-Options, X-Content-Type-Options, etc.), static asset caching, correct API proxy preserving /api/ prefix - docker-compose.yml: production config — db healthcheck, backend healthcheck, frontend depends_on backend healthy, no exposed backend port - docker-compose.override.yml: dev hot-reload — uvicorn --reload with source mount, npm run dev on port 5173 - Rate limiting: slowapi middleware on /auth/login (10/min) and /auth/register (5/min) - README.md: full documentation with architecture, quick start, API table, dev/prod instructions, tests - .env.example: all variables documented with comments - .gitignore: extended with *.pyc, *.cover, .ruff_cache, frontend/.vite, etc. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 25 ++++-- .gitignore | 25 ++++-- README.md | 160 +++++++++++++++++++++++++++++++----- backend/Dockerfile | 35 +++++++- backend/app/auth/router.py | 7 +- backend/app/limiter.py | 4 + backend/app/main.py | 8 ++ backend/entrypoint.sh | 14 ++++ backend/requirements.txt | 2 + docker-compose.override.yml | 13 ++- docker-compose.yml | 26 +++--- frontend/Dockerfile | 9 +- frontend/nginx.conf | 47 +++++++++-- 13 files changed, 315 insertions(+), 60 deletions(-) create mode 100644 backend/app/limiter.py create mode 100644 backend/entrypoint.sh diff --git a/.env.example b/.env.example index cb15d02..07d9aaa 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,26 @@ -# ── PostgreSQL ────────────────────────────────────────── +# ── PostgreSQL ────────────────────────────────────────────────────────────── POSTGRES_USER=budget POSTGRES_PASSWORD=budget POSTGRES_DB=budget_tracker -DB_PORT=5432 -# ── Backend (FastAPI) ────────────────────────────────── +# ── Backend (FastAPI) ─────────────────────────────────────────────────────── +# Full connection URL — built from the vars above by docker-compose DATABASE_URL=postgresql+asyncpg://budget:budget@db:5432/budget_tracker + +# IMPORTANT: generate a strong random key for production +# python -c "import secrets; print(secrets.token_hex(32))" SECRET_KEY=change-me-in-production + ACCESS_TOKEN_EXPIRE_MINUTES=15 REFRESH_TOKEN_EXPIRE_DAYS=7 -CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] -DEBUG=false -BACKEND_PORT=8000 -# ── Frontend ─────────────────────────────────────────── -FRONTEND_PORT=3000 +# Comma-separated list of allowed origins (JSON array syntax) +# Production: set to your actual frontend domain, e.g. ["https://budget.example.com"] +CORS_ORIGINS=["http://localhost"] + +# ── Ports (override for local conflicts) ──────────────────────────────────── +# Production: frontend is exposed on port 80 (FRONTEND_PORT) +FRONTEND_PORT=80 +# Dev only: +DB_PORT=5432 +BACKEND_PORT=8000 diff --git a/.gitignore b/.gitignore index bc2510f..7353c1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,45 @@ # Python __pycache__/ *.py[cod] +*.pyc +*.pyo *.egg-info/ -dist/ .venv/ venv/ +*.cover +.coverage +htmlcov/ +.pytest_cache/ +.ruff_cache/ +*.egg # Node node_modules/ frontend/dist/ +frontend/.vite/ +*.tsbuildinfo -# Environment +# Environment — never commit secrets .env +.env.local +.env.*.local # IDE .vscode/ .idea/ *.swp *.swo +*.sublime-project +*.sublime-workspace # OS .DS_Store Thumbs.db +desktop.ini # Docker pgdata/ -# Testing -.coverage -htmlcov/ -.pytest_cache/ +# Build artifacts +dist/ +build/ diff --git a/README.md b/README.md index 58e2210..6f9d6df 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,155 @@ # Budget Tracker -Application web de suivi de budget personnel. +A personal finance web application to track income, expenses, and budgets with analytics and CSV/PDF export. -## Stack technique + + -- **Backend** : FastAPI, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16 -- **Frontend** : React 18, TypeScript, Vite, Tailwind CSS, shadcn/ui -- **Graphiques** : Recharts -- **Export** : CSV, PDF (WeasyPrint) +--- -## Démarrage rapide +## Stack + +| Layer | Technology | +|-----------|-----------------------------------------------------| +| Backend | FastAPI 0.115, SQLAlchemy 2.0 async, Alembic | +| Database | PostgreSQL 16 | +| Frontend | React 18, TypeScript, Vite, Tailwind CSS, Radix UI | +| Charts | Recharts | +| Export | CSV (native), PDF (WeasyPrint) | +| Server | Gunicorn + Uvicorn workers, Nginx 1.27 | + +--- + +## Quick Start ```bash -# Copier et configurer les variables d'environnement +# 1. Copy and configure environment cp .env.example .env -# Lancer tous les services (dev avec hot-reload) -docker compose up +# 2. Start all services (production mode) +docker compose up -d -# Backend : http://localhost:8000 -# Frontend : http://localhost:5173 -# Health check : http://localhost:8000/health +# App is available at http://localhost +# API docs at http://localhost/api/v1/docs ``` -## Développement +> The backend automatically runs `alembic upgrade head` on startup. + +--- + +## Architecture + +``` +┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ Browser │──80──▶│ Nginx (frontend) │──/api/▶│ FastAPI │ +└─────────────┘ │ serves SPA dist │ │ (gunicorn) │ + └──────────────────┘ └──────┬───────┘ + │ + ┌──────▼───────┐ + │ PostgreSQL │ + └──────────────┘ +``` + +- **Frontend**: React SPA served by Nginx. All `/api/*` requests are proxied to the backend. +- **Backend**: FastAPI with async SQLAlchemy. Exposes REST API under `/api/v1`. +- **Database**: PostgreSQL with Alembic migrations applied at container start. + +--- + +## API Endpoints + +Interactive docs (Swagger UI): `http://localhost/api/v1/docs` + +| Module | Prefix | Description | +|--------------|-------------------------|------------------------------------| +| Auth | `/api/v1/auth` | Register, login, refresh, logout | +| Transactions | `/api/v1/transactions` | CRUD + pagination + filters | +| Categories | `/api/v1/categories` | Income / expense categories | +| Budgets | `/api/v1/budgets` | Monthly budgets + rollover | +| Dashboard | `/api/v1/dashboard` | Monthly summary + charts data | +| History | `/api/v1/history` | Year-over-year analytics | +| Export | `/api/v1/export` | CSV and PDF export | +| Health | `/health` | Liveness probe | + +--- + +## Development (hot-reload) + +The `docker-compose.override.yml` is automatically merged in dev, enabling: +- Backend: `uvicorn --reload` with source code mounted +- Frontend: `npm run dev` (Vite HMR) on port 5173 ```bash -# Linting backend -cd backend && ruff check . +# Start dev environment +cp .env.example .env +docker compose up -# Tests backend -cd backend && pytest - -# Format frontend -cd frontend && npm run format +# Backend API: http://localhost:8000 +# Frontend dev server: http://localhost:5173 +# API docs: http://localhost:8000/api/v1/docs +``` + +--- + +## Production + +```bash +# Use only the base compose file (no override) +docker compose -f docker-compose.yml up -d + +# Generate a secure SECRET_KEY +python -c "import secrets; print(secrets.token_hex(32))" + +# Set in .env: +# SECRET_KEY= +# CORS_ORIGINS=["https://your-domain.com"] +``` + +--- + +## Tests + +```bash +# Backend tests +cd backend +pip install -r requirements.txt +pytest + +# With coverage +pytest --cov=app --cov-report=term-missing + +# Frontend type check +cd frontend +npm run build # tsc -b + vite build +``` + +--- + +## Project Structure + +``` +budget-tracker/ +├── backend/ +│ ├── app/ +│ │ ├── auth/ # JWT auth (register, login, refresh, logout) +│ │ ├── models/ # SQLAlchemy ORM models +│ │ ├── routers/ # FastAPI routers (transactions, budgets, …) +│ │ ├── schemas/ # Pydantic request/response schemas +│ │ ├── services/ # Business logic layer +│ │ ├── config.py # Settings (pydantic-settings) +│ │ ├── database.py # Async engine + session factory +│ │ └── main.py # App factory + middleware +│ ├── alembic/ # Database migrations +│ ├── tests/ # pytest test suite +│ ├── Dockerfile # Multi-stage build (venv → slim runtime) +│ └── entrypoint.sh # alembic upgrade head → gunicorn +└── frontend/ + ├── src/ + │ ├── api/ # Axios client + API functions + │ ├── components/ # Shared UI components + │ ├── hooks/ # React Query hooks + │ ├── pages/ # Route-level page components + │ └── stores/ # Zustand auth store + ├── Dockerfile # Multi-stage build (npm ci → nginx) + └── nginx.conf # Gzip, security headers, SPA fallback, API proxy ``` diff --git a/backend/Dockerfile b/backend/Dockerfile index c7852c4..db246fa 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,13 +1,19 @@ -FROM python:3.12-slim AS base +# ── Stage 1: build ────────────────────────────────────────────────────────── +FROM python:3.12-slim AS build ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app -# System deps for WeasyPrint +# Create virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# System deps required by WeasyPrint (build time) RUN apt-get update && \ apt-get install -y --no-install-recommends \ + gcc \ libpango-1.0-0 \ libpangocairo-1.0-0 \ libgdk-pixbuf2.0-0 \ @@ -19,8 +25,31 @@ RUN apt-get update && \ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# ── Stage 2: runtime ───────────────────────────────────────────────────────── +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" + +# System deps required by WeasyPrint (runtime) +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf2.0-0 \ + libcairo2 \ + libglib2.0-0 && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=build /opt/venv /opt/venv COPY . . +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py index 0642dab..20cdc10 100644 --- a/backend/app/auth/router.py +++ b/backend/app/auth/router.py @@ -1,13 +1,14 @@ import uuid from datetime import timedelta -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm from jose import JWTError from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_user +from app.limiter import limiter from app.auth.security import ( create_access_token, create_refresh_token, @@ -28,7 +29,9 @@ router = APIRouter(prefix="/auth", tags=["auth"]) @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +@limiter.limit("5/minute") async def register( + request: Request, user_data: UserCreate, session: AsyncSession = Depends(get_session), ) -> UserResponse: @@ -54,7 +57,9 @@ async def register( @router.post("/login", response_model=Token) +@limiter.limit("10/minute") async def login( + request: Request, form_data: OAuth2PasswordRequestForm = Depends(), session: AsyncSession = Depends(get_session), ) -> Token: diff --git a/backend/app/limiter.py b/backend/app/limiter.py new file mode 100644 index 0000000..38404a8 --- /dev/null +++ b/backend/app/limiter.py @@ -0,0 +1,4 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/backend/app/main.py b/backend/app/main.py index cdaa569..60107a1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,10 +3,14 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware from app.auth.router import router as auth_router from app.config import settings from app.database import engine +from app.limiter import limiter from app.routers.budgets import router as budgets_router from app.routers.categories import router as categories_router from app.routers.dashboard import router as dashboard_router @@ -28,6 +32,10 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + app.add_middleware(SlowAPIMiddleware) + app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..4a827b6 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -e + +echo "Running database migrations..." +alembic upgrade head + +echo "Starting server..." +exec gunicorn app.main:app \ + --workers 4 \ + --worker-class uvicorn.workers.UvicornWorker \ + --bind 0.0.0.0:8000 \ + --timeout 120 \ + --access-logfile - \ + --error-logfile - diff --git a/backend/requirements.txt b/backend/requirements.txt index 73efd16..d839cb9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,6 +12,8 @@ bcrypt==4.2.1 python-multipart==0.0.20 httpx==0.28.1 weasyprint==63.1 +gunicorn==23.0.0 +slowapi==0.1.9 # Test dependencies aiosqlite==0.20.0 pytest==8.3.4 diff --git a/docker-compose.override.yml b/docker-compose.override.yml index e81870f..d270441 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,13 +1,18 @@ services: + db: + ports: + - "${DB_PORT:-5432}:5432" + backend: - build: - context: ./backend - dockerfile: Dockerfile command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload volumes: - ./backend:/app + ports: + - "${BACKEND_PORT:-8000}:8000" environment: + SECRET_KEY: ${SECRET_KEY:-dev-secret-not-for-production} DEBUG: "true" + CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000","http://localhost"]' frontend: image: node:20-alpine @@ -20,6 +25,8 @@ services: - "5173:5173" environment: NODE_ENV: development + depends_on: + - backend volumes: frontend_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml index d7d7aaf..a5dfa54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,15 +6,14 @@ services: POSTGRES_USER: ${POSTGRES_USER:-budget} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-budget} POSTGRES_DB: ${POSTGRES_DB:-budget_tracker} - ports: - - "${DB_PORT:-5432}:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget}"] - interval: 5s + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget} -d ${POSTGRES_DB:-budget_tracker}"] + interval: 10s timeout: 5s retries: 5 + start_period: 10s backend: build: @@ -24,13 +23,19 @@ services: environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-budget}:${POSTGRES_PASSWORD:-budget}@db:5432/${POSTGRES_DB:-budget_tracker} SECRET_KEY: ${SECRET_KEY:-change-me-in-production} - CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000"]' - DEBUG: ${DEBUG:-false} - ports: - - "${BACKEND_PORT:-8000}:8000" + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15} + REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7} + CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost"]} + DEBUG: "false" depends_on: db: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s frontend: build: @@ -38,9 +43,10 @@ services: dockerfile: Dockerfile restart: unless-stopped ports: - - "${FRONTEND_PORT:-3000}:80" + - "${FRONTEND_PORT:-80}:80" depends_on: - - backend + backend: + condition: service_healthy volumes: pgdata: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 1ebf397..73835b0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,15 +1,20 @@ +# ── Stage 1: build ────────────────────────────────────────────────────────── FROM node:20-alpine AS build WORKDIR /app COPY package.json package-lock.json* ./ -RUN npm install +RUN npm ci COPY . . RUN npm run build -FROM nginx:alpine +# ── Stage 2: runtime ───────────────────────────────────────────────────────── +FROM nginx:1.27-alpine + COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf + EXPOSE 80 + CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 5ae2afb..ccb4c58 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -3,13 +3,48 @@ server { root /usr/share/nginx/html; index index.html; + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + image/svg+xml; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + # Static assets — long-lived cache with immutable flag + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # API proxy → backend (preserve full /api/ prefix) + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + proxy_connect_timeout 10s; + } + + # SPA fallback — all unknown routes serve index.html location / { try_files $uri $uri/ /index.html; } - - location /api/ { - proxy_pass http://backend:8000/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } }